358 lines
12 KiB
TypeScript
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
|
|
});
|