diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index 13796112..fa812476 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -780,11 +780,10 @@ function buildTurnReferenceBatches( continue; } const nextHumanIndex = findNextHumanIndex(messages, index + 1); - if (nextHumanIndex === -1 && isLoading) { - continue; - } const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex; - const assistant = findLastAssistantAnswer(messages, index + 1, endIndex); + const assistant = + findLastAssistantAnswer(messages, index + 1, endIndex) ?? + findFrontendRagAssistant(messages, index + 1, endIndex); if (!assistant?.id) { continue; } @@ -794,6 +793,14 @@ function buildTurnReferenceBatches( nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id, includeBeforeMessage: false, }).filter((source) => source.mode !== "none"); + // GeneralQA's single-RAG stream sends `docs` before the answer. Make + // those cards available immediately, while keeping normal streaming turns + // on their existing "wait for an answer" behavior. + const isFrontendRagTurn = + isSingleRoundRagMessage(assistant) || isMultiRoundRagMessage(assistant); + if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) { + continue; + } if (sources.length > 0) { batches.push({ id: assistant.id, @@ -832,6 +839,23 @@ function findLastAssistantAnswer( return null; } +function findFrontendRagAssistant( + messages: Message[], + start: number, + end: number, +): Message | null { + for (let index = end - 1; index >= start; index -= 1) { + const message = messages[index]; + if ( + message?.type === "ai" && + (isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message)) + ) { + return message; + } + } + return null; +} + function hasAssistantAnswerContent(message: Message): boolean { const content = message.content; if (typeof content === "string") { diff --git a/frontend-web/src/core/threads/use-single-round-rag.ts b/frontend-web/src/core/threads/use-single-round-rag.ts index e90d5fc8..bd6ee3e5 100644 --- a/frontend-web/src/core/threads/use-single-round-rag.ts +++ b/frontend-web/src/core/threads/use-single-round-rag.ts @@ -5,7 +5,7 @@ import { readRuntimeEnv } from "@/env"; const STORAGE_PREFIX = "workspace.single-round-rag."; -type KnowledgeBaseChunk = { +type KnowledgeBaseChunk = Record & { answer?: unknown; docs?: unknown; }; @@ -168,36 +168,114 @@ function parseKnowledgeBaseChunk(jsonText: string): KnowledgeBaseChunk { } } -function flattenDocs(value: unknown): Record[] { - if (Array.isArray(value)) { - return value.flatMap((item) => flattenDocs(item)); +function readString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value; } - if (!value || typeof value !== "object") return []; - const record = value as Record; - if ("docs" in record) return flattenDocs(record.docs); - return [record]; + return undefined; } -function normalizeSources(value: unknown): RagSource[] { - return flattenDocs(value).map((item, index) => { - const title = String(item.m_title ?? item.title ?? item.source ?? `参考资料 ${index + 1}`); - const content = String(item.m_content ?? item.content ?? item.snippet ?? ""); - const source = item.source1 ?? item.source; - const rawUrl = item.url ?? item.link ?? item.href; - return { - id: String(item.recUuid ?? item.rec_uuid ?? item.uuid ?? item.id ?? "") || undefined, - title, - type: String(item.source1 ?? item.type ?? "知识库"), - snippet: content, - content, - source: typeof source === "string" ? source : undefined, - time: typeof item.m_publish === "string" ? item.m_publish : undefined, - url: typeof rawUrl === "string" && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined, - raw: item, - }; +function docsFallbackContent(docs: unknown): string | undefined { + const candidates = Array.isArray(docs) ? docs : [docs]; + return readString(...candidates); +} + +function buildKnowledgeDetailUrl(recUuid: string | undefined): string | undefined { + const knowledgeUrl = readRuntimeEnv("VITE_AIPI_KNOWLEDGE_URL")?.replace(/\/$/, ""); + if (!knowledgeUrl || !recUuid) return undefined; + return `${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${encodeURIComponent(recUuid)}`; +} + +function toRagSource( + record: Record, + index: number, + fallbackContent?: string, +): RagSource { + const title = + readString(record.m_title, record.title, record.source, record.source1) ?? + `参考资料 ${index + 1}`; + const content = + readString( + record.m_content, + // Some deployed responses spell this field incorrectly; keep it as a + // read-only compatibility alias rather than dropping the document. + record.m_conent, + record.content, + record.snippet, + fallbackContent, + record.url, + ) ?? ""; + const source = readString(record.source1, record.source, record.m_source); + const rawUrl = readString(record.url, record.link, record.href); + const recUuid = readString(record.recUuid, record.rec_uuid, record.uuid, record.id); + return { + id: recUuid, + title, + type: readString(record.type, record.source1) ?? "知识库", + snippet: content, + content, + source, + time: readString(record.m_publish, record.m_publicsh, record.time), + // GeneralQA uses recUuid to open the knowledge-detail page. Prefer that + // stable internal record link, then fall back to a backend-provided URL. + url: + buildKnowledgeDetailUrl(recUuid) ?? + (rawUrl && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined), + raw: record, + }; +} + +/** + * A single-RAG stream is a concatenation of source envelopes, for example: + * `{docs:["..."], recUuid:"...", m_title:"...", m_content:"..."}`. + * The root-level metadata is the source; `docs` merely marks the envelope and + * supplies a fallback body. This mirrors GeneralQA's per-chunk processing. + */ +function normalizeSources(chunk: KnowledgeBaseChunk): RagSource[] { + const hasRootMetadata = [ + chunk.recUuid, + chunk.rec_uuid, + chunk.m_title, + chunk.m_content, + chunk.m_conent, + chunk.source1, + chunk.m_publish, + chunk.m_publicsh, + ].some((value) => typeof value === "string" && value.trim()); + if (hasRootMetadata) { + return [toRagSource(chunk, 0, docsFallbackContent(chunk.docs))]; + } + + const docs = Array.isArray(chunk.docs) ? chunk.docs : chunk.docs ? [chunk.docs] : []; + return docs.flatMap((item, index): RagSource[] => { + if (typeof item === "string") { + return [toRagSource({}, index, item)]; + } + if (!item || typeof item !== "object") return []; + return [toRagSource(item as Record, index)]; }); } +function mergeSources(current: RagSource[], incoming: RagSource[]): RagSource[] { + const result = [...current]; + const keys = new Set( + current.map((source) => + source.id + ? `id:${source.id}` + : `content:${source.title}\u0000${source.content}\u0000${source.source ?? ""}`, + ), + ); + for (const source of incoming) { + const key = source.id + ? `id:${source.id}` + : `content:${source.title}\u0000${source.content}\u0000${source.source ?? ""}`; + if (keys.has(key)) continue; + keys.add(key); + result.push(source); + } + return result; +} + function createId(prefix: string) { return `${prefix}-${crypto.randomUUID()}`; } @@ -397,7 +475,9 @@ export function useSingleRoundRagMessages(threadId: string) { for (const jsonText of extracted.objects) { const chunk = parseKnowledgeBaseChunk(jsonText); if (typeof chunk.answer === "string") answerChunks.push({ answer: chunk.answer }); - if (chunk.docs !== undefined) sources = normalizeSources(chunk.docs); + if (chunk.docs !== undefined) { + sources = mergeSources(sources, normalizeSources(chunk)); + } refreshAssistant(); } }