diff --git a/src/components/DataPreview/session/entryWhitelist/index.tsx b/src/components/DataPreview/session/entryWhitelist/index.tsx index 794d33dd3e8a01a755c852b1b575ae6e4c882f1e..716bc8006934de2a710b0d3865b9fcb8f85abfaf 100644 --- a/src/components/DataPreview/session/entryWhitelist/index.tsx +++ b/src/components/DataPreview/session/entryWhitelist/index.tsx @@ -155,25 +155,26 @@ async function mapWithConcurrencyUntilTimeout( return results.filter((item): item is R => item !== undefined); } -function getCurrentEntryWhitelistBelong() { +function getCurrentEntryWhitelistBelong(belongIdOverride = '') { const current = orgCtrl.home.current as any; - const belongId = String(current?.id ?? '').trim(); + const belongId = belongIdOverride.trim() || String(current?.id ?? '').trim(); const belongName = String(current?.name ?? '当前单位').trim(); return { current, belongId, belongName }; } async function waitForCurrentEntryWhitelistBelong( timeoutMs = ENTRY_WHITELIST_INIT_WAIT_MS, + belongIdOverride = '', ) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { - const belong = getCurrentEntryWhitelistBelong(); + const belong = getCurrentEntryWhitelistBelong(belongIdOverride); if (belong.current && belong.belongId) { return belong; } await new Promise((resolve) => window.setTimeout(resolve, 300)); } - return getCurrentEntryWhitelistBelong(); + return getCurrentEntryWhitelistBelong(belongIdOverride); } function normalizeEntryName(name: unknown): string { @@ -626,8 +627,13 @@ async function createEntryWhitelistFromCapture( }); } -export async function ensureEntryWhitelistInitialized(): Promise { - const { belongId, belongName } = await waitForCurrentEntryWhitelistBelong(); +export async function ensureEntryWhitelistInitialized( + belongIdOverride = '', +): Promise { + const { belongId, belongName } = await waitForCurrentEntryWhitelistBelong( + ENTRY_WHITELIST_INIT_WAIT_MS, + belongIdOverride, + ); if (!belongId) return false; const existingTask = initializingEntryWhitelistTasks.get(belongId); if (existingTask) return existingTask; diff --git a/src/pages/Home/components/Search/PerformancePanel.tsx b/src/pages/Home/components/Search/PerformancePanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b1a8ddb57b572079fee968a773df988bba00b131 --- /dev/null +++ b/src/pages/Home/components/Search/PerformancePanel.tsx @@ -0,0 +1,272 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { DashboardOutlined, ReloadOutlined } from '@ant-design/icons'; +import { Alert, Button, Modal, Space, Table, Tag } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; + +interface RagStageTelemetry { + vector_retrieve_ms?: number; + selected_file_filter_ms?: number; + rerank_ms?: number; + first_token_ms?: number | null; + first_token_available?: boolean; + llm_generate_ms?: number; +} + +interface MetricRecord { + requestId: string; + timestamp: string; + matchMode: string; + selectedName: string; + cacheHit: boolean; + coldRequest: boolean; + totalMs: number; + ragMs: number; + mcpMs: number; + agentLlmMs: number; + ragTelemetry?: RagStageTelemetry; +} + +export interface AssistantSearchMetricsSnapshot { + generatedAt: string; + targetMs: number; + summary: { + totalRequests: number; + warmRequests: number; + coldRequests: number; + cacheHitRate: number; + underTargetRate: number; + avgTotalMs: number; + p50TotalMs: number; + p95TotalMs: number; + observedRagRequests: number; + timingCompletenessRate: number; + }; + stageAverages: Record; + recent: MetricRecord[]; +} + +interface PerformancePanelProps { + loadMetrics: () => Promise; +} + +interface ComparisonRow { + key: string; + metric: string; + current: string; + target: string; + wiki: string; +} + +interface StageRow { + key: string; + stage: string; + duration: string; +} + +function formatMs(value: number | undefined): string { + if (typeof value !== 'number') return '—'; + return value >= 1000 ? `${(value / 1000).toFixed(2)} s` : `${value.toFixed(0)} ms`; +} + +const recentColumns: ColumnsType = [ + { + title: '时间', + dataIndex: 'timestamp', + width: 92, + render: (value: string) => new Date(value).toLocaleTimeString(), + }, + { + title: '请求 ID', + dataIndex: 'requestId', + width: 116, + ellipsis: true, + render: (value: string) => value.slice(0, 8), + }, + { + title: '状态', + key: 'state', + width: 90, + render: (_, record) => ( + + {record.coldRequest && 冷启动} + {record.cacheHit && 缓存} + {!record.coldRequest && !record.cacheHit && 暖请求} + + ), + }, + { title: '模式', dataIndex: 'matchMode', width: 82 }, + { title: '总耗时', dataIndex: 'totalMs', width: 88, render: formatMs }, + { title: 'MCP', dataIndex: 'mcpMs', width: 82, render: formatMs }, + { + title: '向量检索', + key: 'vector', + width: 90, + render: (_, record) => formatMs(record.ragTelemetry?.vector_retrieve_ms), + }, + { + title: '重排', + key: 'rerank', + width: 82, + render: (_, record) => formatMs(record.ragTelemetry?.rerank_ms), + }, + { + title: 'RAG 模型', + key: 'ragLlm', + width: 90, + render: (_, record) => formatMs(record.ragTelemetry?.llm_generate_ms), + }, + { title: 'Agent 模型', dataIndex: 'agentLlmMs', width: 96, render: formatMs }, + { title: '推荐入口', dataIndex: 'selectedName', ellipsis: true }, +]; + +const PerformancePanel: React.FC = ({ loadMetrics }) => { + const mountedRef = useRef(true); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [snapshot, setSnapshot] = useState(); + + useEffect( + () => () => { + mountedRef.current = false; + }, + [], + ); + + const refresh = useCallback(async () => { + setLoading(true); + setError(''); + try { + const next = await loadMetrics(); + if (mountedRef.current) setSnapshot(next); + } catch (loadError) { + if (mountedRef.current) { + setError(loadError instanceof Error ? loadError.message : '基线指标加载失败'); + } + } finally { + if (mountedRef.current) setLoading(false); + } + }, [loadMetrics]); + + const summary = snapshot?.summary; + const comparisonRows: ComparisonRow[] = [ + { + key: 'average', + metric: '暖请求平均耗时', + current: formatMs(summary?.avgTotalMs), + target: '< 15 s', + wiki: 'Phase 2 接入后生成', + }, + { + key: 'p95', + metric: '暖请求 P95', + current: formatMs(summary?.p95TotalMs), + target: '持续下降', + wiki: 'Phase 2 接入后生成', + }, + { + key: 'under-target', + metric: '15 秒内完成率', + current: `${summary?.underTargetRate ?? 0}%`, + target: '≥ 95%', + wiki: 'Phase 2 接入后生成', + }, + { + key: 'complete', + metric: '分段计时完整率', + current: `${summary?.timingCompletenessRate ?? 0}%`, + target: '≥ 95%', + wiki: '沿用同一口径', + }, + ]; + const stageRows: StageRow[] = [ + ['retriever_setup_ms', '检索器准备'], + ['vector_retrieve_ms', 'PGVector 向量检索'], + ['selected_file_filter_ms', '文件范围过滤'], + ['rerank_ms', 'BGE 重排'], + ['prompt_build_ms', '提示词组装'], + ['llm_generate_ms', 'Qwen 完整生成'], + ['total_ms', 'Python RAG 总耗时'], + ].map(([key, stage]) => ({ + key, + stage, + duration: formatMs(snapshot?.stageAverages[key]), + })); + + return ( + <> + + setOpen(false)}> + + } + loading={loading} + onClick={() => void refresh()}> + 刷新 + + } + /> + {error && } + + size="small" + pagination={false} + dataSource={comparisonRows} + columns={[ + { title: '指标', dataIndex: 'metric' }, + { title: '当前 RAG(A)', dataIndex: 'current' }, + { title: '验收目标', dataIndex: 'target' }, + { title: 'Wiki 并行(B)', dataIndex: 'wiki' }, + ]} + /> + + size="small" + pagination={false} + dataSource={stageRows} + columns={[ + { title: 'RAG 阶段', dataIndex: 'stage' }, + { title: '平均耗时', dataIndex: 'duration' }, + ]} + /> + + rowKey="requestId" + size="small" + loading={loading} + scroll={{ x: 1050 }} + pagination={{ pageSize: 10, showSizeChanger: false }} + dataSource={snapshot?.recent ?? []} + columns={recentColumns} + /> + + + + ); +}; + +export default PerformancePanel; diff --git a/src/pages/Home/components/Search/index.less b/src/pages/Home/components/Search/index.less index 63233dffafea027dea280fe5a2d1f3329f62869d..a298d5028048d1e78096854b9fe0ed00574daef8 100644 --- a/src/pages/Home/components/Search/index.less +++ b/src/pages/Home/components/Search/index.less @@ -109,4 +109,8 @@ } } } -} \ No newline at end of file +} + +.assistant-performance-panel { + width: 100%; +} diff --git a/src/pages/Home/components/Search/index.tsx b/src/pages/Home/components/Search/index.tsx index 809803936e327446b9f95d6a92939b609b4295aa..f21fb76f2b89a84092c7e44e741ef64619af7a38 100644 --- a/src/pages/Home/components/Search/index.tsx +++ b/src/pages/Home/components/Search/index.tsx @@ -28,6 +28,9 @@ import useAsyncLoad from '@/hooks/useAsyncLoad'; import { useDebounce } from '@/hooks/useDebounce'; import FullScreenModal from '@/components/Common/fullScreen'; import { ensureEntryWhitelistInitialized } from '@/components/DataPreview/session/entryWhitelist'; +import PerformancePanel, { AssistantSearchMetricsSnapshot } from './PerformancePanel'; +import { archiveAssistantFeedback } from '@/services/assistantFeedbackArchive'; +import { RAG_ADMIN_BASE_URL } from '@/services/ragAdmin'; interface IProps { isGlobal: boolean; @@ -46,12 +49,11 @@ interface AssistantRecommendation { const ASSISTANT_REPLY_TIMEOUT_MS = 60000; const ASSISTANT_REPLY_SETTLE_MS = 1000; const ASSISTANT_REPLY_POLL_MS = 800; -const RAG_ADMIN_BASE_URL = 'http://127.0.0.1:5179'; const RAG_LIST_TIMEOUT_MS = 8000; const RAG_ASK_TIMEOUT_MS = 60000; const FIXED_RAG_KNOWLEDGE_BASE_CODE = 'RAGZSK'; const FIXED_RAG_KNOWLEDGE_BASE_NAME = 'RAG知识库'; -const RAG_DOCUMENT_FILE_PATTERN = /\.(?:pdf|txt|md)$/i; +const RAG_DOCUMENT_FILE_PATTERN = /\.(?:pdf|docx|txt|md)$/i; interface DecodedAssistantMessage { body: string; @@ -61,6 +63,7 @@ interface DecodedAssistantMessage { interface RagDocumentLoadResult { directoryFound: boolean; files: string[]; + scopeId: string; } function getTargetKey(target: unknown): string { @@ -82,6 +85,18 @@ function getTargetName(target: unknown): string { return String(record?.name || metadata?.name || '未识别单位'); } +function createAssistantSearchRequestId(): string { + return `search-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +function isMissingAssistantResult(value: unknown): boolean { + return ['', '未找到', '无', 'none', 'null'].includes( + String(value ?? '') + .trim() + .toLowerCase(), + ); +} + async function fetchWithTimeout( input: RequestInfo | URL, init: RequestInit = {}, @@ -96,6 +111,21 @@ async function fetchWithTimeout( } } +async function loadAssistantSearchMetrics(): Promise { + const response = await fetchWithTimeout( + `${RAG_ADMIN_BASE_URL}/assistant/metrics?limit=100`, + ); + const payload = (await response.json()) as { + code?: number; + msg?: string; + data?: AssistantSearchMetricsSnapshot; + }; + if (!response.ok || !payload.data) { + throw new Error(payload.msg || '辅助搜索基线指标加载失败'); + } + return payload.data; +} + function normalizeSources(sources: string[], fallback: string[] = []): string[] { const normalized = sources .map((item) => item.trim()) @@ -131,8 +161,9 @@ function normalizeRagDocumentNames(files: string[]): string[] { ]; } -async function loadMcpRagDocumentSources(): Promise { - const response = await fetchWithTimeout(`${RAG_ADMIN_BASE_URL}/rag/files`); +async function loadMcpRagDocumentSources(scopeId: string): Promise { + const query = new URLSearchParams({ scope_id: scopeId }); + const response = await fetchWithTimeout(`${RAG_ADMIN_BASE_URL}/rag/files?${query}`); const payload = await response.json(); if (!response.ok || ![0, 200, '0', '200'].includes(payload?.code)) { throw new Error(payload?.msg || '无法读取 RAG MCP 文件列表'); @@ -325,30 +356,28 @@ async function loadFixedRagDocumentSources( ): Promise { try { const roots = await loadScopedRagRoots(target); - if (roots.length === 0) return { directoryFound: false, files: [] }; + if (roots.length === 0) return { directoryFound: false, files: [], scopeId: '' }; - let directoryFound = false; - const scopedFiles: string[] = []; for (const root of roots) { await root.loadDirectoryResource(); const ragDirectory = await findFixedRagDirectory(root); if (!ragDirectory) continue; - directoryFound = true; const files = await ragDirectory.loadFiles(true); - scopedFiles.push( - ...files - .map((file: any) => String(file?.metadata?.name || file?.name || '').trim()) - .filter(Boolean), - ); + return { + directoryFound: true, + scopeId: String(ragDirectory.target.id ?? '').trim(), + files: normalizeRagDocumentNames( + files + .map((file: any) => String(file?.metadata?.name || file?.name || '').trim()) + .filter(Boolean), + ), + }; } - return { - directoryFound, - files: normalizeRagDocumentNames(scopedFiles), - }; + return { directoryFound: false, files: [], scopeId: '' }; } catch { - return { directoryFound: false, files: [] }; + return { directoryFound: false, files: [], scopeId: '' }; } } @@ -575,12 +604,14 @@ const Search: React.FC = (props) => { const [ragDocumentsLoading, setRagDocumentsLoading] = useState(false); const [ragDirectoryFound, setRagDirectoryFound] = useState(false); const [ragDocumentUnitKey, setRagDocumentUnitKey] = useState(''); + const [ragDocumentScopeId, setRagDocumentScopeId] = useState(''); const ragLoadSeqRef = useRef(0); const currentUnit = orgCtrl.home.current as ITarget | undefined; const currentUnitKey = getTargetKey(currentUnit); const currentUnitName = getTargetName(currentUnit); const isCurrentUnitRagState = ragDocumentUnitKey === currentUnitKey; const currentUnitRagDirectoryFound = isCurrentUnitRagState && ragDirectoryFound; + const currentUnitRagScopeId = isCurrentUnitRagState ? ragDocumentScopeId : ''; const currentUnitRagDocumentOptions = isCurrentUnitRagState ? ragDocumentOptions : []; const currentUnitSelectedRagDocuments = isCurrentUnitRagState ? selectedRagDocuments @@ -605,6 +636,7 @@ const Search: React.FC = (props) => { setRagDocumentsLoading(true); setRagDirectoryFound(false); setRagDocumentUnitKey(''); + setRagDocumentScopeId(''); setRagDocumentOptions([]); setSelectedRagDocuments([]); try { @@ -617,6 +649,7 @@ const Search: React.FC = (props) => { } setRagDirectoryFound(result.directoryFound); setRagDocumentUnitKey(targetKey); + setRagDocumentScopeId(result.scopeId); setRagDocumentOptions(result.files); setSelectedRagDocuments((current) => current.length > 0 @@ -635,6 +668,7 @@ const Search: React.FC = (props) => { setRagDirectoryFound(false); setRagScopeLimited(false); setRagDocumentUnitKey(''); + setRagDocumentScopeId(''); setRagDocumentOptions([]); setSelectedRagDocuments([]); setAssistantAnswer(''); @@ -731,7 +765,14 @@ const Search: React.FC = (props) => { ); }; - const confirmOpenBusinessEntry = async (entryName: string, content: string) => { + const confirmOpenBusinessEntry = async ( + entryName: string, + content: string, + originalQuery: string, + ) => { + if (isMissingAssistantResult(entryName)) { + archiveAssistantFeedback(currentUnit, originalQuery, 'not-found'); + } const file = await findBusinessEntryFile(entryName); if (!file) { Modal.confirm({ @@ -752,6 +793,9 @@ const Search: React.FC = (props) => { command.emitter('executor', 'open', file); setModalVisible(false); }, + onCancel: () => { + archiveAssistantFeedback(currentUnit, originalQuery, 'dismissed'); + }, }); }; @@ -823,7 +867,11 @@ const Search: React.FC = (props) => { return; } setAssistantStage('正在核对 RAG MCP 已入库文件...'); - const mcpRagDocuments = await loadMcpRagDocumentSources(); + // Use the owner of the actual RAGZSK directory. A unit view may expose a + // group-owned knowledge folder whose target id differs from home.current. + const currentBelongId = + currentUnitRagScopeId || String((orgCtrl.home.current as any)?.id ?? ''); + const mcpRagDocuments = await loadMcpRagDocumentSources(currentBelongId); if (!isSubmitUnitActive()) return; const mcpFileByName = new Map( mcpRagDocuments.map((fileName) => [fileName.toLowerCase(), fileName]), @@ -831,24 +879,14 @@ const Search: React.FC = (props) => { const intendedRagDocuments = ragScopeLimited ? currentSelectedScopedDocuments : currentRagDocuments; - const unsyncedRagDocuments = intendedRagDocuments.filter( - (fileName) => !mcpFileByName.has(fileName.toLowerCase()), - ); - if (unsyncedRagDocuments.length > 0) { - throw new Error( - `以下当前单位知识文件尚未同步到 RAG MCP:${unsyncedRagDocuments.join( - '、', - )}。请先完成知识文件上传或入库同步。`, - ); - } const searchableRagDocuments = intendedRagDocuments .map((fileName) => mcpFileByName.get(fileName.toLowerCase())) .filter((fileName): fileName is string => Boolean(fileName)); if (searchableRagDocuments.length === 0) { - throw new Error('当前单位没有已同步到 RAG MCP 的可检索文件'); + throw new Error('当前单位的 RAGZSK 文件尚未通过知识库管理流程同步到本地 RAG MCP'); } setAssistantStage('正在同步当前单位入口白名单...'); - await ensureEntryWhitelistInitialized(); + await ensureEntryWhitelistInitialized(currentBelongId); if (!isSubmitUnitActive()) return; const applications = (await orgCtrl.loadApplications()).filter((app) => app.isAuth); if (!isSubmitUnitActive()) return; @@ -859,7 +897,6 @@ const Search: React.FC = (props) => { const sourceFallbackDocuments = searchableRagDocuments; const useKnowledgeBaseScope = true; const knowledgeBasePlatformName = FIXED_RAG_KNOWLEDGE_BASE_NAME; - const currentBelongId = String((orgCtrl.home.current as any)?.id ?? ''); const assistantCandidates = useKnowledgeBaseScope ? [] : applications.slice(0, 200).map((app) => ({ @@ -876,6 +913,7 @@ const Search: React.FC = (props) => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + requestId: createAssistantSearchRequestId(), query, candidates: assistantCandidates, knowledgeBases: fixedRagFiles, @@ -933,6 +971,7 @@ const Search: React.FC = (props) => { await confirmOpenBusinessEntry( data.entryName || data.applicationName, displayAnswer, + query, ); return; } @@ -944,8 +983,12 @@ const Search: React.FC = (props) => { okText: '确认打开', cancelText: '暂不打开', onOk: () => command.emitter('executor', 'open', targetApplication), + onCancel: () => { + archiveAssistantFeedback(currentUnit, query, 'dismissed'); + }, }); } else { + archiveAssistantFeedback(currentUnit, query, 'not-found'); Modal.info({ title: '辅助搜索结果', content: renderAssistantResult(displayAnswer), @@ -1026,11 +1069,12 @@ const Search: React.FC = (props) => { `消息溯源:${answerSources.length ? answerSources.join('、') : '无'}`, ].join('\n'); setAssistantAnswer(formattedAnswer); - await confirmOpenBusinessEntry(entryName, formattedAnswer); + await confirmOpenBusinessEntry(entryName, formattedAnswer, query); return; } if (!targetApplication) { + archiveAssistantFeedback(currentUnit, query, 'not-found'); setAssistantAnswer( [ '应用名称:未找到', @@ -1060,6 +1104,9 @@ const Search: React.FC = (props) => { okText: '确认打开', cancelText: '暂不打开', onOk: () => command.emitter('executor', 'open', targetApplication), + onCancel: () => { + archiveAssistantFeedback(currentUnit, query, 'dismissed'); + }, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : '辅助搜索失败'; @@ -1439,6 +1486,9 @@ const Search: React.FC = (props) => { unCheckedChildren="关闭" /> 辅助搜索 + {assistantSearch && ( + + )} {!assistantSearch && ( diff --git a/src/services/assistantFeedbackArchive.ts b/src/services/assistantFeedbackArchive.ts new file mode 100644 index 0000000000000000000000000000000000000000..3127d40ada8720dac989a0aaa011fe2a386b05b8 --- /dev/null +++ b/src/services/assistantFeedbackArchive.ts @@ -0,0 +1,130 @@ +import { schema } from '@/ts/base'; +import type { ITarget } from '@/ts/core'; +import { RAG_ADMIN_BASE_URL } from './ragAdmin'; + +export type AssistantFeedbackReason = 'not-found' | 'dismissed'; + +interface EncryptedAssistantFeedback extends schema.Xbase { + archiveVersion: number; + algorithm: 'AES-256-GCM'; + reason: AssistantFeedbackReason; + scopeId: string; + createdAt: string; + truncated: boolean; + iv: string; + aad: string; + tag: string; + ciphertext: string; +} + +const FEEDBACK_COLLECTION = 'assistant-search-feedback'; +const MAX_QUERY_CHARS = 500; +const MAX_RECORDS_PER_UNIT = 1000; +const CLEANUP_BATCH_SIZE = 200; +const DEDUPLICATION_WINDOW_MS = 60_000; +const recentFeedback = new Map(); +const cleanupScheduled = new Set(); + +function isDuplicate(key: string): boolean { + const now = Date.now(); + const previous = recentFeedback.get(key) ?? 0; + recentFeedback.set(key, now); + if (recentFeedback.size > 200) { + for (const [item, timestamp] of recentFeedback) { + if (now - timestamp > DEDUPLICATION_WINDOW_MS) recentFeedback.delete(item); + } + } + return now - previous < DEDUPLICATION_WINDOW_MS; +} + +async function requestEncryptedRecord( + query: string, + reason: AssistantFeedbackReason, + scopeId: string, +): Promise { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 3000); + try { + const response = await fetch(`${RAG_ADMIN_BASE_URL}/assistant/feedback/encrypt`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, reason, scopeId }), + signal: controller.signal, + }); + const payload = await response.json(); + if (!response.ok || !payload?.data?.ciphertext) { + throw new Error(payload?.msg || 'assistant feedback encryption failed'); + } + return payload.data as EncryptedAssistantFeedback; + } finally { + window.clearTimeout(timeout); + } +} + +function scheduleRetentionCleanup(target: ITarget): void { + if (cleanupScheduled.has(target.id)) return; + cleanupScheduled.add(target.id); + window.setTimeout(() => { + const collection = + target.resource.genColl(FEEDBACK_COLLECTION); + void (async () => { + try { + let count = await collection.count({ + options: { match: { isDeleted: false } }, + }); + while (count > MAX_RECORDS_PER_UNIT) { + const excess = Math.min(count - MAX_RECORDS_PER_UNIT, CLEANUP_BATCH_SIZE); + const oldest = await collection.load({ + take: excess, + options: { + match: { isDeleted: false }, + sort: { createTime: 1 }, + }, + }); + if (oldest.length === 0 || !(await collection.removeMany(oldest))) break; + count -= oldest.length; + } + } catch { + // 留档失败不能影响搜索主链路,也不能把用户原文写入普通日志。 + } finally { + cleanupScheduled.delete(target.id); + } + })(); + }, 10_000); +} + +export function archiveAssistantFeedback( + target: ITarget | undefined, + query: string, + reason: AssistantFeedbackReason, +): void { + const normalizedQuery = query.trim().slice(0, MAX_QUERY_CHARS); + if (!target || !normalizedQuery) return; + + const deduplicationKey = `${target.id}::${reason}::${normalizedQuery}`; + if (isDuplicate(deduplicationKey)) return; + + void (async () => { + try { + const encrypted = await requestEncryptedRecord(normalizedQuery, reason, target.id); + const collection = + target.resource.genColl(FEEDBACK_COLLECTION); + await collection.insert({ + ...encrypted, + id: 'snowId()', + status: 0, + createUser: '', + updateUser: '', + version: '', + createTime: encrypted.createdAt, + updateTime: encrypted.createdAt, + shareId: target.id, + belongId: target.belongId, + isDeleted: false, + }); + scheduleRetentionCleanup(target); + } catch { + // 留档是低优先级后台任务,失败时不阻断搜索或弹窗操作。 + } + })(); +} diff --git a/src/services/ragAdmin.ts b/src/services/ragAdmin.ts new file mode 100644 index 0000000000000000000000000000000000000000..014e255546734f79a1e16d7057cf47fac736d3b5 --- /dev/null +++ b/src/services/ragAdmin.ts @@ -0,0 +1,3 @@ +export const RAG_ADMIN_BASE_URL = ( + import.meta.env.VITE_RAG_ADMIN_BASE_URL || 'http://127.0.0.1:5179' +).replace(/\/+$/, ''); diff --git a/src/services/ragMcpSync.ts b/src/services/ragMcpSync.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4c7b59d0ac6d17cbb175a2b20727ea41d100fbd --- /dev/null +++ b/src/services/ragMcpSync.ts @@ -0,0 +1,216 @@ +import type { FileItemModel } from '@/ts/base/model'; +import { RAG_ADMIN_BASE_URL } from './ragAdmin'; +const SUPPORTED_RAG_FILE_PATTERN = /\.(?:pdf|docx|txt|md)$/i; +const MAX_RETRIES = 2; +const RECONCILE_COOLDOWN_MS = 30_000; + +interface RagMcpUploadTask { + kind: 'upload'; + scopeId: string; + sourceId: string; + filename: string; + content: Blob; + attempts: number; +} + +interface RagMcpDeleteTask { + kind: 'delete'; + scopeId: string; + filename: string; + attempts: number; +} + +type RagMcpTask = RagMcpUploadTask | RagMcpDeleteTask; + +const pendingTasks = new Map(); +const reconcileLastRun = new Map(); +const reconcilingScopes = new Set(); +let workerRunning = false; + +function taskKey(task: Pick) { + return `${task.scopeId}::${task.filename}`; +} + +async function uploadTask(task: RagMcpUploadTask): Promise { + const formData = new FormData(); + formData.append('file', task.content, task.filename); + formData.append('scope_id', task.scopeId); + formData.append('source_id', task.sourceId); + const response = await fetch(`${RAG_ADMIN_BASE_URL}/rag/upload`, { + method: 'POST', + body: formData, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || ![0, 200, '0', '200'].includes(payload?.code)) { + throw new Error(payload?.msg || `RAG MCP 文件同步失败 (${response.status})`); + } +} + +async function deleteTask(task: RagMcpDeleteTask): Promise { + const query = new URLSearchParams({ + scope_id: task.scopeId, + file_name: task.filename, + }); + const response = await fetch(`${RAG_ADMIN_BASE_URL}/rag/file?${query}`, { + method: 'DELETE', + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || ![0, 200, '0', '200'].includes(payload?.code)) { + throw new Error(payload?.msg || `RAG MCP 文件删除同步失败 (${response.status})`); + } +} + +async function runWorker(): Promise { + if (workerRunning) return; + workerRunning = true; + try { + while (pendingTasks.size > 0) { + const current = pendingTasks.entries().next().value as + | [string, RagMcpTask] + | undefined; + if (!current) break; + const [key, task] = current; + pendingTasks.delete(key); + try { + if (task.kind === 'upload') { + await uploadTask(task); + } else { + await deleteTask(task); + } + } catch (error) { + if (task.attempts < MAX_RETRIES) { + pendingTasks.set(key, { ...task, attempts: task.attempts + 1 }); + } else { + console.error(`RAGZSK 文件同步失败: ${task.filename}`, error); + } + } + } + } finally { + workerRunning = false; + if (pendingTasks.size > 0) void runWorker(); + } +} + +/** + * Queue a platform RAGZSK upload after the platform file itself succeeds. + * The caller deliberately does not await vectorization, keeping the normal + * file-upload interaction independent from local MCP ingestion latency. + */ +export function enqueueRagMcpUpload(input: { + scopeId: string; + sourceId: string; + filename: string; + content: Blob; +}): void { + const filename = input.filename.trim(); + const scopeId = input.scopeId.trim(); + if (!scopeId || !filename || !SUPPORTED_RAG_FILE_PATTERN.test(filename)) return; + const task: RagMcpUploadTask = { + kind: 'upload', + ...input, + scopeId, + filename, + sourceId: input.sourceId.trim(), + attempts: 0, + }; + pendingTasks.set(taskKey(task), task); + void runWorker(); +} + +export function enqueueRagMcpDelete(input: { scopeId: string; filename: string }): void { + const scopeId = input.scopeId.trim(); + const filename = input.filename.trim(); + if (!scopeId || !filename || !SUPPORTED_RAG_FILE_PATTERN.test(filename)) return; + const task: RagMcpDeleteTask = { + kind: 'delete', + scopeId, + filename, + attempts: 0, + }; + pendingTasks.set(taskKey(task), task); + void runWorker(); +} + +function platformFileUrl(shareLink: string): string { + if (/^https?:\/\//i.test(shareLink) || shareLink.startsWith('/orginone/kernel/load/')) { + return shareLink; + } + return `/orginone/kernel/load/${shareLink}`; +} + +function flattenMcpFileList(payload: any): string[] { + const files = payload?.data ?? payload; + if (!files || typeof files !== 'object') return []; + return Object.values(files) + .flatMap((items) => (Array.isArray(items) ? items : [])) + .map(String) + .filter((name) => SUPPORTED_RAG_FILE_PATTERN.test(name)); +} + +/** + * Reconcile an already existing platform RAGZSK folder in the background. + * This is intentionally triggered by normal folder loading rather than by a + * search request, so first-search latency never includes downloads or indexing. + */ +export async function reconcileRagMcpFiles( + scopeIdInput: string, + platformFiles: FileItemModel[], +): Promise { + const scopeId = scopeIdInput.trim(); + const now = Date.now(); + if ( + !scopeId || + reconcilingScopes.has(scopeId) || + now - (reconcileLastRun.get(scopeId) ?? 0) < RECONCILE_COOLDOWN_MS + ) { + return; + } + + reconcilingScopes.add(scopeId); + reconcileLastRun.set(scopeId, now); + try { + const supportedFiles = platformFiles.filter( + (item) => + !item.isDirectory && + SUPPORTED_RAG_FILE_PATTERN.test(item.name) && + Boolean(item.shareLink), + ); + const query = new URLSearchParams({ scope_id: scopeId }); + const response = await fetch(`${RAG_ADMIN_BASE_URL}/rag/files?${query}`); + const payload = await response.json().catch(() => ({})); + if (!response.ok || ![0, 200, '0', '200'].includes(payload?.code)) { + throw new Error(payload?.msg || `RAG MCP 文件清单读取失败 (${response.status})`); + } + + const mcpNames = new Set(flattenMcpFileList(payload)); + const platformNames = new Set(supportedFiles.map((item) => item.name)); + + for (const staleName of mcpNames) { + if (!platformNames.has(staleName)) { + enqueueRagMcpDelete({ scopeId, filename: staleName }); + } + } + + for (const item of supportedFiles) { + if (mcpNames.has(item.name) || !item.shareLink) continue; + try { + const fileResponse = await fetch(platformFileUrl(item.shareLink)); + if (!fileResponse.ok) { + throw new Error(`平台文件下载失败 (${fileResponse.status})`); + } + enqueueRagMcpUpload({ + scopeId, + sourceId: String((item as any).id ?? item.shareLink ?? item.key ?? ''), + filename: item.name, + content: await fileResponse.blob(), + }); + } catch (error) { + console.error(`RAGZSK 历史文件补齐失败: ${item.name}`, error); + } + } + } catch (error) { + console.error(`RAGZSK 文件清单同步失败: ${scopeId}`, error); + } finally { + reconcilingScopes.delete(scopeId); + } +} diff --git a/src/ts/core/thing/directory.ts b/src/ts/core/thing/directory.ts index b701293369bf6329b6c955af687515af193243bb..afa39e22bfa5e71e4b42e1f53f10926940af487d 100644 --- a/src/ts/core/thing/directory.ts +++ b/src/ts/core/thing/directory.ts @@ -18,6 +18,7 @@ import { ISysFileInfo, SysDirectoryInfo, SysFileInfo } from './systemfile'; import { IPageTemplate } from './standard/page'; import { Recorder } from './recorder'; import { Container, IContainer } from './container'; +import { enqueueRagMcpUpload, reconcileRagMcpFiles } from '@/services/ragMcpSync'; import { IDocumentTemplate } from './standard/document'; /** 可为空的进度回调 */ @@ -331,11 +332,15 @@ export class Directory extends Container implements IDirector operate: BucketOpreates.List, }); if (res.success) { - this.files = (res.data || []) + const platformFiles = res.data || []; + this.files = platformFiles .filter((i) => !i.isDirectory) .map((item) => { return new SysFileInfo(item, this.sysDirectory); }); + if (this.metadata.code === 'RAGZSK') { + void reconcileRagMcpFiles(this.target.id, platformFiles); + } } // 查询是否包含引用文件 const fileLinks = this.resource.fileLinkColl.cache.filter( @@ -411,9 +416,17 @@ export class Directory extends Container implements IDirector this.sysDirectory.taskEmitter.changCallback(); }); if (data) { - const file = new SysFileInfo(data, this.sysDirectory); - this.files.push(file); - return file; + const createdFile = new SysFileInfo(data, this.sysDirectory); + this.files.push(createdFile); + if (this.metadata.code === 'RAGZSK') { + enqueueRagMcpUpload({ + scopeId: this.target.id, + sourceId: String((data as any).id ?? data.shareLink ?? data.key ?? ''), + filename: name, + content: file, + }); + } + return createdFile; } } async searchComment(commont: schema.XCommon): Promise { diff --git a/src/ts/core/thing/systemfile.ts b/src/ts/core/thing/systemfile.ts index 84427787583f3fa3e2f43d127d46fe7278ce8ec5..41b6771b75ee057ff7cf7d2cadd17a260aac3bc5 100644 --- a/src/ts/core/thing/systemfile.ts +++ b/src/ts/core/thing/systemfile.ts @@ -5,6 +5,7 @@ import { FileInfo, IFile, IFileInfo } from './fileinfo'; import { IDirectory } from './directory'; import { directoryOperates, entityOperates, fileOperates } from '../public'; import { Container, IContainer } from './container'; +import { enqueueRagMcpDelete } from '@/services/ragMcpSync'; /** 可为空的进度回调 */ export type OnProgress = (p: number) => void; @@ -117,6 +118,12 @@ export class SysFileInfo extends FileInfo implements ISysFileInf if (res.success) { this.directory.notifyReloadFiles(); this.directory.files = this.directory.files.filter((i) => i.key != this.key); + if (this.directory.metadata.code === 'RAGZSK') { + enqueueRagMcpDelete({ + scopeId: this.directory.target.id, + filename: this.filedata.name, + }); + } } return res.success; }