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((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((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(); 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({ 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 | null) => void, conversation?: KnowledgeConversationState, projectCode?: string ) => new Promise((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(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({ 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({ 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({ 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({ url: '/api/knowledge/summary-card', method: 'POST', data: { queryText: params.queryText, category: 'sop', fragmentIds: params.fragmentIds }, timeout: 45000 });