Files
prop-ai-hr/mobile-uni/src/services/knowledge.ts
T

358 lines
12 KiB
TypeScript

import type {
AnswerFeedbackVerdict,
KnowledgeSearchResponse,
KnowledgeMediaSelectedFile,
KnowledgeConversationState,
UnifiedKnowledgeResponse,
KnowledgeSnippet,
PositionSopSummary,
SummaryCardResponse,
KnowledgeCitationDetail
} from '@/types/api';
import { apiMediaUrl, apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import { getSelectedPosition } from './auth';
import { fileSelectionFailure } from '../utils/file-selection';
import { withPlatformFileSelection } from './file-selection-lifecycle';
import { isAndroidAppRuntime } from './local-file-picker';
type RawKnowledgeSnippet = KnowledgeSnippet & {
title?: string;
text?: string;
};
const mediaExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v'];
const videoExtensions = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']);
interface AppMediaFile {
tempFilePath?: string;
path?: string;
size?: number;
fileType?: 'image' | 'video';
}
interface AppChooseMediaResult {
tempFiles?: AppMediaFile[];
}
type UniWithChooseMedia = {
chooseMedia?: (options: {
count: number;
mediaType: Array<'image' | 'video'>;
sourceType: Array<'album'>;
success: (result: AppChooseMediaResult) => void;
fail: (error: unknown) => void;
}) => void;
};
const validateKnowledgeMedia = (
path: string,
name: string,
size: number,
declaredKind?: 'image' | 'video'
): KnowledgeMediaSelectedFile => {
if (!path) throw new Error('未获取到附件文件');
const extension = `.${name.split('.').pop()?.toLowerCase() || ''}`;
const kind = declaredKind || (videoExtensions.has(extension) ? 'video' : 'image');
const maxSize = kind === 'video' ? 100 * 1024 * 1024 : 10 * 1024 * 1024;
if (size > maxSize) {
throw new Error(kind === 'video' ? '视频不能超过 100MB' : '图片不能超过 10MB');
}
return { path, name, size, kind };
};
const chooseKnowledgeMediaWithFileInput = () =>
new Promise<KnowledgeMediaSelectedFile>((resolve, reject) => {
uni.chooseFile({
count: 1,
type: 'all',
extension: mediaExtensions,
success: (result) => {
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
const file = (tempFiles[0] || {}) as { path?: string; name?: string; size?: number };
const path = file.path || result.tempFilePaths?.[0] || '';
const name = file.name || path.split('/').pop() || '现场附件';
const size = Number(file.size || 0);
try {
resolve(validateKnowledgeMedia(path, name, size));
} catch (error) {
reject(error);
}
},
fail: (error) => reject(fileSelectionFailure(error, '附件'))
});
});
const chooseKnowledgeMediaWithAppPicker = () =>
new Promise<KnowledgeMediaSelectedFile>((resolve, reject) => {
const chooseMedia = (uni as unknown as UniWithChooseMedia).chooseMedia;
if (!chooseMedia) {
reject(new Error('当前 App 版本无法选择图片或视频'));
return;
}
chooseMedia({
count: 1,
mediaType: ['image', 'video'],
sourceType: ['album'],
success: (result) => {
const file = result.tempFiles?.[0] || {};
const path = file.tempFilePath || file.path || '';
const name = path.split('/').pop() || (file.fileType === 'video' ? '现场视频.mp4' : '现场图片.jpg');
// App chooseMedia reports the selected file size in KiB.
const size = Math.max(0, Number(file.size || 0)) * 1024;
try {
resolve(validateKnowledgeMedia(path, name, size, file.fileType));
} catch (error) {
reject(error);
}
},
fail: (error) => reject(fileSelectionFailure(error, '附件'))
});
});
export const chooseKnowledgeMedia = () =>
isAndroidAppRuntime()
? withPlatformFileSelection(chooseKnowledgeMediaWithAppPicker)
: chooseKnowledgeMediaWithFileInput();
const normalizeSnippet = (snippet: RawKnowledgeSnippet): KnowledgeSnippet => ({
...snippet,
docTitle: snippet.docTitle || snippet.title || '',
content: snippet.content || snippet.text || ''
});
export const mergeKnowledgeSnippets = (...groups: RawKnowledgeSnippet[][]): KnowledgeSnippet[] => {
const rows: KnowledgeSnippet[] = [];
const indexes = new Map<string, number>();
groups.flat().forEach((item) => {
const snippet = normalizeSnippet(item);
const key = snippet.fragmentId
? `fragment:${snippet.fragmentId}`
: snippet.docId
? `${snippet.domain || snippet.sourceType || ''}:doc:${snippet.docId}`
: `${snippet.domain || snippet.sourceType || snippet.spaceCode || ''}|${snippet.docTitle}|${snippet.content}`;
const existingIndex = indexes.get(key);
if (existingIndex === undefined) {
indexes.set(key, rows.length);
rows.push(snippet);
return;
}
const present = Object.fromEntries(
Object.entries(snippet).filter(([, value]) => value !== undefined && value !== null && value !== '')
);
rows[existingIndex] = { ...rows[existingIndex], ...present };
});
return rows;
};
export const searchKnowledge = async (
queryText: string,
position = getSelectedPosition(),
conversation?: KnowledgeConversationState,
projectCode?: string,
source: 'mobile_uni_sop' | 'mobile_uni_voice' | 'practice_help' = 'mobile_uni_sop',
broadcastMessageId?: number
) => {
const response = await apiRequest<UnifiedKnowledgeResponse>({
url: '/api/knowledge/query',
method: 'POST',
data: {
queryText,
category: 'sop',
position,
source,
limit: 5,
conversationId: conversation?.conversationId,
contextVersion: conversation?.contextVersion,
projectCode,
...(typeof broadcastMessageId === 'number' && Number.isSafeInteger(broadcastMessageId) && broadcastMessageId > 0
? { broadcastMessageId }
: {})
},
timeout: 30000
});
return normalizeUnifiedResponse(response);
};
export const searchKnowledgeWithMedia = (
file: KnowledgeMediaSelectedFile,
queryText: string,
position = getSelectedPosition(),
onUploadTask?: (task: ReturnType<typeof uni.uploadFile> | null) => void,
conversation?: KnowledgeConversationState,
projectCode?: string
) => new Promise<KnowledgeSearchResponse>((resolve, reject) => {
const task = uni.uploadFile({
url: apiUrl('/api/knowledge/query-media'),
filePath: file.path,
name: 'file',
header: authHeaders(false),
formData: {
queryText,
position,
...(conversation ? {
conversationId: conversation.conversationId,
contextVersion: String(conversation.contextVersion)
} : {}),
...(projectCode ? { projectCode } : {})
},
timeout: 360000,
success: (response) => {
try {
const payload = readTextPayload<UnifiedKnowledgeResponse>(response.statusCode, response.data);
resolve(normalizeUnifiedResponse(payload));
} catch (error) {
reject(error);
}
},
fail: () => reject(new Error(file.kind === 'video' ? '视频上传或分析超时' : '图片上传失败')),
complete: () => onUploadTask?.(null)
});
onUploadTask?.(task);
});
export const queryKnowledgeTool = async (
toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY',
queryText: string,
conversation?: KnowledgeConversationState,
projectCode?: string
) => {
const response = await apiRequest<UnifiedKnowledgeResponse>({
url: '/api/knowledge/query',
method: 'POST',
data: {
queryText,
source: 'mobile_uni_tool',
limit: 5,
toolCode,
conversationId: conversation?.conversationId,
contextVersion: conversation?.contextVersion,
projectCode
},
timeout: 30000
});
return normalizeUnifiedResponse(response);
};
export const requestKnowledgeResourceDownloadLink = async (attachmentId: number) => {
const response = await apiRequest<{ url: string }>({
url: `/api/knowledge/resources/${encodeURIComponent(String(attachmentId))}/download-link`,
method: 'POST'
});
if (!response.url?.startsWith('/api/knowledge/resources/')) {
throw new Error('资料下载链接无效');
}
return apiUrl(response.url);
};
export const loadKnowledgeCitationDetail = (detailRef: string) => {
const safeRef = String(detailRef || '').trim();
if (!/^[A-Za-z0-9_-]{43}$/.test(safeRef)) return Promise.reject(new Error('引用地址无效'));
return apiRequest<KnowledgeCitationDetail>({
url: `/api/knowledge/citations/${encodeURIComponent(safeRef)}`,
method: 'GET',
timeout: 15000,
clearAuthOnUnauthorized: false
});
};
export const loadKnowledgeCitationMediaUrl = async (detailRef: string) => {
const safeRef = String(detailRef || '').trim();
if (!/^[A-Za-z0-9_-]{43}$/.test(safeRef)) throw new Error('引用地址无效');
const response = await apiRequest<{ url: string }>({
url: `/api/knowledge/citations/${encodeURIComponent(safeRef)}/media-link`,
method: 'POST',
clearAuthOnUnauthorized: false
});
if (!response.url?.startsWith('/api/knowledge/resources/')) throw new Error('视频地址无效');
return apiMediaUrl(response.url);
};
const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): KnowledgeSearchResponse => {
const legacy = response.legacy || ({} as KnowledgeSearchResponse);
const citationSnippets = (response.citations || []).map((item) => normalizeSnippet({
docTitle: item.title,
content: item.snippet,
docId: item.docId,
fragmentId: item.fragmentId,
spaceCode: item.spaceCode,
sourceType: item.sourceType,
domain: item.domain,
status: item.status,
occurredAt: item.occurredAt,
updatedAt: item.updatedAt,
mediaType: item.mediaType,
detailRef: item.detailRef,
locatorSummary: item.locatorSummary
}));
return {
...legacy,
requestId: response.requestId,
queryText: response.queryText,
category: legacy.category || 'sop',
answer: response.answer,
reference: legacy.reference || (response.citations || []).map((item) => item.title).join('、'),
docs: legacy.docs || [],
snippets: mergeKnowledgeSnippets(citationSnippets, legacy.snippets || []),
training: legacy.training || [],
records: legacy.records || [],
citations: response.citations || [],
usedSpaceCodes: response.usedSpaceCodes || [],
noEvidence: response.noEvidence,
promptVersion: response.promptVersion,
data: response.data,
conversationId: response.conversationId,
contextVersion: response.contextVersion,
intent: response.intent,
rewrittenQuery: response.rewrittenQuery,
resources: response.resources || [],
memoryCandidate: response.memoryCandidate || legacy.memoryCandidate,
broadcastContext: response.broadcastContext || legacy.broadcastContext || null
};
};
export const getPositionSopSummary = (position = getSelectedPosition()) =>
apiRequest<PositionSopSummary>({
url: `/api/knowledge/position-sop?position=${encodeURIComponent(position)}&projectType=住宅`,
method: 'GET',
timeout: 15000
});
export const submitAnswerFeedback = (params: {
queryText: string;
requestId?: string;
fragmentIds: number[];
verdict: AnswerFeedbackVerdict;
reviewId?: number | null;
reasonCodes?: string[];
comment?: string;
}) =>
apiRequest<{ saved: number }>({
url: '/api/knowledge/answer-feedback',
method: 'POST',
data: {
queryText: params.queryText,
requestId: params.requestId || '',
fragmentIds: params.fragmentIds,
verdict: params.verdict,
category: 'sop',
position: getSelectedPosition(),
source: 'mobile_uni_sop',
reviewId: params.reviewId ?? null,
reasonCodes: params.reasonCodes || [],
comment: params.comment || ''
},
timeout: 15000
});
export const generateSummaryCard = (params: { queryText: string; fragmentIds: number[] }) =>
apiRequest<SummaryCardResponse>({
url: '/api/knowledge/summary-card',
method: 'POST',
data: {
queryText: params.queryText,
category: 'sop',
fragmentIds: params.fragmentIds
},
timeout: 45000
});