From 067f6027327634879809c73bab52104b76643f20 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Fri, 14 Aug 2026 19:31:15 +0800 Subject: [PATCH 01/13] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/core/threads/use-single-round-rag.ts | 79 +++++++++++++------ 1 file changed, 53 insertions(+), 26 deletions(-) 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..3d3e925b 100644 --- a/frontend-web/src/core/threads/use-single-round-rag.ts +++ b/frontend-web/src/core/threads/use-single-round-rag.ts @@ -168,33 +168,60 @@ function parseKnowledgeBaseChunk(jsonText: string): KnowledgeBaseChunk { } } -function flattenDocs(value: unknown): Record[] { - if (Array.isArray(value)) { - return value.flatMap((item) => flattenDocs(item)); - } - if (!value || typeof value !== "object") return []; - const record = value as Record; - if ("docs" in record) return flattenDocs(record.docs); - return [record]; -} +/** + * Mirrors GeneralQA's `mapDocsToSources` behavior: `docs` may be an array, + * a single document, or a plain string. The previous adapter only accepted + * objects and consequently dropped the first stream payload whenever the + * knowledge-base service returned a string document. + */ +function normalizeSources(value: KnowledgeBaseChunk["docs"]): RagSource[] { + const docs = Array.isArray(value) ? value : value ? [value] : []; + return docs.flatMap((item, index): RagSource[] => { + if (typeof item === "string") { + return [ + { + title: `参考资料 ${index + 1}`, + type: "知识库", + snippet: item, + content: item, + raw: { content: item }, + }, + ]; + } + if (!item || typeof item !== "object") return []; -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, - }; + const record = item as Record; + const title = String( + record.title ?? record.m_title ?? record.source ?? `参考资料 ${index + 1}`, + ); + const content = String( + record.content ?? record.m_content ?? record.snippet ?? record.url ?? "", + ); + const source = record.source ?? record.source1 ?? record.title ?? record.m_title; + const rawUrl = record.url ?? record.link ?? record.href; + return [ + { + id: + String(record.recUuid ?? record.rec_uuid ?? record.uuid ?? record.id ?? "") || + undefined, + title, + type: String(record.source1 ?? record.type ?? "知识库"), + snippet: content, + content, + source: typeof source === "string" ? source : undefined, + time: + typeof record.m_publish === "string" + ? record.m_publish + : typeof record.time === "string" + ? record.time + : undefined, + url: + typeof rawUrl === "string" && /^https?:\/\//i.test(rawUrl) + ? rawUrl + : undefined, + raw: record, + }, + ]; }); } From 5c586652afb3fc0bf581bc10c8fc03dd8ea6135f Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Fri, 14 Aug 2026 21:25:29 +0800 Subject: [PATCH 02/13] daimhebing1 --- .../components/workspace/chats/chat-box.tsx | 32 +++- .../src/core/threads/use-single-round-rag.ts | 153 ++++++++++++------ 2 files changed, 131 insertions(+), 54 deletions(-) diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index f8021559..c185cb43 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -777,11 +777,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; } @@ -791,6 +790,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, @@ -829,6 +836,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 3d3e925b..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,63 +168,114 @@ function parseKnowledgeBaseChunk(jsonText: string): KnowledgeBaseChunk { } } +function readString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value; + } + return undefined; +} + +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, + }; +} + /** - * Mirrors GeneralQA's `mapDocsToSources` behavior: `docs` may be an array, - * a single document, or a plain string. The previous adapter only accepted - * objects and consequently dropped the first stream payload whenever the - * knowledge-base service returned a string document. + * 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(value: KnowledgeBaseChunk["docs"]): RagSource[] { - const docs = Array.isArray(value) ? value : value ? [value] : []; +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 [ - { - title: `参考资料 ${index + 1}`, - type: "知识库", - snippet: item, - content: item, - raw: { content: item }, - }, - ]; + return [toRagSource({}, index, item)]; } if (!item || typeof item !== "object") return []; - - const record = item as Record; - const title = String( - record.title ?? record.m_title ?? record.source ?? `参考资料 ${index + 1}`, - ); - const content = String( - record.content ?? record.m_content ?? record.snippet ?? record.url ?? "", - ); - const source = record.source ?? record.source1 ?? record.title ?? record.m_title; - const rawUrl = record.url ?? record.link ?? record.href; - return [ - { - id: - String(record.recUuid ?? record.rec_uuid ?? record.uuid ?? record.id ?? "") || - undefined, - title, - type: String(record.source1 ?? record.type ?? "知识库"), - snippet: content, - content, - source: typeof source === "string" ? source : undefined, - time: - typeof record.m_publish === "string" - ? record.m_publish - : typeof record.time === "string" - ? record.time - : undefined, - url: - typeof rawUrl === "string" && /^https?:\/\//i.test(rawUrl) - ? rawUrl - : undefined, - raw: record, - }, - ]; + 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()}`; } @@ -424,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(); } } From 47faa9bcdfc5a4e6fc3ab60da1d4ce54a039fb4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 16 Aug 2026 21:48:38 +0800 Subject: [PATCH 03/13] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/messages/message-list-item.tsx | 376 ++++++++++---- .../workspace/messages/message-list.tsx | 19 +- .../src/core/threads/use-multi-round-rag.ts | 459 +++++++++++++----- scripts/package-frontend-offline-deps.ps1 | 32 ++ scripts/package-frontend-offline-deps.sh | 28 ++ scripts/restore-frontend-offline-deps.ps1 | 42 ++ scripts/restore-frontend-offline-deps.sh | 38 ++ 7 files changed, 767 insertions(+), 227 deletions(-) create mode 100644 scripts/package-frontend-offline-deps.ps1 create mode 100644 scripts/package-frontend-offline-deps.sh create mode 100644 scripts/restore-frontend-offline-deps.ps1 create mode 100644 scripts/restore-frontend-offline-deps.sh diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index 66ab3a16..b6945e1c 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -6,9 +6,10 @@ import { ExternalLinkIcon, FileIcon, Loader2Icon, - PencilIcon, - SearchIcon, -} from 'lucide-react'; + PencilIcon, + SearchIcon, + SparklesIcon, +} from 'lucide-react'; import { memo, useCallback, @@ -16,9 +17,10 @@ import { useEffect, useMemo, useState, - type AnchorHTMLAttributes, - type ImgHTMLAttributes, -} from 'react'; + type AnchorHTMLAttributes, + type ImgHTMLAttributes, + type ReactNode, +} from 'react'; import rehypeKatex from 'rehype-katex'; import { Loader } from '@/components/ai-elements/loader'; @@ -838,7 +840,7 @@ function FrontendRagSourcesPanel({ const [open, setOpen] = useState(false); if (sources.length === 0) return null; return ( - + - {open && ( -
- {trace.rounds.map((round, index) => ( - - ))} -
+ {open && ( +
+ {trace.rounds.map((round, index) => ( +
+ {index < trace.rounds.length - 1 && ( + + )} + + +
+ ))} +
)}
@@ -1014,7 +1030,7 @@ function MultiRoundRagContent({ ); } -function MultiRoundRagRoundCard({ +function MultiRoundRagRoundCard({ index, round, isLoading, @@ -1026,78 +1042,228 @@ function MultiRoundRagRoundCard({ isLoading: boolean; rehypePlugins: MarkdownContentProps['rehypePlugins']; components: MarkdownContentProps['components']; -}) { - return ( -
-
- - {index} - - {round.title} - {round.isSearching && } -
- {round.thought && ( -
-
思考内容
- -
- )} - {round.body && ( -
-
本轮正文
- -
- )} - {round.query && ( -
- - {round.query} -
- )} - {round.retrievals.length > 0 && ( -
-
- 检索内容 · {round.retrievals.length} 条 -
-
- {round.retrievals.map((item, resultIndex) => ( -
- - {resultIndex + 1}. - {item.title} - -
- {item.content || '暂无资料摘要'} -
- {(item.source || item.time) && ( -
- {[item.source, item.time].filter(Boolean).join(' · ')} -
- )} -
- ))} -
-
- )} -
- ); -} +}) { + const [open, setOpen] = useState(!round.query || round.isSearching); + useEffect(() => { + // A newly created round stays expanded to expose its thought and loading + // query. Once that query has returned, retain only the round header until + // the user asks to inspect the completed detail again. + setOpen(!round.query || round.isSearching); + }, [round.id, round.isSearching, round.query]); + + return ( + +
+ + + + +
+ {round.thought && ( + } + > + + + )} + {round.body && ( +
+
本轮正文
+ +
+ )} + {(round.query || round.isSearching) && ( +
+ {round.isSearching ? ( + + ) : ( + + )} + 检索词 + + {round.query || '正在生成检索词…'} + +
+ )} + {(round.query || round.isSearching || round.retrievals.length > 0) && ( + } + suffix={{round.retrievals.length} 条} + > + {round.isSearching ? ( +
+ + 正在召回相关内容… +
+ ) : ( +
+ {round.retrievals.length > 0 ? ( + round.retrievals.map((item, resultIndex) => ( + + )) + ) : ( +

+ 本轮未召回相关资料。 +

+ )} +
+ )} +
+ )} +
+
+
+
+ ); +} + +function MultiRoundRagDetailSection({ + title, + icon, + suffix, + defaultOpen = true, + children, +}: { + title: string; + icon: ReactNode; + suffix?: ReactNode; + defaultOpen?: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + useEffect(() => setOpen(defaultOpen), [defaultOpen]); + + return ( + +
+ + + + + {children} + +
+
+ ); +} + +function MultiRoundRagResultCard({ + item, + index, +}: { + item: MultiRoundRagRound['retrievals'][number]; + index: number; +}) { + const [open, setOpen] = useState(index === 1); + const sourceMeta = [item.source, item.time].filter(Boolean).join(' · '); + + return ( + +
+ + + + +

+ {item.content || '暂无资料摘要'} +

+ {(sourceMeta || item.url) && ( +
+ {sourceMeta && {sourceMeta}} + {item.url && ( + + 查看原文 + + + )} +
+ )} +
+
+
+ ); +} function resolveSingleRoundRagSourceUrl( source: RuntimeDisplaySource | null, diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 488fbec6..016168d8 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -255,15 +255,15 @@ export function MessageList({ const [settings] = useLocalSettings(); const ragModeEnabled = settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); - const hasSingleRoundRag = useMemo( + const hasSingleRoundRag = useMemo( () => messages.some( (message) => isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message), ), - [messages], - ); - const referencesEnabled = ragModeEnabled || hasSingleRoundRag; + [messages], + ); + const referencesEnabled = ragModeEnabled || hasSingleRoundRag; const { skills } = useSkills(); const skillDisplayMap = useMemo( () => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()), @@ -436,11 +436,12 @@ export function MessageList({ className={cn("flex size-full flex-col justify-center", className)} > - {groupMessages(messages, (group) => { diff --git a/frontend-web/src/core/threads/use-multi-round-rag.ts b/frontend-web/src/core/threads/use-multi-round-rag.ts index ee8ec224..45737f5b 100644 --- a/frontend-web/src/core/threads/use-multi-round-rag.ts +++ b/frontend-web/src/core/threads/use-multi-round-rag.ts @@ -13,6 +13,9 @@ export type MultiRoundRagRetrieval = { time?: string; recUuid?: string; url?: string; + /** Original tool payload, retained so a future renderer can add more fields + * without losing data received from the multi-round service. */ + raw?: Record; }; export type MultiRoundRagRound = { @@ -87,8 +90,11 @@ function asRecord(value: unknown): Record | null { : null; } -function asString(value: unknown): string { - return typeof value === "string" ? value : ""; +/** Values in the deployed tools are occasionally numeric IDs, not strings. */ +function asText(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return ""; } function parseJson(value: unknown): unknown { @@ -103,8 +109,11 @@ function parseJson(value: unknown): unknown { function parseEventLine(line: string): MultiRoundEvent | null { const trimmed = line.trim(); if (!trimmed.startsWith("data:")) return null; + const payload = trimmed.slice("data:".length).trim(); + if (!payload || payload === "[DONE]") return { event: "done" }; try { - return JSON.parse(trimmed.slice("data:".length).trim()) as MultiRoundEvent; + const parsed = parseJson(payload); + return asRecord(parsed) as MultiRoundEvent | null; } catch { return null; } @@ -141,10 +150,10 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) { if (current.title === "正在准备检索" && !current.thought && !current.query) { current.title = "思考过程"; current.thought = thought; - return; + return current; } current.isSearching = false; - trace.rounds.push({ + const next = { id: createId("multi-rag-round"), title: "思考过程", thought, @@ -152,7 +161,9 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) { query: "", isSearching: true, retrievals: [], - }); + }; + trace.rounds.push(next); + return next; } function appendRoundBody(trace: MultiRoundRagTrace, body: string) { @@ -162,49 +173,94 @@ function appendRoundBody(trace: MultiRoundRagTrace, body: string) { current.body = `${current.body ?? ""}${body}`; } -function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] { - const root = asRecord(parseJson(value)); - const list = - (Array.isArray(root?.results) && root.results) || - (Array.isArray(root?.organic) && root.organic) || - []; - return list.flatMap((item, index) => { +function firstArray(values: unknown[]): unknown[] { + for (const value of values) { + const parsed = parseJson(value); + if (Array.isArray(parsed)) return parsed; + } + return []; +} + +function toRecordList(value: unknown): Record[] { + const parsed = parseJson(value); + const root = asRecord(parsed); + const list = Array.isArray(parsed) + ? parsed + : firstArray([ + root?.results, + root?.organic, + root?.data, + root?.items, + root?.list, + root?.documents, + ]); + return list.flatMap((item) => { + if (typeof item === "string") return [{ content: item }]; const record = asRecord(item); - if (!record) return []; - const recUuid = asString(record.recUuid ?? record.rec_uuid); - const url = asString(record.url ?? record.link ?? record.href); + return record ? [record] : []; + }); +} + +/** + * GeneralQA's service fans out to several business search tools. Normalising + * all of their result shapes here prevents a successful tool call from being + * silently absent just because it did not use `results` or `organic`. + */ +function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] { + const list = toRecordList(value); + return list.flatMap((item, index) => { + const recUuid = asText(item.recUuid ?? item.rec_uuid ?? item.uuid); + const url = asText(item.url ?? item.link ?? item.href); + const title = asText( + item.title ?? item.m_title ?? item.taskName ?? item.user_name ?? item.name ?? item.user_id, + ); + const content = asText( + item.content ?? + item.m_content ?? + item.m_conent ?? + item.snippet ?? + item.text ?? + item.overview ?? + item.page_content ?? + item.description ?? + item.summary, + ); return [ { - id: asString(record.id) || recUuid || undefined, - title: asString(record.title) || `检索资料 ${index + 1}`, - content: asString(record.content ?? record.snippet ?? record.text), - source: asString(record.source ?? record.source1 ?? url) || undefined, - time: asString(record.publish_time ?? record.m_publish ?? record.date) || undefined, + id: asText(item.id) || recUuid || undefined, + title: title || `检索资料 ${index + 1}`, + content: content || (url && !title ? url : ""), + source: asText(item.source ?? item.source1 ?? item.m_source ?? item.platform) || undefined, + time: asText(item.publish_time ?? item.m_publish ?? item.m_publicsh ?? item.date ?? item.time) || undefined, recUuid: recUuid || undefined, url: /^https?:\/\//i.test(url) ? url : undefined, + raw: item, }, ]; }); } function mapReferences(value: unknown): MultiRoundRagSource[] { - const record = asRecord(value); - const list = Array.isArray(record?.references) ? record.references : []; + const parsed = parseJson(value); + const record = asRecord(parsed); + const list = Array.isArray(parsed) + ? parsed + : firstArray([record?.references, record?.results, record?.sources, record?.documents]); return list.flatMap((item, index) => { const source = asRecord(item); if (!source) return []; - const content = asString(source.content ?? source.snippet); - const rawUrl = asString(source.url ?? source.link ?? source.href); - const recUuid = asString(source.recUuid ?? source.rec_uuid ?? source.uuid); + const content = asText(source.content ?? source.m_content ?? source.m_conent ?? source.snippet ?? source.text); + const rawUrl = asText(source.url ?? source.link ?? source.href); + const recUuid = asText(source.recUuid ?? source.rec_uuid ?? source.uuid); return [ { - id: asString(source.id) || recUuid || undefined, - title: asString(source.title) || `参考资料 ${index + 1}`, - type: asString(source.type) || "参考资料", - snippet: asString(source.snippet) || content, + id: asText(source.id) || recUuid || undefined, + title: asText(source.title ?? source.m_title ?? source.taskName ?? source.user_name) || `参考资料 ${index + 1}`, + type: asText(source.type) || "参考资料", + snippet: asText(source.snippet) || content, content, - source: asString(source.source ?? source.source1) || undefined, - time: asString(source.time ?? source.m_publish ?? source.publish_time) || undefined, + source: asText(source.source ?? source.source1 ?? source.m_source ?? source.platform) || undefined, + time: asText(source.time ?? source.m_publish ?? source.m_publicsh ?? source.publish_time ?? source.date) || undefined, url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined, raw: source, }, @@ -222,6 +278,71 @@ function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace { }; } +function toolTitle(toolName: string): string { + switch (toolName) { + case "local_search": + return "本地知识检索"; + case "tavily_search": + return "联网检索"; + case "google_search": + case "getTaskLikeTop": + return "思考过程"; + case "search_user_profile": + return "靶标平台画像库"; + case "get_taiwan_news": + case "search_keyword_taiwan_news": + return "台湾热点新闻"; + case "get_world_news": + return "世界热点新闻"; + case "get_root_messaage_comment": + return "获取该消息的全部评论"; + case "search_user_behavior": + return "查询该用户的历史行为"; + case "get_america_news": + return "美国热点新闻"; + case "get_time_data": + return "态势新闻搜索"; + case "get_textid_content": + return "获取新闻详细内容"; + default: + return toolName || "检索过程"; + } +} + +function isSearchTool(toolName: string): boolean { + return [ + "local_search", + "tavily_search", + "google_search", + "getTaskLikeTop", + "search_user_profile", + "get_taiwan_news", + "search_keyword_taiwan_news", + "get_world_news", + "get_root_messaage_comment", + "search_user_behavior", + "get_america_news", + "get_time_data", + ].includes(toolName); +} + +function queryFromToolInput(input: Record, result: unknown): string { + const resultRecord = asRecord(parseJson(result)); + const direct = asText(input.q ?? input.query ?? input.keyword ?? input.user_fig); + if (direct) return direct; + if (input.s || input.e) { + return [asText(input.s), asText(input.e)].filter(Boolean).join(" - "); + } + return asText(resultRecord?.query ?? resultRecord?.q); +} + +function textFromEventData(value: unknown): string { + const parsed = parseJson(value); + if (typeof parsed === "string") return parsed; + const record = asRecord(parsed); + return asText(record?.text ?? record?.content ?? record?.message ?? record?.answer ?? record?.output); +} + function isSingleRoundRagMessage(message: Message) { return message.additional_kwargs?.single_round_rag === true; } @@ -370,92 +491,204 @@ export function useMultiRoundRagMessages(threadId: string) { const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; + let activeThoughtRound: MultiRoundRagRound | null = null; + let activeThoughtTailIsAnswer = false; + let answerFromShowText = false; + + const appendAnswer = (text: string, allowCompleteSnapshot = false) => { + if (!text) return; + // Some gateways emit one complete `message` after a `show_text` + // answer. Restrict snapshot detection to that case and to a + // meaningful length: adjacent streaming chunks can legitimately + // contain the same character, and must never be discarded. + if ( + allowCompleteSnapshot && + trace.answer.length >= 16 && + text.startsWith(trace.answer) + ) { + trace.answer = text; + return; + } + trace.answer += text; + }; + + const consumeShowText = (text: string, tailIsAnswer = false) => { + if (!text) return; + if (activeThoughtRound) { + const endIndex = text.indexOf(""); + if (endIndex < 0) { + activeThoughtRound.thought += text; + return; + } + activeThoughtRound.thought += text.slice(0, endIndex); + const roundBody = text.slice(endIndex + "".length).trim(); + if (roundBody) { + if (activeThoughtTailIsAnswer) { + appendAnswer(roundBody); + answerFromShowText = false; + trace.isComplete = true; + } else { + appendRoundBody(trace, roundBody); + } + } + activeThoughtRound = null; + activeThoughtTailIsAnswer = false; + return; + } + + const thinkStart = text.indexOf(""); + if (thinkStart < 0) { + const current = latestRound(trace); + current.isSearching = false; + appendAnswer(text); + answerFromShowText = true; + trace.isComplete = true; + return; + } + + const prefix = text.slice(0, thinkStart).trim(); + if (prefix) appendRoundBody(trace, prefix); + + // A full … envelope is normally delivered as one + // tool call. Keep the unfinished variant open as well: production + // proxies are allowed to split an SSE frame at any byte boundary. + const thinkEnd = text.indexOf("", thinkStart + "".length); + const thought = text + .slice(thinkStart + "".length, thinkEnd < 0 ? undefined : thinkEnd) + .trim(); + const thoughtRound = addThinkingRound(trace, thought); + if (thinkEnd < 0) { + activeThoughtRound = thoughtRound; + activeThoughtTailIsAnswer = tailIsAnswer; + return; + } + const roundBody = text.slice(thinkEnd + "".length).trim(); + if (roundBody) { + if (tailIsAnswer) { + appendAnswer(roundBody); + answerFromShowText = false; + trace.isComplete = true; + } else { + appendRoundBody(trace, roundBody); + } + } + }; + + const consumeToolCall = (payload: unknown) => { + const parsedPayload = parseJson(payload); + const tools = Array.isArray(parsedPayload) ? parsedPayload : [parsedPayload]; + for (const toolValue of tools) { + const tool = asRecord(toolValue); + if (!tool) continue; + const toolName = asText(tool.tool_name ?? tool.name); + const input = asRecord(parseJson(tool.tool_input ?? tool.input ?? tool.arguments)) ?? {}; + const current = latestRound(trace); + + if (toolName === "show_text") { + consumeShowText(asText(input.text ?? input.content)); + continue; + } + + if (toolName === "get_textid_content") { + current.title = toolTitle(toolName); + const article = asRecord(parseJson(input.result)); + const body = asText(article?.content ?? article?.text ?? input.content ?? input.result); + if (body) appendRoundBody(trace, body); + current.isSearching = false; + continue; + } + + const hasResult = input.result !== undefined; + const query = queryFromToolInput(input, input.result); + if (isSearchTool(toolName) || hasResult) { + current.title = toolTitle(toolName); + current.query = query || current.query; + if (!hasResult && isSearchTool(toolName)) { + // GeneralQA can receive a model text event before the search + // tool announces its query. The query starts a new visible + // loading phase, so restore the round/trace state here and + // keep the query badge spinning until its result arrives. + current.isSearching = true; + trace.isComplete = false; + } else if (hasResult) { + const retrievals = mapRetrievals(input.result); + // An empty search response is still a completed response; it + // must replace a previous list instead of leaving stale cards. + current.retrievals = retrievals; + if (retrievals.length === 0) { + const fallbackBody = textFromEventData(input.result); + if (fallbackBody && fallbackBody !== current.query) { + appendRoundBody(trace, fallbackBody); + } + } + current.isSearching = false; + } + } + } + }; + + const finishTrace = () => { + trace.isComplete = true; + activeThoughtRound = null; + activeThoughtTailIsAnswer = false; + for (const round of trace.rounds) round.isSearching = false; + }; + + const consumeEvent = (event: MultiRoundEvent) => { + const eventType = asText(event.event); + const data = event.data; + if (eventType === "tool_call") { + consumeToolCall(data); + } else if (eventType === "message") { + const text = textFromEventData(data); + if (text) { + if (activeThoughtRound || /<\/?think>/i.test(text)) { + // Most deployments put thoughts in `show_text`, but this + // fallback also preserves a compatible gateway that emits + // `` in normal message chunks. + consumeShowText(text, true); + } else { + latestRound(trace).isSearching = false; + appendAnswer(text, answerFromShowText); + answerFromShowText = false; + trace.isComplete = true; + } + } + } else if (eventType === "references") { + sources = mapReferences(data); + } else if (eventType === "error") { + const message = textFromEventData(data) || "多轮 RAG 服务返回错误。"; + setError(message); + if (!trace.answer) trace.answer = message; + finishTrace(); + } else if (eventType === "done") { + // A few compatible gateway versions include the whole final + // answer in `done`; render it when no `message` chunk was sent. + const finalText = textFromEventData(data); + if (finalText && !trace.answer) appendAnswer(finalText); + finishTrace(); + } + refreshAssistant(); + }; + + const consumeLines = (flush = false) => { + const lines = buffer.split(/\r?\n/); + buffer = flush ? "" : (lines.pop() ?? ""); + for (const line of lines) { + const event = parseEventLine(line); + if (event) consumeEvent(event); + } + }; + while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split(/\r?\n/); - buffer = lines.pop() ?? ""; - for (const line of lines) { - const event = parseEventLine(line); - if (!event) continue; - const eventType = asString(event.event); - const data = event.data; - if (eventType === "tool_call") { - const tool = asRecord(data); - const toolName = asString(tool?.tool_name); - const input = asRecord(tool?.tool_input) ?? {}; - const current = latestRound(trace); - if (toolName === "show_text") { - const text = asString(input.text); - if (text.includes("")) { - const thinkEnd = text.indexOf(""); - addThinkingRound( - trace, - (thinkEnd >= 0 - ? text.slice(0, thinkEnd + "".length) - : text - ) - .replace(/<\/?think>/g, "") - .trim(), - ); - if (thinkEnd >= 0) { - const roundBody = text - .slice(thinkEnd + "".length) - .trim(); - if (roundBody) { - appendRoundBody(trace, roundBody); - } - } - } else { - // A non-thinking `show_text` immediately after a thought is - // this round's narrative; the terminal `message` event is - // still rendered only as the final answer below all rounds. - current.isSearching = false; - trace.answer += text; - trace.isComplete = true; - } - } else if ( - toolName === "local_search" || - toolName === "tavily_search" || - toolName === "google_search" - ) { - const queryData = asRecord(parseJson(input.result)); - current.title = - toolName === "local_search" ? "本地知识检索" : "联网检索"; - current.query = - asString(input.q ?? input.query) || - asString(queryData?.query) || - current.query; - const retrievals = mapRetrievals(input.result); - if (retrievals.length > 0) current.retrievals = retrievals; - current.isSearching = false; - } else if (toolName === "get_textid_content") { - const article = asRecord(parseJson(input.result)); - const body = - asString(article?.content ?? article?.text) || - asString(input.content); - if (body) appendRoundBody(trace, body); - current.isSearching = false; - } - refreshAssistant(); - } else if (eventType === "message" && typeof data === "string") { - latestRound(trace).isSearching = false; - trace.answer += data; - trace.isComplete = true; - refreshAssistant(); - } else if (eventType === "references") { - sources = mapReferences(data); - refreshAssistant(); - } else if (eventType === "done") { - trace.isComplete = true; - for (const round of trace.rounds) round.isSearching = false; - refreshAssistant(); - } - } + consumeLines(); } - trace.isComplete = true; - for (const round of trace.rounds) round.isSearching = false; + buffer += decoder.decode(); + consumeLines(true); + finishTrace(); refreshAssistant(); } catch (caught) { trace.isComplete = true; diff --git a/scripts/package-frontend-offline-deps.ps1 b/scripts/package-frontend-offline-deps.ps1 new file mode 100644 index 00000000..039ae169 --- /dev/null +++ b/scripts/package-frontend-offline-deps.ps1 @@ -0,0 +1,32 @@ +# Package frontend node_modules for offline development. +# Uses tar -h to dereference pnpm junctions so the archive is self-contained. +param( + [string]$OutputDir = (Join-Path (Split-Path $PSScriptRoot -Parent) "dist") +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path $PSScriptRoot -Parent +$Frontend = Join-Path $Root "frontend-web" +$Archive = Join-Path $OutputDir "frontend-offline-deps.tar.gz" + +if (-not (Test-Path (Join-Path $Frontend "node_modules\.pnpm"))) { + throw "frontend-web/node_modules is missing or incomplete. Run 'pnpm install' in frontend-web first." +} + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null +if (Test-Path $Archive) { Remove-Item $Archive -Force } + +Write-Host "Packaging frontend dependencies..." +Write-Host " Source: $Frontend" +Write-Host " Output: $Archive" + +$start = Get-Date +tar -czhf $Archive -C $Frontend node_modules package.json pnpm-lock.yaml +$elapsed = ((Get-Date) - $start).TotalSeconds +$sizeMb = [math]::Round((Get-Item $Archive).Length / 1MB, 2) + +Write-Host "" +Write-Host "Done in ${elapsed}s — ${sizeMb} MB" +Write-Host "" +Write-Host "Copy to offline machine, then run:" +Write-Host " scripts\restore-frontend-offline-deps.ps1 -Archive " diff --git a/scripts/package-frontend-offline-deps.sh b/scripts/package-frontend-offline-deps.sh new file mode 100644 index 00000000..fa1243a4 --- /dev/null +++ b/scripts/package-frontend-offline-deps.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FRONTEND="${ROOT}/frontend-web" +OUTPUT_DIR="${1:-${ROOT}/dist}" +ARCHIVE="${OUTPUT_DIR}/frontend-offline-deps.tar.gz" + +if [[ ! -d "${FRONTEND}/node_modules/.pnpm" ]]; then + echo "frontend-web/node_modules is missing or incomplete. Run 'pnpm install' in frontend-web first." >&2 + exit 1 +fi + +mkdir -p "${OUTPUT_DIR}" +rm -f "${ARCHIVE}" + +echo "Packaging frontend dependencies..." +echo " Source: ${FRONTEND}" +echo " Output: ${ARCHIVE}" + +tar -czhf "${ARCHIVE}" -C "${FRONTEND}" node_modules package.json pnpm-lock.yaml + +SIZE_MB=$(du -m "${ARCHIVE}" | awk '{print $1}') +echo "" +echo "Done — ${SIZE_MB} MB" +echo "" +echo "Copy to offline machine, then run:" +echo " scripts/restore-frontend-offline-deps.sh " diff --git a/scripts/restore-frontend-offline-deps.ps1 b/scripts/restore-frontend-offline-deps.ps1 new file mode 100644 index 00000000..a874b628 --- /dev/null +++ b/scripts/restore-frontend-offline-deps.ps1 @@ -0,0 +1,42 @@ +# Restore frontend node_modules from an offline dependency archive. +param( + [Parameter(Mandatory = $true)] + [string]$Archive +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path $PSScriptRoot -Parent +$Frontend = Join-Path $Root "frontend-web" + +if (-not (Test-Path $Archive)) { + throw "Archive not found: $Archive" +} + +Write-Host "Restoring frontend dependencies to $Frontend ..." + +# Back up existing node_modules if present +$backup = Join-Path $Frontend "node_modules.bak" +if (Test-Path (Join-Path $Frontend "node_modules")) { + if (Test-Path $backup) { Remove-Item $backup -Recurse -Force } + Rename-Item (Join-Path $Frontend "node_modules") "node_modules.bak" + Write-Host " Existing node_modules backed up to node_modules.bak" +} + +tar -xzf $Archive -C $Frontend + +# Quick sanity check +$required = @( + "node_modules\.pnpm", + "node_modules\react\package.json", + "node_modules\vite\package.json" +) +foreach ($rel in $required) { + if (-not (Test-Path (Join-Path $Frontend $rel))) { + throw "Restore incomplete — missing $rel" + } +} + +Write-Host "" +Write-Host "Restore complete. You can now run:" +Write-Host " cd frontend-web" +Write-Host " pnpm dev" diff --git a/scripts/restore-frontend-offline-deps.sh b/scripts/restore-frontend-offline-deps.sh new file mode 100644 index 00000000..53560555 --- /dev/null +++ b/scripts/restore-frontend-offline-deps.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +ARCHIVE="$1" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FRONTEND="${ROOT}/frontend-web" + +if [[ ! -f "${ARCHIVE}" ]]; then + echo "Archive not found: ${ARCHIVE}" >&2 + exit 1 +fi + +echo "Restoring frontend dependencies to ${FRONTEND} ..." + +if [[ -d "${FRONTEND}/node_modules" ]]; then + rm -rf "${FRONTEND}/node_modules.bak" + mv "${FRONTEND}/node_modules" "${FRONTEND}/node_modules.bak" + echo " Existing node_modules backed up to node_modules.bak" +fi + +tar -xzf "${ARCHIVE}" -C "${FRONTEND}" + +for rel in node_modules/.pnpm node_modules/react/package.json node_modules/vite/package.json; do + if [[ ! -e "${FRONTEND}/${rel}" ]]; then + echo "Restore incomplete — missing ${rel}" >&2 + exit 1 + fi +done + +echo "" +echo "Restore complete. You can now run:" +echo " cd frontend-web" +echo " pnpm dev" From 771957429ad50ef062e4a1c0f57047505585c05f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Mon, 17 Aug 2026 18:16:05 +0800 Subject: [PATCH 04/13] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=20Deep=20Research=20?= =?UTF-8?q?=E5=8F=AF=E9=9D=A0=E6=80=A7=EF=BC=8C=E5=B9=B6=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=208BF=20=E4=BB=BB=E5=8A=A1=E5=8D=A1=E7=89=87=E4=B8=8E=E4=BC=9A?= =?UTF-8?q?=E5=95=86=E4=B8=8A=E4=B8=8B=E6=96=87=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 MySQL 读写分离下的持久化与 SSE 重连问题,报告完成前先落库;8BF 嵌入页支持 taskId 任务卡与独立意图 metadata;前端统一 createClientId 并更新 runtime 配置。 Co-authored-by: Cursor --- .gitignore | 6 + frontend-web/public/runtime-config.js | 10 +- .../settings/appearance-settings-page.tsx | 6 +- .../custom-theme-library-settings.tsx | 6 +- .../src/core/document-rewrite/context.tsx | 6 +- .../use-sentiment-agent-chat.ts | 7 +- .../src/core/threads/use-multi-round-rag.ts | 3 +- .../src/core/threads/use-single-round-rag.ts | 3 +- .../eightbf-roundtable/api/task-context.ts | 289 ++++++++++ .../components/EightBfTaskCard.tsx | 58 ++ .../pages/EightBfRoundtablePage.tsx | 87 ++- .../src/eightbf-roundtable/styles/step1.css | 96 ++++ frontend-web/src/lib/client-id.ts | 27 + .../src/pages/PluginManagementPage.tsx | 6 +- .../src/position-roundtable/api/sessions.ts | 6 +- .../hooks/useStep1Intent.ts | 5 +- .../pages/RoundtablePlanningPage.tsx | 68 ++- .../strategy-components/api/deep-research.ts | 3 +- offline-backend-20260512/backend/CLAUDE.md | 29 + offline-backend-20260512/backend/README.md | 2 +- .../app/gateway/deep_research_job_executor.py | 82 ++- .../app/gateway/routers/deep_research.py | 107 +++- .../backend/app/gateway/routers/intent.py | 27 +- .../deep_research/adapters/event_sink.py | 109 +++- .../middlewares/summarization_middleware.py | 274 +++++---- .../persistence/deep_research_events/sql.py | 51 +- .../persistence/deep_research_jobs/sql.py | 38 +- .../persistence/deep_research_messages/sql.py | 13 +- .../persistence/deep_research_sessions/sql.py | 18 +- .../deep_research_sources/model.py | 9 +- .../persistence/deep_research_sources/sql.py | 14 +- .../tests/test_deep_research_event_sink.py | 83 +++ ...test_deep_research_executor_reliability.py | 66 +++ .../tests/test_deep_research_persistence.py | 538 ++++++++++++++++++ .../tests/test_deep_research_start_job.py | 156 +++++ .../tests/test_summarization_human_message.py | 30 + 36 files changed, 2038 insertions(+), 300 deletions(-) create mode 100644 frontend-web/src/eightbf-roundtable/api/task-context.ts create mode 100644 frontend-web/src/eightbf-roundtable/components/EightBfTaskCard.tsx create mode 100644 frontend-web/src/lib/client-id.ts create mode 100644 offline-backend-20260512/backend/tests/test_deep_research_event_sink.py create mode 100644 offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py create mode 100644 offline-backend-20260512/backend/tests/test_deep_research_persistence.py create mode 100644 offline-backend-20260512/backend/tests/test_deep_research_start_job.py create mode 100644 offline-backend-20260512/backend/tests/test_summarization_human_message.py diff --git a/.gitignore b/.gitignore index b7e4cc9e..3fcfdf74 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,12 @@ offline-backend-20260512/backend/tests/* # Keep the model configuration regression coverage versioned even though # this deployment repository otherwise ignores newly added backend tests. !offline-backend-20260512/backend/tests/test_model_management.py +# Deep-research reliability regression coverage. +!offline-backend-20260512/backend/tests/test_deep_research_persistence.py +!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py +!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py +!offline-backend-20260512/backend/tests/test_deep_research_start_job.py +!offline-backend-20260512/backend/tests/test_summarization_human_message.py deploy .history diff --git a/frontend-web/public/runtime-config.js b/frontend-web/public/runtime-config.js index 66470d66..e24ea0c7 100644 --- a/frontend-web/public/runtime-config.js +++ b/frontend-web/public/runtime-config.js @@ -32,10 +32,10 @@ window.__CM_RUNTIME_CONFIG__ = { VITE_BRAND_WELCOME_DARK_URL: "", // DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。 - // VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026", + // VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow", // LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。 - // VITE_LANGGRAPH_BASE_URL: "http://10.19.200.5:2026/api", + // VITE_LANGGRAPH_BASE_URL: "https://ch1.b.uat.4.cn/deerflow/api", // Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。 VITE_CANVAS_LANGGRAPH_BASE_URL: "", @@ -50,6 +50,10 @@ window.__CM_RUNTIME_CONFIG__ = { // CMZS 后端接口:任务管理、任务分析、三情分析、行动分析等业务接口。 VITE_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm", + // 8BF 嵌入页专用任务系统接口。部署到其他系统时只需修改这一项, + // 不影响原多智能体会商使用的任务详情接口。 + VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm", + // 佳来模型对话接口。 VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt", @@ -90,7 +94,7 @@ window.__CM_RUNTIME_CONFIG__ = { VITE_AIPI_FRONTEND_URL: "http://47.88.25.99:3000", // Consumer 接口:深链任务详情 cop-task-detail、研判情报报告 selectSqReport 等使用。 - VITE_AIPI_CONSUMER_URL: "http://10.19.200.5:8002/consumer", + VITE_AIPI_CONSUMER_URL: "https://ch1.b.uat.4.cn/consumer", // A 系统跳转地址:人物、组织、事件等列表项打开链接使用。 VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain", diff --git a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx index d3320001..8d16f7e2 100644 --- a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx @@ -54,6 +54,7 @@ import type { CompactShortcutRouteParam, } from "@/core/system-settings/api"; import { cn } from "@/lib/utils"; +import { createClientId } from "@/lib/client-id"; import { APP_THEME_COLORS, LOGIN_SOURCE_OPTIONS, @@ -77,10 +78,7 @@ const languageOptions: { value: Locale; label: string }[] = [ const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue"; function makeShortcutId() { - if (typeof crypto !== "undefined" && "randomUUID" in crypto) { - return crypto.randomUUID(); - } - return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`; + return createClientId("shortcut"); } function newShortcutParam(): CompactShortcutRouteParam { diff --git a/frontend-web/src/components/workspace/settings/custom-theme-library-settings.tsx b/frontend-web/src/components/workspace/settings/custom-theme-library-settings.tsx index e7f30e0d..ce84813d 100644 --- a/frontend-web/src/components/workspace/settings/custom-theme-library-settings.tsx +++ b/frontend-web/src/components/workspace/settings/custom-theme-library-settings.tsx @@ -43,6 +43,7 @@ import { customThemeTextContrast, type CustomThemeAdvancedKey, } from "@/core/theme/custom-theme"; +import { createClientId } from "@/lib/client-id"; import { cn } from "@/lib/utils"; const MAX_CUSTOM_THEMES = 12; @@ -127,10 +128,7 @@ const ADVANCED_COLOR_FIELDS: Array<{ ]; function makeThemeId(): `custom-theme-${string}` { - const suffix = - typeof crypto !== "undefined" && "randomUUID" in crypto - ? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase() - : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; + const suffix = createClientId().replace(/[^a-z0-9-]/gi, "").toLowerCase(); return `custom-theme-${suffix}`; } diff --git a/frontend-web/src/core/document-rewrite/context.tsx b/frontend-web/src/core/document-rewrite/context.tsx index 22424354..638a3df5 100644 --- a/frontend-web/src/core/document-rewrite/context.tsx +++ b/frontend-web/src/core/document-rewrite/context.tsx @@ -6,6 +6,7 @@ import type { DocumentRewriteStage, } from "@/open-canvas/hooks/useDocumentRewrite"; import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite"; +import { createClientId } from "@/lib/client-id"; export type DocumentRewritePresentation = { id: string; @@ -74,10 +75,7 @@ const DocumentRewritePresentationContext = createContext(null); function createId() { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return "drw-ui-" + crypto.randomUUID(); - } - return "drw-ui-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2); + return createClientId("drw-ui"); } export function DocumentRewritePresentationProvider({ diff --git a/frontend-web/src/core/sentiment-agent/use-sentiment-agent-chat.ts b/frontend-web/src/core/sentiment-agent/use-sentiment-agent-chat.ts index f38b04df..a9417ba1 100644 --- a/frontend-web/src/core/sentiment-agent/use-sentiment-agent-chat.ts +++ b/frontend-web/src/core/sentiment-agent/use-sentiment-agent-chat.ts @@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk"; import { useCallback, useEffect, useRef, useState } from "react"; import { runSentimentAgent, toAgUiMessages } from "./api"; +import { createClientId } from "@/lib/client-id"; const STORAGE_PREFIX = "workspace.sentiment-agent."; @@ -53,9 +54,9 @@ export function useSentimentAgentChat(threadId: string) { const question = query.trim(); if (!question) return; - const assistantId = `sentiment-ai-${crypto.randomUUID()}`; + const assistantId = createClientId("sentiment-ai"); const userMessage: Message = { - id: `sentiment-human-${crypto.randomUUID()}`, + id: createClientId("sentiment-human"), type: "human", content: [{ type: "text", text: question }], additional_kwargs: { @@ -115,7 +116,7 @@ export function useSentimentAgentChat(threadId: string) { try { await runSentimentAgent({ threadId, - runId: `run-${crypto.randomUUID()}`, + runId: createClientId("run"), messages: history, signal: controller.signal, handlers: { diff --git a/frontend-web/src/core/threads/use-multi-round-rag.ts b/frontend-web/src/core/threads/use-multi-round-rag.ts index 45737f5b..805bee50 100644 --- a/frontend-web/src/core/threads/use-multi-round-rag.ts +++ b/frontend-web/src/core/threads/use-multi-round-rag.ts @@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk"; import { useCallback, useEffect, useRef, useState } from "react"; import { readRuntimeEnv } from "@/env"; +import { createClientId } from "@/lib/client-id"; const STORAGE_PREFIX = "workspace.multi-round-rag."; @@ -77,7 +78,7 @@ function writeStoredMessages(threadId: string, messages: Message[]) { } function createId(prefix: string) { - return `${prefix}-${crypto.randomUUID()}`; + return createClientId(prefix); } function isAbortError(error: unknown) { 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 bd6ee3e5..39c29326 100644 --- a/frontend-web/src/core/threads/use-single-round-rag.ts +++ b/frontend-web/src/core/threads/use-single-round-rag.ts @@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk"; import { useCallback, useEffect, useRef, useState } from "react"; import { readRuntimeEnv } from "@/env"; +import { createClientId } from "@/lib/client-id"; const STORAGE_PREFIX = "workspace.single-round-rag."; @@ -277,7 +278,7 @@ function mergeSources(current: RagSource[], incoming: RagSource[]): RagSource[] } function createId(prefix: string) { - return `${prefix}-${crypto.randomUUID()}`; + return createClientId(prefix); } function isAbortError(error: unknown) { diff --git a/frontend-web/src/eightbf-roundtable/api/task-context.ts b/frontend-web/src/eightbf-roundtable/api/task-context.ts new file mode 100644 index 00000000..2d7021a6 --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/api/task-context.ts @@ -0,0 +1,289 @@ +import { getBackendBaseURL } from "@/core/config"; +import { readRuntimeEnv } from "@/env"; + +export interface EightBfTask { + id: string; + externalTaskId: string; + title: string; + description: string; + direction: string; + overview: string; + priority: string; + taskPriority: string; + targetDirection: string; + taskStatus: string; + source: string; + raw: Record | null; +} + +export interface EightBfDictionaryOption { + label: string; + value: string; + alias?: string; + statusTone?: string; +} + +export interface EightBfTaskDictionaries { + priority: EightBfDictionaryOption[]; + direction: EightBfDictionaryOption[]; + status: EightBfDictionaryOption[]; +} + +export interface EightBfTaskContextPayload { + kind: "eightbf_roundtable_task"; + usage: string; + task_id: string; + task: { + id: string; + title: string; + description: string; + direction: string; + overview: string; + priority: string; + task_priority: string; + target_direction: string; + task_status: string; + }; + task_tags: { + priority: string; + direction: string; + status: string; + }; +} + +const TASK_DETAIL_PATH = "/task/{taskId}"; +const DICTIONARY_TYPES = { + priority: "task_priority", + direction: "task_direction", + status: "task_status", +} as const; + +function externalApiBase(): string { + // The position-roundtable Vue app calls this task system through + // VITE_API_BASE_URL (usually the /zncm gateway), not DeerFlow's own API. + return ( + readRuntimeEnv("VITE_8BF_TASK_API_BASE_URL")?.replace(/\/+$/, "") || + readRuntimeEnv("VITE_API_BASE_URL")?.replace(/\/+$/, "") || + getBackendBaseURL().replace(/\/+$/, "") + ); +} + +function externalUrl(path: string): string { + if (/^https?:\/\//i.test(path)) return path; + return `${externalApiBase()}${path.startsWith("/") ? path : `/${path}`}`; +} + +/** + * 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用 + * `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。 + * 两套 token 的用途不同,不能直接复用 `apiFetch` 的默认鉴权头;否则 + * 任务详情/字典接口可能返回 401,页面只会看到空任务卡。 + * + * 这里仍保留 include credentials,与原 Vue 请求保持一致;同时不走 + * `apiFetch` 的 401 自动清理逻辑,避免外部任务系统的鉴权失败误登出 + * DeerFlow 当前用户。 + */ +async function fetchExternalTaskSystem( + path: string, + init: RequestInit = {}, +): Promise { + const headers = new Headers(init.headers); + if (!headers.has("Authorization")) { + headers.set("Authorization", "Bearer admin"); + } + if (!headers.has("Accept")) { + headers.set("Accept", "application/json"); + } + return fetch(externalUrl(path), { + ...init, + credentials: init.credentials ?? "include", + headers, + }); +} + +function firstText(...values: unknown[]): string { + for (const value of values) { + if (value === undefined || value === null) continue; + const text = String(value).trim(); + if (text) return text; + } + return ""; +} + +function unwrapData(payload: unknown): Record { + let current: unknown = payload; + // Task services in different deployments wrap the object as either + // { data: task } or { data: { data: task } }. + for (let index = 0; index < 3; index += 1) { + if (!current || typeof current !== "object" || Array.isArray(current)) break; + const next = (current as { data?: unknown }).data; + if (!next || typeof next !== "object" || Array.isArray(next)) break; + current = next; + } + return current && typeof current === "object" && !Array.isArray(current) + ? (current as Record) + : {}; +} + +export function normalizeEightBfTask( + raw: Record | null | undefined, + fallbackTaskId = "", +): EightBfTask { + const source = raw ?? {}; + const id = firstText( + source.id, + source.taskId, + source.externalTaskId, + source.external_task_id, + fallbackTaskId, + ); + return { + id, + externalTaskId: firstText(source.externalTaskId, source.external_task_id, id), + title: firstText(source.title, source.taskName, source.task_name, source.name), + description: firstText( + source.description, + source.taskDescription, + source.task_description, + source.taskContent, + source.task_content, + source.content, + ), + direction: firstText( + source.direction, + source.taskDirection, + source.task_direction, + ), + overview: firstText(source.overview, source.summary), + priority: firstText(source.priority, source.urgency), + taskPriority: firstText( + source.taskPriority, + source.task_priority, + source.priority, + source.urgency, + ), + targetDirection: firstText( + source.targetDirection, + source.target_direction, + source.direction, + source.taskDirection, + source.task_direction, + ), + taskStatus: firstText(source.taskStatus, source.task_status, source.status), + source: "测试分析岗任务详情接口", + raw: source, + }; +} + +function dictionaryRows(payload: unknown): unknown[] { + if (Array.isArray(payload)) return payload; + if (payload && typeof payload === "object") { + const directData = (payload as { data?: unknown }).data; + if (Array.isArray(directData)) return directData; + if (directData && typeof directData === "object") { + const nested = directData as { rows?: unknown; list?: unknown }; + if (Array.isArray(nested.rows)) return nested.rows; + if (Array.isArray(nested.list)) return nested.list; + } + } + const data = unwrapData(payload); + if (Array.isArray(data.rows)) return data.rows; + if (Array.isArray(data.list)) return data.list; + return []; +} + +function normalizeDictionary(payload: unknown): EightBfDictionaryOption[] { + return dictionaryRows(payload) + .map((item) => { + if (!item || typeof item !== "object") return null; + const row = item as Record; + const label = firstText(row.dictLabel, row.label); + const value = firstText(row.dictValue, row.dict_value, row.value); + if (!label || !value) return null; + return { + label, + value, + alias: firstText(row.alias) || undefined, + statusTone: firstText(row.statusTone, row.status_tone) || undefined, + } as EightBfDictionaryOption; + }) + .filter((item): item is EightBfDictionaryOption => item !== null); +} + +export async function fetchEightBfTask(taskId: string): Promise { + const encodedId = encodeURIComponent(taskId); + const path = TASK_DETAIL_PATH.replaceAll("{taskId}", encodedId); + const response = await fetchExternalTaskSystem(path, { method: "GET" }); + if (!response.ok) { + throw new Error(`任务详情接口请求失败(${response.status})`); + } + const payload = (await response.json()) as unknown; + const task = normalizeEightBfTask(unwrapData(payload), taskId); + // Keep the external route id as the stable scope even if a deployment's + // response includes a legacy/internal numeric id. + return { ...task, id: taskId, externalTaskId: taskId }; +} + +export async function fetchEightBfTaskDictionaries(): Promise { + const entries = await Promise.all( + (Object.entries(DICTIONARY_TYPES) as Array<[ + keyof EightBfTaskDictionaries, + string, + ]>).map(async ([key, path]) => { + try { + const response = await fetchExternalTaskSystem( + `/system/dict/data/type/${path}`, + { method: "GET" }, + ); + if (!response.ok) throw new Error(`字典接口请求失败(${response.status})`); + return [key, normalizeDictionary(await response.json())] as const; + } catch (error) { + // A dictionary failure must not hide the task card; the card falls back + // to the raw task codes and the detail request remains usable. + console.warn(`[8bf] ${key} 字典加载失败`, error); + return [key, []] as const; + } + }), + ); + return Object.fromEntries(entries) as EightBfTaskDictionaries; +} + +export function dictionaryText( + options: EightBfDictionaryOption[] | undefined, + value: unknown, + preferredField: "label" | "alias" = "label", +): string { + const raw = firstText(value); + if (!raw) return ""; + const match = options?.find((option) => option.value === raw); + return firstText(match?.[preferredField], match?.label, raw); +} + +export function buildEightBfTaskContext( + task: EightBfTask | null, + dictionaries: EightBfTaskDictionaries, +): EightBfTaskContextPayload | null { + if (!task?.id) return null; + return { + kind: "eightbf_roundtable_task", + usage: + "这是 8BF 页面左上角任务卡片中的任务信息。请直接基于这些信息理解用户问题,不要要求用户重复提供已经存在的任务名称、任务描述、任务方向或任务状态。", + task_id: task.id, + task: { + id: task.id, + title: task.title, + description: task.description, + direction: task.direction, + overview: task.overview, + priority: task.priority, + task_priority: task.taskPriority, + target_direction: task.targetDirection, + task_status: task.taskStatus, + }, + task_tags: { + priority: dictionaryText(dictionaries.priority, task.taskPriority), + direction: dictionaryText(dictionaries.direction, task.targetDirection, "alias"), + status: dictionaryText(dictionaries.status, task.taskStatus), + }, + }; +} diff --git a/frontend-web/src/eightbf-roundtable/components/EightBfTaskCard.tsx b/frontend-web/src/eightbf-roundtable/components/EightBfTaskCard.tsx new file mode 100644 index 00000000..48e70eb5 --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/components/EightBfTaskCard.tsx @@ -0,0 +1,58 @@ +import { Clipboard, Loader2 } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { + dictionaryText, + type EightBfDictionaryOption, + type EightBfTask, +} from "@/eightbf-roundtable/api/task-context"; + +interface EightBfTaskCardProps { + task: EightBfTask | null; + dictionaries: { + priority: EightBfDictionaryOption[]; + direction: EightBfDictionaryOption[]; + status: EightBfDictionaryOption[]; + }; + loading?: boolean; + error?: string | null; +} + +export function EightBfTaskCard({ + task, + dictionaries, + loading = false, + error, +}: EightBfTaskCardProps) { + const priority = dictionaryText(dictionaries.priority, task?.taskPriority); + const direction = dictionaryText(dictionaries.direction, task?.targetDirection, "alias"); + const status = dictionaryText(dictionaries.status, task?.taskStatus); + const tags = [priority, direction, status].filter(Boolean); + + return ( +
+
+ +

当前任务

+ {loading && } +
+ +
+

+ {task?.title || (task?.id ? `任务 ${task.id}` : "等待任务信息")} +

+ {tags.length > 0 && ( +
+ {tags.map((tag) => {tag})} +
+ )} +

+ {task?.description || task?.overview || (loading ? "正在读取任务详情…" : "暂无任务描述")} +

+ {error &&

{error}

} +
+
+ ); +} diff --git a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx index a5ba9789..1696ea51 100644 --- a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx +++ b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx @@ -1,7 +1,17 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; import { Loader2, Save, Settings2, X } from "lucide-react"; import { getAccountDisplayName, getStoredAuth } from "@/core/auth"; +import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard"; +import { + buildEightBfTaskContext, + fetchEightBfTask, + fetchEightBfTaskDictionaries, + normalizeEightBfTask, + type EightBfTask, + type EightBfTaskDictionaries, +} from "@/eightbf-roundtable/api/task-context"; import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow"; import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage"; import { listChains } from "@/roundtable-planning/api/chains"; @@ -26,10 +36,28 @@ const BUSINESS_CODE = "8BF"; */ export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) { const auth = getStoredAuth(); + const [searchParams] = useSearchParams(); + const taskId = useMemo( + () => searchParams.get("taskId")?.trim() || "", + [searchParams], + ); // 8BF 公共业务链由指定账号维护,不依赖外部透传的管理员角色。 // 与敏感词管理使用同一可信账户展示名规则(登录邮箱 @ 前缀)。 const canConfigureChain = getAccountDisplayName(auth).toLowerCase() === "lqq"; - const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]); + const storageTaskId = useMemo( + () => `8bf:${auth?.user_id || "anonymous"}:${taskId || "anonymous"}`, + [auth?.user_id, taskId], + ); + const [task, setTask] = useState(() => ( + taskId ? normalizeEightBfTask({}, taskId) : null + )); + const [taskDictionaries, setTaskDictionaries] = useState({ + priority: [], + direction: [], + status: [], + }); + const [taskLoading, setTaskLoading] = useState(Boolean(taskId)); + const [taskError, setTaskError] = useState(null); const [mapping, setMapping] = useState(null); const [chains, setChains] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -41,6 +69,53 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa () => chains.find((chain) => chain.id === mapping?.chainId) ?? null, [chains, mapping?.chainId], ); + const taskContext = useMemo( + () => buildEightBfTaskContext(task, taskDictionaries), + [task, taskDictionaries], + ); + + useEffect(() => { + if (!taskId) { + setTask(null); + setTaskError(null); + setTaskLoading(false); + setTaskDictionaries({ priority: [], direction: [], status: [] }); + return; + } + + let cancelled = false; + setTaskLoading(true); + setTaskError(null); + setTask(normalizeEightBfTask({}, taskId)); + let pendingRequests = 2; + const markRequestFinished = () => { + pendingRequests -= 1; + if (!cancelled && pendingRequests === 0) setTaskLoading(false); + }; + // Keep detail and dictionary requests independent: a missing dictionary + // must not prevent the task card or task context from being available. + void fetchEightBfTask(taskId) + .then((nextTask) => { + if (!cancelled) setTask(nextTask); + }) + .catch((error) => { + if (cancelled) return; + setTaskError(error instanceof Error ? error.message : "任务详情加载失败"); + }) + .finally(markRequestFinished); + void fetchEightBfTaskDictionaries() + .then((nextDictionaries) => { + if (!cancelled) setTaskDictionaries(nextDictionaries); + }) + .catch(() => { + // fetchEightBfTaskDictionaries already degrades each dictionary to an + // empty list; this guard protects future adapter changes. + }) + .finally(markRequestFinished); + return () => { + cancelled = true; + }; + }, [taskId]); const loadConfiguration = useCallback(async () => { setIsLoading(true); @@ -162,6 +237,16 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa lockedChainId={mapping?.chainId ?? null} lockBusinessChain storageTaskId={storageTaskId} + taskContext={taskContext as Record | null} + entryRailTaskId={taskId || null} + entryRailTaskCardSlot={taskId ? ( + + ) : undefined} toolbarSlot={toolbarSlot} entryRailBusinessChainSlot={ svg:last-child { + margin-left: auto; + color: #0f766e; +} + +.eightbf-roundtable .eightbf-roundtable__task-card-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 7px; + background: #0f766e; + color: #fff; +} + +.eightbf-roundtable .eightbf-roundtable__task-card-body { + min-width: 0; + margin-top: 11px; +} + +.eightbf-roundtable .eightbf-roundtable__task-card-title { + margin: 0; + color: #0f172a; + font-size: 14px; + font-weight: 700; + line-height: 1.55; + overflow-wrap: anywhere; +} + +.eightbf-roundtable .eightbf-roundtable__task-card-tags { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 8px; +} + +.eightbf-roundtable .eightbf-roundtable__task-card-tags span { + border: 1px solid #c7e6df; + border-radius: 999px; + background: #ecfdf5; + color: #0f766e; + padding: 2px 7px; + font-size: 10px; + line-height: 1.4; +} + +.eightbf-roundtable .eightbf-roundtable__task-card-description { + display: -webkit-box; + margin: 9px 0 0; + color: #475569; + font-size: 11px; + line-height: 1.65; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 5; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #94a3b8; } +.eightbf-roundtable .eightbf-roundtable__task-card-error { margin: 8px 0 0; color: #b91c1c; font-size: 10px; line-height: 1.5; } + +.dark .eightbf-roundtable .eightbf-roundtable__task-card { + border-color: rgb(92 156 219 / 45%); + background: linear-gradient(145deg, #0b315d 0%, #08264b 100%); + box-shadow: 0 8px 24px rgb(0 0 0 / 18%); +} + +.dark .eightbf-roundtable .eightbf-roundtable__task-card-heading, +.dark .eightbf-roundtable .eightbf-roundtable__task-card-title { color: #f1f7ff; } +.dark .eightbf-roundtable .eightbf-roundtable__task-card-heading > svg:last-child { color: #65d6c6; } +.dark .eightbf-roundtable .eightbf-roundtable__task-card-tags span { border-color: rgb(101 214 198 / 40%); background: rgb(15 118 110 / 24%); color: #9ef0df; } +.dark .eightbf-roundtable .eightbf-roundtable__task-card-description { color: #c3d4e8; } +.dark .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #8fa8c5; } +.dark .eightbf-roundtable .eightbf-roundtable__task-card-error { color: #fca5a5; } + /* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */ diff --git a/frontend-web/src/lib/client-id.ts b/frontend-web/src/lib/client-id.ts new file mode 100644 index 00000000..77aff53b --- /dev/null +++ b/frontend-web/src/lib/client-id.ts @@ -0,0 +1,27 @@ +/** + * Generate a client-only id without assuming a secure browser context. + * + * `crypto.randomUUID()` is unavailable in several browsers when the app is + * opened directly through an `http://:` address. Request and + * UI ids need uniqueness, not a security token, so a timestamp/random fallback + * keeps those flows usable until the deployment is served over HTTPS. + */ +export function createClientId(prefix?: string): string { + const cryptoApi = typeof globalThis === "object" ? globalThis.crypto : undefined; + let id: string | undefined; + + if (typeof cryptoApi?.randomUUID === "function") { + id = cryptoApi.randomUUID(); + } else if (typeof cryptoApi?.getRandomValues === "function") { + try { + const values = new Uint32Array(4); + cryptoApi.getRandomValues(values); + id = Array.from(values, (value) => value.toString(16).padStart(8, "0")).join(""); + } catch { + // Some restricted browser contexts expose crypto but reject random data. + } + } + + id ??= `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; + return prefix ? `${prefix}-${id}` : id; +} diff --git a/frontend-web/src/pages/PluginManagementPage.tsx b/frontend-web/src/pages/PluginManagementPage.tsx index 0faf29b9..e1d9cb0b 100644 --- a/frontend-web/src/pages/PluginManagementPage.tsx +++ b/frontend-web/src/pages/PluginManagementPage.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { useAgentSelector } from "@/core/agents"; +import { createClientId } from "@/lib/client-id"; import { type DomainAgentMapping, normalizeDomainInput, @@ -20,10 +21,7 @@ import { function makeRow(): DomainAgentMapping { return { - id: - typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + id: createClientId(), domain: "", agentId: "", enabled: true, diff --git a/frontend-web/src/position-roundtable/api/sessions.ts b/frontend-web/src/position-roundtable/api/sessions.ts index 04af1c2f..1974d5ea 100644 --- a/frontend-web/src/position-roundtable/api/sessions.ts +++ b/frontend-web/src/position-roundtable/api/sessions.ts @@ -6,6 +6,7 @@ */ import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; +import { createClientId } from "@/lib/client-id"; /** * 节点状态由岗位会商服务推进;前端只据此决定可否发送、展示进度, @@ -147,10 +148,7 @@ const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`; * (不会重复 +revision / 重复解锁下游 / 重复驳回)。每次新的用户操作则取新键。 */ function newCommandId(): string { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID().replace(/-/g, ""); - } - return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`; + return createClientId().replace(/-/g, ""); } /** diff --git a/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts b/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts index 1ca4edbe..950bb363 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts @@ -24,6 +24,7 @@ import { type IntentSummary, } from "@/roundtable-planning/api/intent"; import { uploadFiles } from "@/core/uploads/api"; +import { createClientId } from "@/lib/client-id"; import { clarificationKey, formatIntentClarificationAnswer, @@ -449,9 +450,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe // Phase 4 单飞幂等键:每个回合一个稳定 id。后端据此对 (threadId, clientTurnId) // 跨 worker 单飞——重复 / 双击命中既有回合即幂等返回,杜绝并发下双发意图。 - const clientTurnId = - (typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID()) || - `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + const clientTurnId = createClientId(); const res = await streamIntent({ threadId: intentThreadId, diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 5259e5ce..8cfe08a6 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -298,6 +298,12 @@ export default function RoundtablePlanningPage({ toolbarSlot, /** 独立业务页在第一步左上角替换默认业务链选择区的内容。 */ entryRailBusinessChainSlot, + /** 独立业务页在第一步左上角提供自己的任务卡片;不触发原深链任务接口。 */ + entryRailTaskCardSlot, + /** 独立业务页传给意图智能体的隐藏任务上下文。 */ + taskContext, + /** 独立业务页的任务作用域,仅用于左栏记录筛选,不改变原深链逻辑。 */ + entryRailTaskId, /** 独立业务页的根节点样式类,避免修改原会商样式。 */ rootClassName, }: { @@ -307,6 +313,9 @@ export default function RoundtablePlanningPage({ storageTaskId?: string | null; toolbarSlot?: ReactNode; entryRailBusinessChainSlot?: ReactNode; + entryRailTaskCardSlot?: ReactNode; + taskContext?: Record | null; + entryRailTaskId?: string | null; rootClassName?: string; }) { const { theme } = useTheme(); @@ -315,6 +324,10 @@ export default function RoundtablePlanningPage({ const isDark = theme === "dark"; const navigate = useNavigate(); const location = useLocation(); + const taskContextRef = useRef | null>(taskContext ?? null); + useEffect(() => { + taskContextRef.current = taskContext ?? null; + }, [taskContext]); /** 无界(wujie)嵌入抽屉:隐藏业务链条入口,设置按钮改作手动回传交付。 */ const inWujieEmbed = isWujieEnv(); @@ -542,6 +555,38 @@ export default function RoundtablePlanningPage({ }>({ intentReady: null, interactiveMessages: [] }); const buildInitialIntent = useCallback((): string => { + const task = taskContextRef.current?.task as Record | null | undefined; + const taskTags = taskContextRef.current?.task_tags as + | Record + | undefined; + const taskValue = (value: unknown) => + value === undefined || value === null ? "" : String(value).trim(); + const taskLines = task + ? [ + ["任务 ID", taskValue(task.id ?? taskContextRef.current?.task_id)], + ["任务名称", taskValue(task.title)], + ["任务描述", taskValue(task.description)], + ["任务概览", taskValue(task.overview)], + ["任务方向", taskValue(task.direction ?? task.target_direction)], + ["优先级", taskValue(task.task_priority ?? task.priority)], + ["任务状态", taskValue(task.task_status)], + ["优先级标签", taskValue(taskTags?.priority)], + ["方向标签", taskValue(taskTags?.direction)], + ["状态标签", taskValue(taskTags?.status)], + ] + .filter(([, value]) => value) + .map(([label, value]) => `${label}: ${value}`) + : []; + const taskCardContext = taskLines.length + ? [ + "【8BF 任务卡片上下文】", + ...taskLines, + "请把以上任务卡片信息作为本轮研讨的既有背景,不要让用户重复提供。", + "", + ].join("\n") + : ""; + const withTaskCardContext = (value: string) => + taskCardContext ? `${taskCardContext}${value}` : value; const { intentReady, interactiveMessages } = step1StateRef.current; if (intentReady) { const parts: string[] = [intentReady.objective]; @@ -559,17 +604,17 @@ export default function RoundtablePlanningPage({ parts.push( "用户已经过任务理解智能体澄清,以上信息可以直接作为研讨依据。请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨,不要再反问用户。", ); - return parts.join("\n"); + return withTaskCardContext(parts.join("\n")); } const userTurns = interactiveMessages .filter((m) => m.sender === "user") .map((m) => m.content?.trim()) .filter((s): s is string => !!s); if (userTurns.length === 0) { - return "用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。"; + return withTaskCardContext("用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。"); } const conversation = userTurns.map((t, i) => `用户#${i + 1}: ${t}`).join("\n"); - return `${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`; + return withTaskCardContext(`${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`); }, []); const step2 = useStep2Orchestration({ @@ -593,6 +638,7 @@ export default function RoundtablePlanningPage({ currentStep, selectedModel, triggerToast, + taskContext, }); // 同步 step1StateRef,让 buildInitialIntent / Persona / Step 3 都能读到最新值。 @@ -2176,12 +2222,13 @@ export default function RoundtablePlanningPage({ onSelectChain={handleSelectChainFromSidebar} onOpenDraft={drafts.handleLoadDraft} activeDraftId={drafts.currentDraftId} - entryRailTaskId={recordTaskIdOf(prefillRef.current)} + entryRailTaskId={entryRailTaskId ?? recordTaskIdOf(prefillRef.current)} historyTaskId={draftStorageTaskId} entryRailBusinessCode={prefillRef.current?.businessCode ?? null} lockedChainId={lockedChainId} lockBusinessChain={lockBusinessChain} entryRailBusinessChainSlot={entryRailBusinessChainSlot} + entryRailTaskCardSlot={entryRailTaskCardSlot} /> {/* MIDDLE VIEWPORT */} @@ -2743,6 +2790,8 @@ interface LeftSidebarProps { lockedChainId?: string | null; /** 第一阶段替换默认业务链选择区的自定义内容。 */ entryRailBusinessChainSlot?: ReactNode; + /** 第一阶段由独立业务页提供的自定义任务卡片。 */ + entryRailTaskCardSlot?: ReactNode; } function Step2SandboxSidebarSync({ @@ -2794,6 +2843,7 @@ function LeftSidebar({ lockBusinessChain, lockedChainId, entryRailBusinessChainSlot, + entryRailTaskCardSlot, }: LeftSidebarProps) { // 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、 // 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。 @@ -2937,6 +2987,7 @@ function LeftSidebar({ lockBusinessChain={lockBusinessChain} lockedChainId={lockedChainId ?? null} businessChainSlot={entryRailBusinessChainSlot} + taskCardSlot={entryRailTaskCardSlot} /> )}
@@ -2992,6 +3043,7 @@ function RoundtableEntryRail({ lockedChainId, lockBusinessChain = false, businessChainSlot, + taskCardSlot, }: { selectedChainId: string | null; onSelectChain: (chain: BusinessChain) => void; @@ -3007,6 +3059,7 @@ function RoundtableEntryRail({ lockedChainId?: string | null; lockBusinessChain?: boolean; businessChainSlot?: ReactNode; + taskCardSlot?: ReactNode; }) { const [chains, setChains] = useState([]); const [chainSectionExpanded, setChainSectionExpanded] = useState(true); @@ -3093,7 +3146,12 @@ function RoundtableEntryRail({ )} > {/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */} - {deeplink ? ( + {taskCardSlot ? ( + <> + {taskCardSlot} + {lockBusinessChain && businessChainSlot} + + ) : deeplink ? ( isXdfx ? (
diff --git a/frontend-web/src/strategy-components/api/deep-research.ts b/frontend-web/src/strategy-components/api/deep-research.ts index 464c1090..8cb5ada8 100644 --- a/frontend-web/src/strategy-components/api/deep-research.ts +++ b/frontend-web/src/strategy-components/api/deep-research.ts @@ -17,6 +17,7 @@ import { import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; +import { createClientId } from "@/lib/client-id"; import type { DocumentRewriteSummary, DocumentRewriteStreamEvent, @@ -355,7 +356,7 @@ export async function startJob( method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - request_id: opts.requestId ?? crypto.randomUUID(), + request_id: opts.requestId ?? createClientId(), entry: opts.entry ?? "legacy", config: opts.config ?? null, }), diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 5e70c7f9..ff23ba93 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -1147,6 +1147,10 @@ Automatic conversation summarization when approaching token limits: the config flag. `config.summarization.enabled` now defaults to **false**. - `config.yaml` `summarization` still supplies the **tuning**: trigger types (tokens, messages, or fraction of max input) and the keep policy. +- A compressed request must retain the latest non-empty `HumanMessage`, even + when it has already been folded into the hidden summary. This keeps strict + OpenAI-compatible gateways from rejecting a tool-continuation call with + `No user query found in message`. - Keeps recent messages while summarizing older ones. Tests: `tests/test_summarization_toggle.py` (gating + whitelist), @@ -1284,6 +1288,31 @@ The per-user Deep Research concurrency guard counts only session statuses consume a slot; otherwise saved drafts make `POST /sessions/{id}/jobs` return 429 permanently. Regression: `test_saved_draft_sessions_do_not_consume_research_concurrency`. +`POST /api/deep-research/sessions/{id}/jobs` is retry-safe under MySQL +read/write splitting. Deep Research session/job/event/source/message writes +must serialise their changed row while the write transaction is still open +(`flush` for inserts, select-after-update **before** commit), never through a +post-commit `session.refresh()` or a fresh pooled read. Replica lag otherwise +turns a committed job into a misleading 500, can make dispatcher `claim_job()` +lose its snapshot, or can fail report/follow-up persistence after the model has +already completed. Always nudge the dispatcher for both a newly created job and +an idempotently reused queued job; otherwise a retry after such an interrupted +response returns the old job but leaves it waiting for the periodic scan. The +SSE replay generator must catch temporary event/job-store reads, emit a +recoverable warning, and keep the connection open so the next poll can repair +the cursor instead of surfacing an ASGI `ExceptionGroup`. Regressions: +`test_deep_research_writes_never_depend_on_post_commit_refresh`, +`test_job_create_never_reads_back_after_commit`, +`test_retry_of_existing_queued_job_nudges_dispatcher`, and +`test_stream_stays_open_when_durable_replay_has_a_transient_failure`. + +At ordinary report completion, persist and confirm the session's +`report_markdown` projection **before** marking the Job `completed`. Retry a +short transient projection failure; if it cannot be confirmed, let the job +become failed rather than reporting a report that vanishes on refresh. +`test_report_is_persisted_before_job_is_marked_completed` and +`test_report_projection_failure_never_marks_job_completed` cover the ordering. + The chat-collection frontend must keep report-writing progress in the existing workspace `MessageList` / `MessageGroup` tool-step renderer: inject only virtual `deep_research_progress` tool calls for the durable phases, keep the diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index 37707bdd..562eb251 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -151,7 +151,7 @@ FastAPI application providing REST endpoints for frontend integration: | `GET /api/threads/{id}/uploads/list` | List uploaded files | | `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail | | `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts | -| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. | +| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Deep Research session/job/event/source/message writes serialise their rows in the writer transaction, rather than doing a post-commit ORM refresh or readback; this prevents MySQL read/write-splitting replica lag from failing “start writing”, dispatcher claim, report persistence, or follow-up history. A report is written to the session and confirmed before its Job becomes `completed`; an unconfirmed write is retried and never exposed as a false completion. Retrying an already-created queued job re-nudges the dispatcher, recovering an interrupted HTTP response without creating a second report. If progress-event persistence, durable replay, or live fan-out is briefly unavailable, the worker/stream logs the degradation and continues or reconnects rather than failing the whole job. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. | | `POST /api/ai-writing/sample/extract` | Extract text from Word/PDF/Markdown/TXT samples for AI-writing imitation mode | | `POST /cop/saveSuperiorTask` | TaskCOP compatibility API (DeerFlow Bearer/session authentication required): create a situation-overview task with a sequential four-digit id (`0001`, `0002`, ...) and return the legacy `{state, msg, data}` envelope | | `POST /api/taskcop/import/tasks` | TaskCOP compatibility API (authenticated): import tasks from a JSON array or `tasks`/`records` envelope; every row's supplied `id`/`taskId` is authoritative and a duplicate id completely replaces the stored task | diff --git a/offline-backend-20260512/backend/app/gateway/deep_research_job_executor.py b/offline-backend-20260512/backend/app/gateway/deep_research_job_executor.py index a9df4122..f93d14cd 100644 --- a/offline-backend-20260512/backend/app/gateway/deep_research_job_executor.py +++ b/offline-backend-20260512/backend/app/gateway/deep_research_job_executor.py @@ -61,6 +61,8 @@ WORKER_ID = f"w-{os.getpid()}-{_uuid.uuid4().hex[:8]}" _LEASE_TTL_SECONDS = 60 _HEARTBEAT_INTERVAL = 20.0 _CANCEL_POLL_INTERVAL = 5.0 +_REPORT_PROJECTION_RETRY_ATTEMPTS = 3 +_REPORT_PROJECTION_RETRY_DELAY_SECONDS = 0.5 # Phase → progress% mapping for the job timeline. _PHASE_PROGRESS: dict[str, int] = { @@ -1372,7 +1374,17 @@ class DeepResearchJobExecutor: async def _mark_completed( self, job_id: str, session_id: str, lease_owner: str | None, result: DeepResearchResult ) -> None: - # Job row → completed. + # Persist the report projection before exposing a terminal completed + # job. The session row is the durable report itself; marking the job + # complete first and suppressing a session-write failure can otherwise + # make a successfully generated report disappear after refresh. + await self._persist_completed_report_projection( + job_id=job_id, + session_id=session_id, + result=result, + ) + + # Job row → completed only after the report is durable. await self._job_store.update_progress( job_id, lease_owner=lease_owner, @@ -1385,26 +1397,62 @@ class DeepResearchJobExecutor: "plan": result.plan, }, ) - # Session row → completed + report. - source_count = len(result.source_ids) - with suppress(Exception): # noqa: BLE001 - await self._session_store.update( - session_id, - status="completed", - report_markdown=result.report_markdown, - report_html=result.report_html, - plan_snapshot=result.plan, - # Keep the terminal job id alongside model usage. The - # frontend uses it only to replay the persisted research trace - # after a refresh; it does not affect billing or job state. - usage_snapshot={**(result.usage or {}), "lastJobId": job_id}, - source_count=source_count, - active_job_id=None, - ) # Emit a terminal event for SSE consumers. with suppress(Exception): # noqa: BLE001 await self._emit_terminal(job_id, session_id, "job_completed") + async def _persist_completed_report_projection( + self, + *, + job_id: str, + session_id: str, + result: DeepResearchResult, + ) -> None: + """Durably save the report before publishing job completion. + + A write can succeed while a short-lived MySQL proxy/readback hiccup + makes its caller fail. Retry the full owner-scoped update a few times; + if it still cannot be confirmed, propagate the error so ``_run`` marks + the job failed instead of advertising a report that cannot be reopened. + """ + last_error: Exception | None = None + for attempt in range(1, _REPORT_PROJECTION_RETRY_ATTEMPTS + 1): + try: + updated = await self._session_store.update( + session_id, + status="completed", + report_markdown=result.report_markdown, + report_html=result.report_html, + plan_snapshot=result.plan, + # Keep the terminal job id alongside model usage. The + # frontend uses it only to replay the persisted research + # trace after a refresh; it does not affect billing. + usage_snapshot={**(result.usage or {}), "lastJobId": job_id}, + source_count=len(result.source_ids), + active_job_id=None, + ) + if updated is not None: + return + last_error = RuntimeError(f"Deep Research session {session_id} no longer exists") + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + last_error = exc + + if attempt < _REPORT_PROJECTION_RETRY_ATTEMPTS: + logger.warning( + "deep-research report projection failed for %s (attempt %s/%s); retrying", + job_id, + attempt, + _REPORT_PROJECTION_RETRY_ATTEMPTS, + exc_info=True, + ) + await asyncio.sleep(_REPORT_PROJECTION_RETRY_DELAY_SECONDS * attempt) + + raise RuntimeError( + f"研究报告未能保存到会话 {session_id},任务不会标记为完成" + ) from last_error + async def _mark_awaiting( self, job_id: str, session_id: str, pause: ResearchAwaitingInput ) -> None: diff --git a/offline-backend-20260512/backend/app/gateway/routers/deep_research.py b/offline-backend-20260512/backend/app/gateway/routers/deep_research.py index ab52ff82..0867f3d4 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/deep_research.py +++ b/offline-backend-20260512/backend/app/gateway/routers/deep_research.py @@ -639,17 +639,30 @@ async def start_job(session_id: str, body: StartJobRequest, request: Request) -> input_snapshot=input_snapshot, ) - # Update session status. - await session_store.update( - session_id, - user_id=user_id, - status="running", - active_job_id=job["id"], - ) + # The durable job is the source of truth once it has been committed. A + # transient failure updating the display projection must not turn that + # successful write into a misleading 500 response or leave it undispatched. + # The executor re-projects the terminal status when it completes. + try: + await session_store.update( + session_id, + user_id=user_id, + status="running", + active_job_id=job["id"], + ) + except Exception: # noqa: BLE001 + logger.exception( + "deep-research job %s was queued but its session running projection could not be updated", + job["id"], + ) - # Nudge the dispatcher to claim immediately. + # Nudge the dispatcher even for an idempotent retry. If an earlier request + # committed the job but failed before this point (for example a transient + # MySQL read-after-write failure), the retry receives the existing queued + # job. Restricting the nudge to ``created`` would leave that durable job + # queued until the next periodic scan and make the UI appear stuck. dispatcher = _get_dispatcher(request) - if dispatcher is not None and created: + if dispatcher is not None: dispatcher.nudge() return { @@ -810,36 +823,68 @@ async def stream_job( if loop.time() >= next_durable_poll: # Durable replay repairs a missed live delta after # reconnect or when another worker owned the job. - events = await event_store.list_after(job_id, after=cursor, limit=200) - for ev in events: - payload = { - "seq": ev["seq"], - "sessionId": session_id, - "jobId": job_id, - "type": ev["event_type"], - "phase": ev.get("phase", "initializing"), - "timestamp": _iso(ev.get("created_at")), - "payload": ev.get("payload") or {}, - } - yield format_sse("progress", payload, event_id=str(ev["seq"])) - cursor = max(cursor, ev["seq"]) - current = await job_store.get_unscoped(job_id) - if current and current.get("status") in TERMINAL_STATUSES: + try: + events = await event_store.list_after(job_id, after=cursor, limit=200) + for ev in events: + payload = { + "seq": ev["seq"], + "sessionId": session_id, + "jobId": job_id, + "type": ev["event_type"], + "phase": ev.get("phase", "initializing"), + "timestamp": _iso(ev.get("created_at")), + "payload": ev.get("payload") or {}, + } + yield format_sse("progress", payload, event_id=str(ev["seq"])) + cursor = max(cursor, ev["seq"]) + current = await job_store.get_unscoped(job_id) + if current and current.get("status") in TERMINAL_STATUSES: + yield format_sse( + "progress", + { + "seq": cursor + 1, + "sessionId": session_id, + "jobId": job_id, + "type": "job_status", + "phase": current.get("phase", "done"), + "timestamp": _iso(datetime.now(UTC)), + "payload": {"status": current["status"], "progress": current.get("progress", 0)}, + }, + event_id=str(cursor + 1), + ) + return + next_durable_poll = loop.time() + _POLL_INTERVAL_S + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 + # Do not let one read-replica / transient DB failure + # abort the ASGI generator (which surfaces as an + # ExceptionGroup and leaves the page stuck). Live + # frames can still reach an attached browser, and the + # next durable poll repairs the cursor on recovery. + logger.warning( + "deep-research SSE durable replay temporarily failed for job %s; keeping stream open", + job_id, + exc_info=True, + ) yield format_sse( "progress", { - "seq": cursor + 1, + "seq": 0, + "liveId": f"durable-replay-retry:{job_id}:{int(loop.time() * 1000)}", "sessionId": session_id, "jobId": job_id, - "type": "job_status", - "phase": current.get("phase", "done"), + "type": "warning", + "phase": "initializing", "timestamp": _iso(datetime.now(UTC)), - "payload": {"status": current["status"], "progress": current.get("progress", 0)}, + "payload": { + "code": "durable_replay_temporarily_unavailable", + "message": "进度同步暂时不可用,任务仍在继续;正在自动重试。", + "recoverable": True, + }, }, - event_id=str(cursor + 1), ) - return - next_durable_poll = loop.time() + _POLL_INTERVAL_S + next_durable_poll = loop.time() + max(_POLL_INTERVAL_S, 3.0) if live_queue is None: await asyncio.sleep(max(0.01, next_durable_poll - loop.time())) continue diff --git a/offline-backend-20260512/backend/app/gateway/routers/intent.py b/offline-backend-20260512/backend/app/gateway/routers/intent.py index 1f3303ca..4ea44fdc 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/intent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/intent.py @@ -195,17 +195,22 @@ def _intent_thread_metadata( if not task_id: return metadata - metadata.update( - { - "taskId": task_id, - "position_roundtable_task_id": task_id, - "position_task_id": f"{task_id}:intelligence", - "position_id": "intelligence", - "position_roundtable_phase": "intent", - } - ) - if intent_agent_id == POSITION_INTENT_AGENT_ID: - metadata["position_roundtable_intent_agent_id"] = intent_agent_id + metadata["taskId"] = task_id + # 8BF 使用通用意图接口但有明确的上下文 kind;它不能被岗位会商的 + # 会话索引误认为岗位任务,否则两个页面的历史记录会串在一起。 + # 其它历史调用保持原有 metadata 形状,避免影响既有会商恢复逻辑。 + is_eightbf_context = task_context.get("kind") == "eightbf_roundtable_task" + if intent_agent_id == POSITION_INTENT_AGENT_ID or not is_eightbf_context: + metadata.update( + { + "position_roundtable_task_id": task_id, + "position_task_id": f"{task_id}:intelligence", + "position_id": "intelligence", + "position_roundtable_phase": "intent", + } + ) + if intent_agent_id == POSITION_INTENT_AGENT_ID: + metadata["position_roundtable_intent_agent_id"] = intent_agent_id return metadata diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/deep_research/adapters/event_sink.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/deep_research/adapters/event_sink.py index 22a4fa22..75bb022b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/deep_research/adapters/event_sink.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/deep_research/adapters/event_sink.py @@ -48,6 +48,11 @@ class PersistingEventSink: self._session_id = session_id self._job_id = job_id self._on_event = on_event + # A durable event row normally owns the sequence. When the event + # database is temporarily unavailable, use distinct negative sequence + # numbers for the live-only path so clients can render every milestone + # without advancing their durable ``after`` cursor. + self._fallback_seq = 0 async def emit( self, @@ -56,26 +61,66 @@ class PersistingEventSink: phase: ResearchPhase = "initializing", payload: dict | None = None, ) -> DeepResearchEvent: - row = await self._repo.append( - session_id=self._session_id, - job_id=self._job_id, - event_type=type, - phase=phase, - payload=payload or {}, - ) - # Reconstruct the envelope from the persisted row (seq is now assigned). - event = DeepResearchEvent( - seq=int(row.get("seq", 0)), - session_id=self._session_id, - job_id=self._job_id, - type=row.get("event_type", type), - phase=row.get("phase", phase), - payload=row.get("payload") or payload or {}, - ) - if self._on_event is not None: - await self._on_event(event.to_sse_dict()) + event_payload = payload or {} + try: + row = await self._repo.append( + session_id=self._session_id, + job_id=self._job_id, + event_type=type, + phase=phase, + payload=event_payload, + ) + # Reconstruct the envelope from the persisted row (seq is now assigned). + event = DeepResearchEvent( + seq=int(row.get("seq", 0)), + session_id=self._session_id, + job_id=self._job_id, + type=row.get("event_type", type), + phase=row.get("phase", phase), + payload=row.get("payload") or event_payload, + ) + except Exception: # noqa: BLE001 + # Progress persistence must not turn a recoverable database blip + # into a failed report. The job/status stores still record terminal + # state; this live-only frame lets an attached UI stay responsive + # until durable replay becomes available again after reconnect. + self._fallback_seq -= 1 + logger.exception( + "deep-research event persistence failed; continuing job with a live-only event (job=%s, type=%s, phase=%s)", + self._job_id, + type, + phase, + ) + event = DeepResearchEvent( + seq=self._fallback_seq, + session_id=self._session_id, + job_id=self._job_id, + type=type, + phase=phase, + payload={**event_payload, "eventPersistenceDegraded": True}, + ) + await self._publish_event(event) return event + async def _publish_event(self, event: DeepResearchEvent) -> None: + """Push a persisted or live-only event without risking the worker.""" + if self._on_event is None: + return + wire = event.to_sse_dict() + if event.seq < 0: + wire["liveId"] = f"event-persistence-fallback:{self._job_id}:{abs(event.seq)}" + try: + await self._on_event(wire) + except Exception: # noqa: BLE001 + # A browser disconnect or a live-hub issue must not abort report + # generation; persisted rows will still be replayable on reconnect. + logger.warning( + "deep-research live event publish failed; job continues (job=%s, seq=%s)", + self._job_id, + event.seq, + exc_info=True, + ) + async def publish_live( self, type: str, # noqa: A002 @@ -85,16 +130,24 @@ class PersistingEventSink: ) -> None: if self._on_event is None: return - await self._on_event( - { - "seq": 0, - "sessionId": self._session_id, - "jobId": self._job_id, - "type": type, - "phase": phase, - "payload": payload or {}, - } - ) + try: + await self._on_event( + { + "seq": 0, + "sessionId": self._session_id, + "jobId": self._job_id, + "type": type, + "phase": phase, + "payload": payload or {}, + } + ) + except Exception: # noqa: BLE001 + logger.warning( + "deep-research live frame publish failed; job continues (job=%s, type=%s)", + self._job_id, + type, + exc_info=True, + ) class NullEventSink: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py index 3292f89f..d0279a17 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py @@ -27,14 +27,15 @@ from typing import Any, Protocol, override, runtime_checkable from langchain.agents import AgentState from langchain.agents.middleware import SummarizationMiddleware from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse -from langchain_core.messages import ( - AIMessage, - AnyMessage, - SystemMessage, - ToolMessage, - message_to_dict, - messages_from_dict, -) +from langchain_core.messages import ( + AIMessage, + AnyMessage, + HumanMessage, + SystemMessage, + ToolMessage, + message_to_dict, + messages_from_dict, +) from langgraph.config import get_config from langgraph.runtime import Runtime @@ -145,8 +146,8 @@ class _SummaryCacheEntry: summarized_ids: frozenset[str] summary_text: str - preserved_ids: frozenset[str] = frozenset() - preserved_messages: tuple[AnyMessage, ...] = () + preserved_ids: frozenset[str] = frozenset() + preserved_messages: tuple[AnyMessage, ...] = () # Process-wide summary cache shared across every middleware instance. @@ -160,51 +161,99 @@ class _SummaryCacheEntry: # so only the first threshold crossing (or a genuine re-compaction) pays for an # LLM call. A lock guards the non-atomic LRU eviction against concurrent # threads (subagents / parallel conversations share this process). -_GLOBAL_SUMMARY_CACHE: OrderedDict[str, _SummaryCacheEntry] = OrderedDict() -_GLOBAL_SUMMARY_CACHE_LOCK = threading.Lock() -_STATE_CACHE_KEY = "context_summary_cache" - - -def _clear_global_summary_cache() -> None: - """Drop every cached summary. Intended for tests/diagnostics only.""" - with _GLOBAL_SUMMARY_CACHE_LOCK: - _GLOBAL_SUMMARY_CACHE.clear() - - -def _serialize_cache_entry(entry: _SummaryCacheEntry) -> dict[str, Any]: - """Serialize a summary cache entry so it can live in LangGraph state.""" - return { - "version": 1, - "summarized_ids": sorted(entry.summarized_ids), - "summary_text": entry.summary_text, - "preserved_ids": sorted(entry.preserved_ids), - "preserved_messages": [message_to_dict(message) for message in entry.preserved_messages], - } - - -def _deserialize_cache_entry(raw: Any) -> _SummaryCacheEntry | None: - """Best-effort restore of a state-backed summary cache entry.""" - if not isinstance(raw, dict): - return None - try: - summary_text = raw.get("summary_text") - if not isinstance(summary_text, str) or not summary_text: - return None - summarized_ids = raw.get("summarized_ids") or [] - preserved_ids = raw.get("preserved_ids") or [] - preserved_messages_raw = raw.get("preserved_messages") or [] - if not isinstance(summarized_ids, list) or not isinstance(preserved_ids, list) or not isinstance(preserved_messages_raw, list): - return None - preserved_messages = tuple(messages_from_dict(preserved_messages_raw)) - return _SummaryCacheEntry( - summarized_ids=frozenset(str(item) for item in summarized_ids if item is not None), - summary_text=summary_text, - preserved_ids=frozenset(str(item) for item in preserved_ids if item is not None), - preserved_messages=preserved_messages, - ) - except Exception: - logger.debug("summarization: failed to restore state-backed summary cache", exc_info=True) - return None +_GLOBAL_SUMMARY_CACHE: OrderedDict[str, _SummaryCacheEntry] = OrderedDict() +_GLOBAL_SUMMARY_CACHE_LOCK = threading.Lock() +_STATE_CACHE_KEY = "context_summary_cache" + + +def _clear_global_summary_cache() -> None: + """Drop every cached summary. Intended for tests/diagnostics only.""" + with _GLOBAL_SUMMARY_CACHE_LOCK: + _GLOBAL_SUMMARY_CACHE.clear() + + +def _serialize_cache_entry(entry: _SummaryCacheEntry) -> dict[str, Any]: + """Serialize a summary cache entry so it can live in LangGraph state.""" + return { + "version": 1, + "summarized_ids": sorted(entry.summarized_ids), + "summary_text": entry.summary_text, + "preserved_ids": sorted(entry.preserved_ids), + "preserved_messages": [message_to_dict(message) for message in entry.preserved_messages], + } + + +def _deserialize_cache_entry(raw: Any) -> _SummaryCacheEntry | None: + """Best-effort restore of a state-backed summary cache entry.""" + if not isinstance(raw, dict): + return None + try: + summary_text = raw.get("summary_text") + if not isinstance(summary_text, str) or not summary_text: + return None + summarized_ids = raw.get("summarized_ids") or [] + preserved_ids = raw.get("preserved_ids") or [] + preserved_messages_raw = raw.get("preserved_messages") or [] + if not isinstance(summarized_ids, list) or not isinstance(preserved_ids, list) or not isinstance(preserved_messages_raw, list): + return None + preserved_messages = tuple(messages_from_dict(preserved_messages_raw)) + return _SummaryCacheEntry( + summarized_ids=frozenset(str(item) for item in summarized_ids if item is not None), + summary_text=summary_text, + preserved_ids=frozenset(str(item) for item in preserved_ids if item is not None), + preserved_messages=preserved_messages, + ) + except Exception: + logger.debug("summarization: failed to restore state-backed summary cache", exc_info=True) + return None + + +def _has_nonempty_human_message(message: AnyMessage) -> bool: + """Whether a message is a real, non-empty user turn. + + Some OpenAI-compatible gateways reject a completion request that contains + only ``system`` / ``assistant`` / ``tool`` roles. That can legitimately + happen after compaction: the active model call is continuing a tool turn, + while the user request that started the turn has already moved into the + internal summary. + """ + if not isinstance(message, HumanMessage): + return False + content = message.content + if isinstance(content, str): + return bool(content.strip()) + if isinstance(content, list): + return any(bool(block.strip()) if isinstance(block, str) else bool((block.get("text") or block.get("content") or "").strip()) if isinstance(block, dict) else False for block in content) + return bool(str(content).strip()) + + +def _ensure_latest_human_message( + source_messages: list[AnyMessage], + compressed_messages: list[AnyMessage], +) -> list[AnyMessage]: + """Keep the latest real user request visible to strict chat gateways. + + The message is inserted after leading system messages, so it neither + exposes the internal summary to the UI nor splits an AI tool-call from its + following ToolMessages. When there was no user turn in the source at all, + do not fabricate one: that is an upstream caller bug and should remain + diagnosable rather than silently changing the task. + """ + if any(_has_nonempty_human_message(message) for message in compressed_messages): + return compressed_messages + + latest_human = next( + (message for message in reversed(source_messages) if _has_nonempty_human_message(message)), + None, + ) + if latest_human is None: + logger.warning("summarization: compressed model request has no user message and source history contains none; passing it through unchanged") + return compressed_messages + + insert_at = 0 + while insert_at < len(compressed_messages) and isinstance(compressed_messages[insert_at], SystemMessage): + insert_at += 1 + return [*compressed_messages[:insert_at], latest_human, *compressed_messages[insert_at:]] class DeerFlowSummarizationMiddleware(SummarizationMiddleware): @@ -231,8 +280,8 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): # Per-thread cache of the most recent summary: thread_id -> (summarized message ids, summary text). # Points at the *process-wide* cache (not a fresh per-instance dict) so a # summary survives across runs/turns — see ``_GLOBAL_SUMMARY_CACHE``. - self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = _GLOBAL_SUMMARY_CACHE - self._pending_state_cache_update: dict[str, Any] | None = None + self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = _GLOBAL_SUMMARY_CACHE + self._pending_state_cache_update: dict[str, Any] | None = None @override def before_model(self, state: AgentState, runtime: Runtime) -> dict | None: @@ -248,21 +297,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): """Async counterpart of :meth:`before_model` — also a no-op.""" return None - @override - def after_model(self, state: AgentState, runtime: Runtime) -> dict | None: - """Persist the latest reusable summary cache into thread state.""" - if self._pending_state_cache_update is None: - return None - update = self._pending_state_cache_update - self._pending_state_cache_update = None - return {_STATE_CACHE_KEY: update} - - @override - async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None: - """Async counterpart of :meth:`after_model`.""" - return self.after_model(state, runtime) - - # ---- wrap_model_call: transient, display-preserving compression ---------- + @override + def after_model(self, state: AgentState, runtime: Runtime) -> dict | None: + """Persist the latest reusable summary cache into thread state.""" + if self._pending_state_cache_update is None: + return None + update = self._pending_state_cache_update + self._pending_state_cache_update = None + return {_STATE_CACHE_KEY: update} + + @override + async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None: + """Async counterpart of :meth:`after_model`.""" + return self.after_model(state, runtime) + + # ---- wrap_model_call: transient, display-preserving compression ---------- @override def wrap_model_call( @@ -376,20 +425,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): return None thread_id = self._resolve_request_thread_id(request) - if thread_id is None: - logger.debug("summarization: thread_id unresolved; cross-turn summary cache disabled for this call") - cached = self._get_cache_entry(thread_id) if thread_id else None - if cached is None: - cached = _deserialize_cache_entry(getattr(request, "state", {}).get(_STATE_CACHE_KEY)) - if cached is not None and thread_id: - self._store_cache_entry(thread_id, cached) - present_ids = {m.id for m in messages} - cache_usable = cached is not None and cached.summarized_ids <= present_ids and cached.preserved_ids <= present_ids - if cache_usable: - preserved = list(cached.preserved_messages) - excluded_ids = cached.summarized_ids | cached.preserved_ids - preserved.extend(m for m in messages if m.id not in excluded_ids) - effective = [self._summary_message(cached.summary_text), *preserved] + if thread_id is None: + logger.debug("summarization: thread_id unresolved; cross-turn summary cache disabled for this call") + cached = self._get_cache_entry(thread_id) if thread_id else None + if cached is None: + cached = _deserialize_cache_entry(getattr(request, "state", {}).get(_STATE_CACHE_KEY)) + if cached is not None and thread_id: + self._store_cache_entry(thread_id, cached) + present_ids = {m.id for m in messages} + cache_usable = cached is not None and cached.summarized_ids <= present_ids and cached.preserved_ids <= present_ids + if cache_usable: + preserved = list(cached.preserved_messages) + excluded_ids = cached.summarized_ids | cached.preserved_ids + preserved.extend(m for m in messages if m.id not in excluded_ids) + effective = [self._summary_message(cached.summary_text), *preserved] + effective = _ensure_latest_human_message(messages, effective) # Decide re-summarization on the *counted* size of the compressed # context only — NOT ``_should_summarize`` — because that folds in the # last AIMessage's reported ``usage_metadata`` (system prompt + tool @@ -400,8 +450,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): # Refresh LRU recency and reuse without an LLM call. self._touch_cache_entry(thread_id) logger.info( - "summarization: 上下文压缩(复用缓存摘要,无 LLM 调用) context compaction reusing cached summary " - "thread=%s full_tokens=%d compressed_tokens=%d preserved_msgs=%d", + "summarization: 上下文压缩(复用缓存摘要,无 LLM 调用) context compaction reusing cached summary thread=%s full_tokens=%d compressed_tokens=%d preserved_msgs=%d", thread_id, self.token_counter(messages), self.token_counter(effective), @@ -415,7 +464,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): # an "it compacts every turn" report is diagnosable straight from the # backend log: cache=miss → thread_id/key problem; unusable → message-id # instability; stale → preserved genuinely grew back over the trigger. - cache_state = "miss" if cached is None else "stale" + cache_state = "miss" if cached is None else "stale" logger.info( "summarization: compacting thread=%s cache=%s counted_tokens=%d msgs=%d", thread_id, @@ -452,22 +501,23 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): ) -> list[AnyMessage]: """Assemble the compressed message list and update the per-thread cache.""" compressed = [self._summary_message(summary), *preserved] + compressed = _ensure_latest_human_message([*messages_to_summarize, *preserved], compressed) if thread_id: summarized_ids = frozenset(m.id for m in messages_to_summarize if m.id is not None) preserved_ids = {m.id for m in preserved if m.id is not None} - self._store_cache_entry( - thread_id, - entry := _SummaryCacheEntry( - summarized_ids=summarized_ids, - summary_text=summary, - preserved_ids=frozenset(preserved_ids), - preserved_messages=tuple(preserved), - ), - ) - self._pending_state_cache_update = _serialize_cache_entry(entry) - - return compressed + self._store_cache_entry( + thread_id, + entry := _SummaryCacheEntry( + summarized_ids=summarized_ids, + summary_text=summary, + preserved_ids=frozenset(preserved_ids), + preserved_messages=tuple(preserved), + ), + ) + self._pending_state_cache_update = _serialize_cache_entry(entry) + + return compressed def _get_cache_entry(self, thread_id: str) -> _SummaryCacheEntry | None: """Return the cached entry for ``thread_id`` (thread-safe read).""" @@ -571,7 +621,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): return None return _resolve_thread_id(runtime) - def _summary_message(self, summary: str) -> SystemMessage: + def _summary_message(self, summary: str) -> SystemMessage: """Build the summary message handed to the model. It is named ``summary`` and flagged ``hide_from_ui`` defensively. Because @@ -579,21 +629,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): state), the frontend never receives it — the user keeps seeing the original conversation. """ - return SystemMessage( - content=( - "Internal conversation summary for continuity only. Use it silently " - "to answer the user's latest request. Never reveal, quote, mention, " - "or describe this summary or the fact that context was compacted.\n\n" - "\n" - f"{summary}\n" - "" - ), + return SystemMessage( + content=( + "Internal conversation summary for continuity only. Use it silently " + "to answer the user's latest request. Never reveal, quote, mention, " + "or describe this summary or the fact that context was compacted.\n\n" + "\n" + f"{summary}\n" + "" + ), name="summary", additional_kwargs={"hide_from_ui": True, "lc_source": "summarization"}, ) @override - def _build_new_messages(self, summary: str) -> list[AnyMessage]: + def _build_new_messages(self, summary: str) -> list[AnyMessage]: """Kept for backwards compatibility; delegates to :meth:`_summary_message`.""" return [self._summary_message(summary)] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_events/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_events/sql.py index c8be2d52..d090cf70 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_events/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_events/sql.py @@ -68,13 +68,7 @@ class DeepResearchEventRepository: """ for attempt in range(max_retries): async with self._sf() as session: - current_max = ( - await session.execute( - select(func.max(DeepResearchEventRow.seq)).where( - DeepResearchEventRow.job_id == job_id - ) - ) - ).scalar() or 0 + current_max = (await session.execute(select(func.max(DeepResearchEventRow.seq)).where(DeepResearchEventRow.job_id == job_id))).scalar() or 0 next_seq = int(current_max) + 1 row = DeepResearchEventRow( job_id=job_id, @@ -86,49 +80,38 @@ class DeepResearchEventRepository: ) session.add(row) try: + # Do not issue ``session.refresh(row)`` after commit. On a + # MySQL read/write-splitting deployment that read can land + # on a lagging replica, which cannot see the just-committed + # event and raises ``Could not refresh instance``. Flush + # assigns generated primary-key/default values on the writer + # connection, so the plain dict is complete before commit. + await session.flush() + result = self._row_to_dict(row) await session.commit() - await session.refresh(row) - return self._row_to_dict(row) + return result except IntegrityError: await session.rollback() continue # retry with recomputed max - raise RuntimeError( - f"deep_research_events.append: seq collision persisted after {max_retries} retries (job={job_id})" - ) + raise RuntimeError(f"deep_research_events.append: seq collision persisted after {max_retries} retries (job={job_id})") - async def list_after( - self, job_id: str, *, after: int = 0, limit: int = 200 - ) -> list[dict[str, Any]]: + async def list_after(self, job_id: str, *, after: int = 0, limit: int = 200) -> list[dict[str, Any]]: """Events with ``seq > after`` in ascending order (SSE replay, §15.3).""" async with self._sf() as session: - stmt = ( - select(DeepResearchEventRow) - .where(DeepResearchEventRow.job_id == job_id) - .where(DeepResearchEventRow.seq > after) - .order_by(DeepResearchEventRow.seq.asc()) - .limit(limit) - ) + stmt = select(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id).where(DeepResearchEventRow.seq > after).order_by(DeepResearchEventRow.seq.asc()).limit(limit) rows = (await session.execute(stmt)).scalars().all() return [self._row_to_dict(r) for r in rows] async def last_seq(self, job_id: str) -> int: async with self._sf() as session: - mx = ( - await session.execute( - select(func.max(DeepResearchEventRow.seq)).where( - DeepResearchEventRow.job_id == job_id - ) - ) - ).scalar() + mx = (await session.execute(select(func.max(DeepResearchEventRow.seq)).where(DeepResearchEventRow.job_id == job_id))).scalar() return int(mx or 0) async def delete_by_job(self, job_id: str) -> int: from sqlalchemy import delete async with self._sf() as session: - result = await session.execute( - delete(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id) - ) + result = await session.execute(delete(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id)) await session.commit() return result.rowcount @@ -136,8 +119,6 @@ class DeepResearchEventRepository: from sqlalchemy import delete async with self._sf() as session: - result = await session.execute( - delete(DeepResearchEventRow).where(DeepResearchEventRow.session_id == session_id) - ) + result = await session.execute(delete(DeepResearchEventRow).where(DeepResearchEventRow.session_id == session_id)) await session.commit() return result.rowcount diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_jobs/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_jobs/sql.py index adaf323d..083e188f 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_jobs/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_jobs/sql.py @@ -113,9 +113,18 @@ class DeepResearchJobRepository: phase=phase, ) session.add(row) + # Do not issue ``session.refresh(row)`` after commit. A + # MySQL read/write-splitting proxy can route that read to + # a lagging replica, even though the INSERT has already + # committed on the writer. The caller would receive 500 + # while an invisible queued job remains in the database. + # ``flush`` materializes ORM defaults on the writer, so we + # can return a complete, durable snapshot without a + # replica-dependent readback. + await session.flush() + result = self._row_to_dict(row) await session.commit() - await session.refresh(row) - return self._row_to_dict(row), True + return result, True except IntegrityError: # Unique-index conflict → an active job already exists. pass @@ -230,13 +239,14 @@ class DeepResearchJobRepository: ) if result.rowcount == 0: return None - await session.commit() row = ( await session.execute( select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id) ) ).scalar_one_or_none() - return self._row_to_dict(row) if row else None + snapshot = self._row_to_dict(row) if row else None + await session.commit() + return snapshot # ── heartbeat ─────────────────────────────────────────────────────────── @@ -351,13 +361,14 @@ class DeepResearchJobRepository: ) if result.rowcount == 0: return None - await session.commit() row = ( await session.execute( select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id) ) ).scalar_one_or_none() - return self._row_to_dict(row) if row else None + snapshot = self._row_to_dict(row) if row else None + await session.commit() + return snapshot # ── two-phase cancel ──────────────────────────────────────────────────── @@ -385,13 +396,14 @@ class DeepResearchJobRepository: ) if result.rowcount == 0: return None - await session.commit() row = ( await session.execute( select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id) ) ).scalar_one_or_none() - return self._row_to_dict(row) if row else None + snapshot = self._row_to_dict(row) if row else None + await session.commit() + return snapshot async def finalize_cancel( self, job_id: str, *, lease_owner: str | None = None @@ -419,13 +431,14 @@ class DeepResearchJobRepository: ) if result.rowcount == 0: return None - await session.commit() row = ( await session.execute( select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id) ) ).scalar_one_or_none() - return self._row_to_dict(row) if row else None + snapshot = self._row_to_dict(row) if row else None + await session.commit() + return snapshot # ── resume after human-review interrupt ───────────────────────────────── @@ -460,13 +473,14 @@ class DeepResearchJobRepository: ) if result.rowcount == 0: return None - await session.commit() row = ( await session.execute( select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id) ) ).scalar_one_or_none() - return self._row_to_dict(row) if row else None + snapshot = self._row_to_dict(row) if row else None + await session.commit() + return snapshot # ── cleanup ───────────────────────────────────────────────────────────── diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_messages/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_messages/sql.py index d0deab87..5935781d 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_messages/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_messages/sql.py @@ -74,9 +74,13 @@ class DeepResearchMessageRepository: seq=int(current_max) + 1, ) session.add(row) + # Keep the successful append independent of a later replica read. + # The explicit id/seq and Python-side defaults are available after + # flush, before the transaction releases its writer connection. + await session.flush() + snapshot = self._row_to_dict(row) await session.commit() - await session.refresh(row) - return self._row_to_dict(row) + return snapshot async def list_by_session( self, session_id: str, *, user_id: str | _AutoSentinel = AUTO, limit: int = 100 @@ -139,10 +143,11 @@ class DeepResearchMessageRepository: if not result.rowcount: await session.rollback() return None - await session.commit() stmt = select(DeepResearchMessageRow).where(*where) row = (await session.execute(stmt)).scalar_one_or_none() - return self._row_to_dict(row) if row is not None else None + snapshot = self._row_to_dict(row) if row is not None else None + await session.commit() + return snapshot async def delete_by_session(self, session_id: str) -> int: from sqlalchemy import delete diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_sessions/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_sessions/sql.py index 01d93e62..24284a2c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_sessions/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_sessions/sql.py @@ -85,9 +85,13 @@ class DeepResearchSessionRepository: runtime_thread_id=runtime_thread_id, ) session.add(row) + # New sessions are a control-plane write. Avoid a post-commit + # refresh because MySQL read/write-splitting can make a just + # created session look absent on a lagging replica. + await session.flush() + snapshot = self._row_to_dict(row) await session.commit() - await session.refresh(row) - return self._row_to_dict(row) + return snapshot # ── read ──────────────────────────────────────────────────────────────── @@ -187,13 +191,14 @@ class DeepResearchSessionRepository: ) if result.rowcount == 0: return None - await session.commit() row = ( await session.execute( select(DeepResearchSessionRow).where(DeepResearchSessionRow.id == session_id) ) ).scalar_one_or_none() - return self._row_to_dict(row) if row else None + snapshot = self._row_to_dict(row) if row else None + await session.commit() + return snapshot async def replace_report_if_unchanged( self, @@ -231,13 +236,14 @@ class DeepResearchSessionRepository: ) if result.rowcount == 0: return None - await session.commit() row = ( await session.execute( select(DeepResearchSessionRow).where(DeepResearchSessionRow.id == session_id) ) ).scalar_one_or_none() - return self._row_to_dict(row) if row else None + snapshot = self._row_to_dict(row) if row else None + await session.commit() + return snapshot # ── delete ────────────────────────────────────────────────────────────── diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_sources/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_sources/model.py index 97cba76a..99d416dd 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_sources/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_sources/model.py @@ -23,6 +23,13 @@ from deerflow.persistence.types import BeijingDateTime, PortableLongText class DeepResearchSourceRow(Base): __tablename__ = "deep_research_sources" + __table_args__ = ( + # MySQL requires a prefix length when indexing TEXT columns. The + # column remains unbounded so long URLs are preserved; the prefix is + # only an index implementation detail and equality filters still + # compare the complete URL value. + sa.Index("ix_deep_research_sources_url", "url", mysql_length=512), + ) id: Mapped[str] = mapped_column(String(64), primary_key=True) session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) @@ -32,7 +39,7 @@ class DeepResearchSourceRow(Base): # The query that first surfaced this material. first_seen_query: Mapped[str | None] = mapped_column(Text, nullable=True) title: Mapped[str] = mapped_column(Text, nullable=False, default="") - url: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + url: Mapped[str | None] = mapped_column(Text, nullable=True) raw_content: Mapped[str] = mapped_column(PortableLongText, nullable=False, default="") snippet: Mapped[str | None] = mapped_column(Text, nullable=True) source: Mapped[str] = mapped_column(String(256), nullable=False, default="") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_sources/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_sources/sql.py index 0105ec13..bd8536cf 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_sources/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/deep_research_sources/sql.py @@ -37,9 +37,14 @@ class DeepResearchSourceRepository: row = DeepResearchSourceRow(session_id=session_id, job_id=job_id, **fields) session.add(row) try: + # The source was produced on the worker's write path. Build + # its response on that same connection so a replica-lag read + # cannot turn a successfully collected source into a failed + # research run. + await session.flush() + snapshot = self._row_to_dict(row) await session.commit() - await session.refresh(row) - return self._row_to_dict(row) + return snapshot except Exception: await session.rollback() # Fallback: find existing by rec_uuid or url. @@ -147,13 +152,14 @@ class DeepResearchSourceRepository: ) if result.rowcount == 0: return None - await session.commit() row = ( await session.execute( select(DeepResearchSourceRow).where(DeepResearchSourceRow.id == source_id) ) ).scalar_one_or_none() - return self._row_to_dict(row) if row else None + snapshot = self._row_to_dict(row) if row else None + await session.commit() + return snapshot async def delete_by_session(self, session_id: str) -> int: from sqlalchemy import delete diff --git a/offline-backend-20260512/backend/tests/test_deep_research_event_sink.py b/offline-backend-20260512/backend/tests/test_deep_research_event_sink.py new file mode 100644 index 00000000..fdaea172 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_deep_research_event_sink.py @@ -0,0 +1,83 @@ +"""Fault-tolerance coverage for Deep Research progress events.""" + +import pytest + +from deerflow.agents.deep_research.adapters.event_sink import PersistingEventSink + + +class _UnavailableEventRepository: + async def append(self, **kwargs): # noqa: ANN003 + raise RuntimeError("database temporarily unavailable") + + +@pytest.mark.asyncio +async def test_event_persistence_failure_keeps_job_live_and_emits_unique_fallback_event(): + published: list[dict] = [] + + async def publish(event: dict) -> None: + published.append(event) + + sink = PersistingEventSink( + _UnavailableEventRepository(), + session_id="drs_1", + job_id="drj_1", + on_event=publish, + ) + + first = await sink.emit("phase_changed", phase="planning", payload={"to": "planning"}) + second = await sink.emit("queries_planned", phase="collecting", payload={"queries": ["AI"]}) + + assert (first.seq, second.seq) == (-1, -2) + assert first.payload["eventPersistenceDegraded"] is True + assert published[0]["liveId"] == "event-persistence-fallback:drj_1:1" + assert published[1]["liveId"] == "event-persistence-fallback:drj_1:2" + + +@pytest.mark.asyncio +async def test_live_publish_failure_does_not_fail_event_delivery(): + class _EventRepository: + async def append(self, **kwargs): # noqa: ANN003 + return { + "seq": 1, + "event_type": kwargs["event_type"], + "phase": kwargs["phase"], + "payload": kwargs["payload"], + } + + async def broken_publisher(event: dict) -> None: # noqa: ARG001 + raise RuntimeError("browser disconnected") + + sink = PersistingEventSink( + _EventRepository(), + session_id="drs_1", + job_id="drj_1", + on_event=broken_publisher, + ) + + event = await sink.emit("phase_changed", phase="planning") + + assert event.seq == 1 + + +@pytest.mark.asyncio +async def test_document_rewrite_milestone_is_a_supported_durable_event(): + """Guard the artifact-rewrite job path against EventType regressions.""" + + class _EventRepository: + async def append(self, **kwargs): # noqa: ANN003 + return { + "seq": 1, + "event_type": kwargs["event_type"], + "phase": kwargs["phase"], + "payload": kwargs["payload"], + } + + sink = PersistingEventSink(_EventRepository(), session_id="drs_1", job_id="drj_1") + + event = await sink.emit( + "document_rewrite", + phase="planning", + payload={"type": "requirements_started"}, + ) + + assert event.type == "document_rewrite" diff --git a/offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py b/offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py new file mode 100644 index 00000000..0b4e5abc --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py @@ -0,0 +1,66 @@ +"""Failure-ordering regression coverage for final Deep Research report writes.""" + +import pytest + +from app.gateway import deep_research_job_executor as executor_module +from app.gateway.deep_research_job_executor import DeepResearchJobExecutor +from deerflow.agents.deep_research.types import DeepResearchResult + + +class _JobStore: + def __init__(self, session_store) -> None: + self._session_store = session_store + self.progress_calls: list[dict] = [] + + async def update_progress(self, job_id: str, **fields): # noqa: ARG002 + assert self._session_store.report_updates, "report must be persisted before job completion" + self.progress_calls.append(fields) + return {"id": job_id, **fields} + + +class _SessionStore: + def __init__(self, *, available: bool = True) -> None: + self.available = available + self.report_updates: list[dict] = [] + + async def update(self, session_id: str, **fields): # noqa: ARG002 + if not self.available: + raise RuntimeError("temporary session database failure") + self.report_updates.append(fields) + return {"id": session_id, **fields} + + +@pytest.mark.asyncio +async def test_report_is_persisted_before_job_is_marked_completed(): + sessions = _SessionStore() + jobs = _JobStore(sessions) + runner = DeepResearchJobExecutor(jobs, sessions, event_store=None) + + await runner._mark_completed( + "drj_1", + "drs_1", + "worker_1", + DeepResearchResult(report_markdown="# 报告", source_ids=["src_1"]), + ) + + assert sessions.report_updates[0]["report_markdown"] == "# 报告" + assert jobs.progress_calls[0]["status"] == "completed" + + +@pytest.mark.asyncio +async def test_report_projection_failure_never_marks_job_completed(monkeypatch): + monkeypatch.setattr(executor_module, "_REPORT_PROJECTION_RETRY_ATTEMPTS", 2) + monkeypatch.setattr(executor_module, "_REPORT_PROJECTION_RETRY_DELAY_SECONDS", 0) + sessions = _SessionStore(available=False) + jobs = _JobStore(sessions) + runner = DeepResearchJobExecutor(jobs, sessions, event_store=None) + + with pytest.raises(RuntimeError, match="研究报告未能保存"): + await runner._mark_completed( + "drj_1", + "drs_1", + "worker_1", + DeepResearchResult(report_markdown="# 报告"), + ) + + assert jobs.progress_calls == [] diff --git a/offline-backend-20260512/backend/tests/test_deep_research_persistence.py b/offline-backend-20260512/backend/tests/test_deep_research_persistence.py new file mode 100644 index 00000000..203b4e3d --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_deep_research_persistence.py @@ -0,0 +1,538 @@ +"""Persistence tests for deep-research sessions, jobs, events, sources, messages. + +Uses in-memory SQLite + aiosqlite, mirroring ``test_ai_writing_session_repo``. +Focuses on the durable-job mechanics that are load-bearing for correctness: + +- Session CRUD + user isolation. +- Job ``try_create_or_get_active`` idempotency (unique dedupe key). +- Job lease claim / renew / release (atomic CAS). +- Two-phase cancel (``request_cancel`` → ``finalize_cancel``). +- Event seq monotonicity + ``list_after`` replay (SSE reconnect correctness). + +Pattern: a plain ``async def _make_repos()`` helper (not a fixture) — matches +the project convention for async DB setup in pytest-asyncio strict mode. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + +async def _make_repos(): + """Create an in-memory SQLite repo set with all deep-research tables.""" + from deerflow.persistence.base import Base + from deerflow.persistence.deep_research_events.model import DeepResearchEventRow # noqa: F401 + from deerflow.persistence.deep_research_events.sql import DeepResearchEventRepository + from deerflow.persistence.deep_research_jobs.model import DeepResearchJobRow # noqa: F401 + from deerflow.persistence.deep_research_jobs.sql import DeepResearchJobRepository + from deerflow.persistence.deep_research_messages.model import DeepResearchMessageRow # noqa: F401 + from deerflow.persistence.deep_research_messages.sql import DeepResearchMessageRepository + from deerflow.persistence.deep_research_sessions.model import DeepResearchSessionRow # noqa: F401 + from deerflow.persistence.deep_research_sessions.sql import DeepResearchSessionRepository + from deerflow.persistence.deep_research_sources.model import DeepResearchSourceRow # noqa: F401 + from deerflow.persistence.deep_research_sources.sql import DeepResearchSourceRepository + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + sf = async_sessionmaker(engine, expire_on_commit=False) + return { + "sessions": DeepResearchSessionRepository(sf), + "jobs": DeepResearchJobRepository(sf), + "events": DeepResearchEventRepository(sf), + "sources": DeepResearchSourceRepository(sf), + "messages": DeepResearchMessageRepository(sf), + "_engine": engine, + } + + +# ── sessions ──────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_session_create_and_get(): + repos = await _make_repos() + try: + created = await repos["sessions"].create(id="drs_1", user_id="u1", title="Test", query="AI research", mode="deep") + assert created["id"] == "drs_1" + assert created["status"] == "draft" + + got = await repos["sessions"].get("drs_1", user_id="u1") + assert got is not None + assert got["query"] == "AI research" + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_deep_research_writes_never_depend_on_post_commit_refresh(monkeypatch): + """Control-plane writes must survive a MySQL replica-lag readback.""" + from sqlalchemy.ext.asyncio import AsyncSession + + async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001 + raise AssertionError("Deep Research writes must not call session.refresh") + + monkeypatch.setattr(AsyncSession, "refresh", fail_refresh) + repos = await _make_repos() + try: + session = await repos["sessions"].create( + id="drs_1", user_id="u1", title="Test", query="AI research" + ) + assert session["id"] == "drs_1" + assert await repos["sessions"].update("drs_1", user_id="u1", status="running") + + job, created = await repos["jobs"].try_create_or_get_active( + id="drj_1", session_id="drs_1", user_id="u1" + ) + assert created is True + assert job["id"] == "drj_1" + + source = await repos["sources"].upsert( + session_id="drs_1", + id="src_1", + user_id="u1", + title="A", + raw_content="source content", + source="web", + content_hash="hash_1", + ) + assert source["id"] == "src_1" + + message = await repos["messages"].append( + id="msg_1", + session_id="drs_1", + user_id="u1", + role="assistant", + content="已开始撰写", + ) + assert message["id"] == "msg_1" + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_session_user_isolation(): + repos = await _make_repos() + try: + await repos["sessions"].create(id="drs_1", user_id="u1", query="q1") + assert await repos["sessions"].get("drs_1", user_id="u2") is None + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_session_list_strips_heavy_columns(): + repos = await _make_repos() + try: + await repos["sessions"].create(id="drs_1", user_id="u1", query="q", config_snapshot={"mode": "deep"}) + items = await repos["sessions"].list_by_user(user_id="u1") + assert len(items) == 1 + assert "report_markdown" not in items[0] + assert "config_snapshot" not in items[0] + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_session_update_and_delete(): + repos = await _make_repos() + try: + await repos["sessions"].create(id="drs_1", user_id="u1", query="q") + updated = await repos["sessions"].update("drs_1", user_id="u1", title="New Title") + assert updated["title"] == "New Title" + assert await repos["sessions"].delete("drs_1", user_id="u1") is True + assert await repos["sessions"].get("drs_1", user_id="u1") is None + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_count_active_by_user(): + repos = await _make_repos() + try: + await repos["sessions"].create(id="drs_1", user_id="u1", query="q", status="running") + await repos["sessions"].create(id="drs_2", user_id="u1", query="q", status="completed") + count = await repos["sessions"].count_active_by_user(user_id="u1") + assert count == 1 + finally: + await repos["_engine"].dispose() + + +# ── jobs: idempotent create ───────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_job_try_create_returns_created_true(): + repos = await _make_repos() + try: + job, created = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1", request_id="req_1") + assert created is True + assert job["status"] == "queued" + assert job["active_dedupe_key"] is not None + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_job_create_never_reads_back_after_commit(monkeypatch): + """A committed job must not fail when a MySQL replica briefly lags.""" + from sqlalchemy.ext.asyncio import AsyncSession + + async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001 + raise AssertionError("DeepResearchJobRepository must not call session.refresh") + + monkeypatch.setattr(AsyncSession, "refresh", fail_refresh) + repos = await _make_repos() + try: + job, created = await repos["jobs"].try_create_or_get_active( + id="drj_1", + session_id="drs_1", + user_id="u1", + request_id="req_1", + ) + assert created is True + assert job["id"] == "drj_1" + assert job["status"] == "queued" + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_job_try_create_idempotent_returns_existing(): + repos = await _make_repos() + try: + job1, created1 = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1", request_id="req_1") + job2, created2 = await repos["jobs"].try_create_or_get_active(id="drj_2", session_id="drs_1", user_id="u1", request_id="req_2") + assert created1 is True + assert created2 is False + assert job2["id"] == job1["id"] + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_job_dedupe_released_on_terminal(): + repos = await _make_repos() + try: + job, _ = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1") + assert job["active_dedupe_key"] is not None + await repos["jobs"].update_progress("drj_1", lease_owner=None, user_id="u1", status="completed") + job2, created2 = await repos["jobs"].try_create_or_get_active(id="drj_2", session_id="drs_1", user_id="u1") + assert created2 is True + assert job2["id"] == "drj_2" + finally: + await repos["_engine"].dispose() + + +# ── jobs: lease mechanics ─────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_job_claim_succeeds_for_queued(): + repos = await _make_repos() + try: + await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1") + lease_until = datetime.now(UTC) + timedelta(seconds=60) + claimed = await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until) + assert claimed is not None + assert claimed["status"] == "running" + assert claimed["lease_owner"] == "w1" + assert claimed["attempt"] == 1 + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_dispatcher_claims_queued_job_and_starts_report_worker(): + """The claimed snapshot must reach the executor before the job can run.""" + from app.gateway.deep_research_job_dispatcher import DeepResearchJobDispatcher + + class _Executor: + def __init__(self) -> None: + self.started: list[tuple[dict, str, str | None]] = [] + + def start_job(self, snapshot: dict, *, job_id: str, lease_owner: str | None) -> bool: + self.started.append((snapshot, job_id, lease_owner)) + return True + + repos = await _make_repos() + try: + await repos["jobs"].try_create_or_get_active( + id="drj_1", + session_id="drs_1", + user_id="u1", + input_snapshot={"query": "测试报告"}, + ) + runner = _Executor() + dispatcher = DeepResearchJobDispatcher(repos["jobs"], runner, worker_id="worker_1") + + started = await dispatcher.dispatch_once() + + assert started == 1 + assert runner.started == [({"query": "测试报告"}, "drj_1", "worker_1")] + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_job_claim_fails_if_already_claimed(): + repos = await _make_repos() + try: + await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1") + lease_until = datetime.now(UTC) + timedelta(seconds=60) + first = await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until) + assert first is not None + second = await repos["jobs"].claim_job("drj_1", lease_owner="w2", lease_until=lease_until) + assert second is None + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_job_renew_lease_only_by_owner(): + repos = await _make_repos() + try: + await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1") + lease_until = datetime.now(UTC) + timedelta(seconds=60) + await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until) + assert await repos["jobs"].renew_lease("drj_1", lease_owner="w1", lease_until=lease_until) is True + assert await repos["jobs"].renew_lease("drj_1", lease_owner="w2", lease_until=lease_until) is False + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_job_expired_lease_reclaimable(): + repos = await _make_repos() + try: + await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1") + expired = datetime.now(UTC) - timedelta(seconds=10) + await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=expired) + now = datetime.now(UTC) + claimable = await repos["jobs"].list_claimable(now=now) + assert "drj_1" in claimable + reclaimed = await repos["jobs"].claim_job("drj_1", lease_owner="w2", lease_until=now + timedelta(seconds=60)) + assert reclaimed is not None + assert reclaimed["lease_owner"] == "w2" + finally: + await repos["_engine"].dispose() + + +# ── jobs: two-phase cancel ────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_job_cancel_two_phase(): + repos = await _make_repos() + try: + await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1") + lease_until = datetime.now(UTC) + timedelta(seconds=60) + await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until) + + requested = await repos["jobs"].request_cancel("drj_1", user_id="u1") + assert requested is not None + assert requested["cancel_requested"] is True + + finalized = await repos["jobs"].finalize_cancel("drj_1") + assert finalized is not None + assert finalized["status"] == "cancelled" + assert finalized["active_dedupe_key"] is None + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_job_cancel_idempotent(): + repos = await _make_repos() + try: + await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1") + first = await repos["jobs"].request_cancel("drj_1", user_id="u1") + assert first is not None + await repos["jobs"].finalize_cancel("drj_1") + second = await repos["jobs"].request_cancel("drj_1", user_id="u1") + assert second is None + finally: + await repos["_engine"].dispose() + + +# ── jobs: progress update with lease gate ─────────────────────────────────── + + +@pytest.mark.asyncio +async def test_job_update_progress_lease_gated(): + repos = await _make_repos() + try: + await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1") + lease_until = datetime.now(UTC) + timedelta(seconds=60) + await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until) + + updated = await repos["jobs"].update_progress("drj_1", lease_owner="w1", phase="collecting", progress=50) + assert updated is not None + assert updated["phase"] == "collecting" + assert updated["progress"] == 50 + + rejected = await repos["jobs"].update_progress("drj_1", lease_owner="w2", phase="writing") + assert rejected is None + finally: + await repos["_engine"].dispose() + + +# ── events: monotonic seq + replay ────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_event_seq_monotonic(): + repos = await _make_repos() + try: + repo = repos["events"] + e1 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="phase_changed", payload={"to": "planning"}) + e2 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="source_added", payload={"id": "s1"}) + e3 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="source_added", payload={"id": "s2"}) + assert e1["seq"] == 1 + assert e2["seq"] == 2 + assert e3["seq"] == 3 + assert await repo.last_seq("drj_1") == 3 + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_event_append_never_reads_back_after_commit(monkeypatch): + """Event persistence must work when a post-commit read hits a lagging replica.""" + from sqlalchemy.ext.asyncio import AsyncSession + + async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001 + raise AssertionError("DeepResearchEventRepository must not call session.refresh") + + monkeypatch.setattr(AsyncSession, "refresh", fail_refresh) + repos = await _make_repos() + try: + event = await repos["events"].append( + session_id="drs_1", + job_id="drj_1", + event_type="phase_changed", + payload={"to": "planning"}, + ) + assert event["id"] is not None + assert event["seq"] == 1 + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_event_list_after_replay(): + repos = await _make_repos() + try: + repo = repos["events"] + for i in range(5): + await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat", payload={"i": i}) + replayed = await repo.list_after("drj_1", after=2) + assert len(replayed) == 3 + assert [e["seq"] for e in replayed] == [3, 4, 5] + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_event_payload_round_trip(): + repos = await _make_repos() + try: + repo = repos["events"] + await repo.append( + session_id="drs_1", + job_id="drj_1", + event_type="plan_created", + payload={"subtopics": ["AI", "ML"], "nested": {"a": 1}}, + ) + events = await repo.list_after("drj_1", after=0) + assert events[0]["payload"]["subtopics"] == ["AI", "ML"] + assert events[0]["payload"]["nested"]["a"] == 1 + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_event_seq_independent_per_job(): + repos = await _make_repos() + try: + repo = repos["events"] + await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat") + await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat") + await repo.append(session_id="drs_1", job_id="drj_2", event_type="heartbeat") + assert await repo.last_seq("drj_1") == 2 + assert await repo.last_seq("drj_2") == 1 + finally: + await repos["_engine"].dispose() + + +# ── sources ───────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_source_upsert_and_list(): + repos = await _make_repos() + try: + await repos["sources"].upsert( + session_id="drs_1", + id="src_1", + user_id="u1", + title="Source A", + url="https://example.com/a", + raw_content="content A", + source="web", + source_type="web", + content_hash="hash_a", + ) + items = await repos["sources"].list_by_session("drs_1", user_id="u1") + assert len(items) == 1 + assert items[0]["title"] == "Source A" + assert "raw_content" not in items[0] + finally: + await repos["_engine"].dispose() + + +@pytest.mark.asyncio +async def test_source_set_selected(): + repos = await _make_repos() + try: + await repos["sources"].upsert( + session_id="drs_1", + id="src_1", + user_id="u1", + title="A", + raw_content="c", + source="web", + content_hash="h", + ) + updated = await repos["sources"].set_selected("src_1", selected=True, reason="relevant", citation_key="1", user_id="u1") + assert updated["selected"] is True + assert updated["citation_key"] == "1" + finally: + await repos["_engine"].dispose() + + +# ── messages ──────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_message_append_and_list(): + repos = await _make_repos() + try: + await repos["messages"].append(id="msg_1", session_id="drs_1", user_id="u1", role="user", content="question?") + await repos["messages"].append( + id="msg_2", + session_id="drs_1", + user_id="u1", + role="assistant", + content="answer", + citation_source_ids=["src_1"], + ) + msgs = await repos["messages"].list_by_session("drs_1", user_id="u1") + assert len(msgs) == 2 + assert msgs[0]["role"] == "user" + assert msgs[1]["citation_source_ids"] == ["src_1"] + finally: + await repos["_engine"].dispose() diff --git a/offline-backend-20260512/backend/tests/test_deep_research_start_job.py b/offline-backend-20260512/backend/tests/test_deep_research_start_job.py new file mode 100644 index 00000000..576b1b98 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_deep_research_start_job.py @@ -0,0 +1,156 @@ +"""Regression coverage for idempotent Deep Research job submission.""" + +from types import SimpleNamespace + +import pytest + +from app.gateway.routers import deep_research + + +class _SessionStore: + def __init__(self) -> None: + self.updates: list[dict] = [] + + async def get(self, session_id: str, *, user_id: str): # noqa: ARG002 + return { + "id": session_id, + "query": "测试主题", + "title": "测试主题", + "config_snapshot": {"mode": "basic"}, + } + + async def count_active_by_user(self, *, user_id: str) -> int: # noqa: ARG002 + return 0 + + async def update(self, session_id: str, *, user_id: str, **fields): # noqa: ARG002 + self.updates.append(fields) + return {"id": session_id, **fields} + + +class _JobStore: + async def get_active_for_session(self, session_id: str, *, user_id: str): # noqa: ARG002 + return None + + async def try_create_or_get_active(self, **kwargs): # noqa: ANN003 + # Simulate a retry after an earlier request committed the job but did + # not finish responding to the browser. + return {"id": "drj_existing", "status": "queued"}, False + + +class _Dispatcher: + def __init__(self) -> None: + self.nudges = 0 + + def nudge(self) -> None: + self.nudges += 1 + + +def _request(*, sessions, jobs, dispatcher): + return SimpleNamespace( + app=SimpleNamespace( + state=SimpleNamespace( + deep_research_session_store=sessions, + deep_research_job_store=jobs, + deep_research_dispatcher=dispatcher, + ) + ) + ) + + +@pytest.mark.asyncio +async def test_retry_of_existing_queued_job_nudges_dispatcher(monkeypatch): + """An idempotent retry must recover a queued-but-not-yet-dispatched job.""" + + async def fake_require_user(request): # noqa: ANN001 + return "u1" + + monkeypatch.setattr(deep_research, "_require_user", fake_require_user) + sessions = _SessionStore() + dispatcher = _Dispatcher() + request = _request(sessions=sessions, jobs=_JobStore(), dispatcher=dispatcher) + + response = await deep_research.start_job( + "drs_1", + deep_research.StartJobRequest(entry="legacy"), + request, + ) + + assert response == { + "jobId": "drj_existing", + "sessionId": "drs_1", + "status": "queued", + "reused": True, + } + assert dispatcher.nudges == 1 + assert sessions.updates == [{"status": "running", "active_job_id": "drj_existing"}] + + +@pytest.mark.asyncio +async def test_queued_job_still_starts_when_session_projection_temporarily_fails(monkeypatch): + """A committed job is more important than its non-authoritative UI projection.""" + + async def fake_require_user(request): # noqa: ANN001 + return "u1" + + class _ProjectionFailureSessionStore(_SessionStore): + async def update(self, session_id: str, *, user_id: str, **fields): # noqa: ARG002 + raise RuntimeError("temporary database readback failure") + + class _NewJobStore(_JobStore): + async def try_create_or_get_active(self, **kwargs): # noqa: ANN003 + return {"id": "drj_new", "status": "queued"}, True + + monkeypatch.setattr(deep_research, "_require_user", fake_require_user) + dispatcher = _Dispatcher() + response = await deep_research.start_job( + "drs_1", + deep_research.StartJobRequest(entry="legacy"), + _request( + sessions=_ProjectionFailureSessionStore(), + jobs=_NewJobStore(), + dispatcher=dispatcher, + ), + ) + + assert response["jobId"] == "drj_new" + assert response["reused"] is False + assert dispatcher.nudges == 1 + + +@pytest.mark.asyncio +async def test_stream_stays_open_when_durable_replay_has_a_transient_failure(monkeypatch): + """A stream DB blip must yield a recoverable warning, not an ASGI error group.""" + + async def fake_require_user(request): # noqa: ANN001 + return "u1" + + class _StreamJobStore: + async def get(self, job_id: str, *, user_id: str): # noqa: ARG002 + return {"id": job_id, "session_id": "drs_1"} + + async def get_unscoped(self, job_id: str): # noqa: ARG002 + return None + + class _UnavailableEventStore: + async def list_after(self, *args, **kwargs): # noqa: ANN002, ANN003 + raise RuntimeError("temporary replica failure") + + async def is_connected() -> bool: + return False + + monkeypatch.setattr(deep_research, "_require_user", fake_require_user) + request = SimpleNamespace( + app=SimpleNamespace( + state=SimpleNamespace( + deep_research_job_store=_StreamJobStore(), + deep_research_event_store=_UnavailableEventStore(), + ) + ), + is_disconnected=is_connected, + ) + + response = await deep_research.stream_job("drj_1", request) + chunk = await anext(response.body_iterator) + await response.body_iterator.aclose() + + assert "durable_replay_temporarily_unavailable" in chunk diff --git a/offline-backend-20260512/backend/tests/test_summarization_human_message.py b/offline-backend-20260512/backend/tests/test_summarization_human_message.py new file mode 100644 index 00000000..e9636227 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_summarization_human_message.py @@ -0,0 +1,30 @@ +"""Regression coverage for provider-safe transient context compression.""" + +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage + +from deerflow.agents.middlewares.summarization_middleware import _ensure_latest_human_message + + +def test_compression_restores_latest_human_turn_when_only_tool_tail_remains(): + user = HumanMessage(content="请分析这份材料", id="user-1") + source = [ + user, + AIMessage(content="", id="ai-1"), + ToolMessage(content="检索结果", tool_call_id="call-1", id="tool-1"), + ] + compressed = [SystemMessage(content="internal summary"), *source[1:]] + + result = _ensure_latest_human_message(source, compressed) + + assert result[0].type == "system" + assert result[1] is user + assert [message.type for message in result[2:]] == ["ai", "tool"] + + +def test_compression_does_not_duplicate_human_turn_already_in_tail(): + user = HumanMessage(content="继续", id="user-1") + compressed = [SystemMessage(content="internal summary"), user] + + result = _ensure_latest_human_message([user], compressed) + + assert result == compressed From 15a46b903a0ace98d07223f6cf8b2b2277769909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 18 Aug 2026 17:46:11 +0800 Subject: [PATCH 05/13] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E6=94=B9=E5=86=99=E9=85=8D=E5=9B=BE=E4=B8=8E=E6=B7=B1=E5=BA=A6?= =?UTF-8?q?=E7=A0=94=E7=A9=B6=E6=B5=81=E5=BC=8F=E7=A8=B3=E5=AE=9A=E6=80=A7?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=AE=8C=E5=96=84=208BF/=E4=BC=9A=E5=95=86?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全文改写支持可选配图与思考过程展示,深度研究接入 DashScope 原生出图并剥离内联 think 标签;8BF 会商补齐第一步工作区与任务卡回退,同时限制热重载扫描范围。 Co-authored-by: Cursor --- .../artifacts/artifact-file-detail.tsx | 277 +++++++++-- .../artifacts/document-rewrite-dialog.tsx | 66 ++- .../messages/document-rewrite-timeline.tsx | 94 +++- .../workspace/messages/message-group.tsx | 32 ++ .../src/core/document-rewrite/context.tsx | 18 + .../src/core/streamdown/inline-think.ts | 90 ++++ .../eightbf-roundtable/api/task-context.ts | 21 + .../components/EightBfStep1Workspace.tsx | 451 ++++++++++++++++++ .../components/EightBfTaskCard.tsx | 77 ++- .../pages/EightBfRoundtablePage.tsx | 73 ++- .../src/eightbf-roundtable/styles/step1.css | 311 +++++++++--- .../src/eightbf-roundtable/styles/step2.css | 65 ++- frontend-web/src/open-canvas/api/rewrite.ts | 43 ++ .../open-canvas/hooks/useDocumentRewrite.ts | 161 +++++-- .../deep-research/DeepResearchWorkbench.tsx | 290 ++++++++++- .../pages/deep-research/ReportConfigCard.tsx | 215 ++++++++- .../deep-research/ResearchSandboxPanel.tsx | 31 +- .../pages/deep-research/useReportSandbox.ts | 8 +- .../components/PositionBusinessChainFlow.tsx | 51 +- .../src/roundtable-planning/api/intent.ts | 37 ++ .../components/Step2Panel.tsx | 265 ++++++---- .../pages/RoundtablePlanningPage.tsx | 345 +++++++++----- offline-backend-20260512/backend/CLAUDE.md | 9 +- offline-backend-20260512/backend/Makefile | 4 +- offline-backend-20260512/backend/README.md | 21 +- .../app/gateway/deep_research_job_executor.py | 291 ++++++++--- .../gateway/deep_research_report_rewrite.py | 4 + .../gateway/document_rewrite_illustrations.py | 263 ++++++++++ .../app/gateway/document_rewrite_pipeline.py | 39 +- .../app/gateway/routers/business_mapping.py | 32 ++ .../backend/app/gateway/routers/intent.py | 38 +- .../backend/app/gateway/routers/writing.py | 61 ++- offline-backend-20260512/backend/config.yaml | 15 +- .../deerflow/agents/deep_research/UPSTREAM.md | 2 +- .../agents/deep_research/adapters/image.py | 223 ++++++++- .../agents/deep_research/adapters/llm.py | 68 ++- .../deerflow/agents/deep_research/events.py | 5 + .../agents/deep_research/runners/basic.py | 1 + .../agents/deep_research/runners/detailed.py | 3 + .../agents/deep_research/runners/images.py | 23 +- .../deep_research/runners/multi_agent.py | 1 + .../deep_research/runners/report_stream.py | 14 + .../harness/deerflow/utils/stream_text.py | 90 ++++ .../backend/tests/test_business_mapping.py | 41 ++ ...test_deep_research_executor_reliability.py | 6 + .../tests/test_deep_research_start_job.py | 55 +++ .../test_position_roundtable_sessions.py | 6 +- scripts/start-backend.sh | 4 +- 48 files changed, 3724 insertions(+), 616 deletions(-) create mode 100644 frontend-web/src/core/streamdown/inline-think.ts create mode 100644 frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx create mode 100644 offline-backend-20260512/backend/app/gateway/document_rewrite_illustrations.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/utils/stream_text.py diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index 3a11ded3..9af34f0b 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -19,8 +19,9 @@ import { useQueryClient } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react"; -import { toast } from "sonner"; -import { Streamdown } from "streamdown"; +import { toast } from "sonner"; +import { defaultUrlTransform } from "react-markdown"; +import { Streamdown } from "streamdown"; import { Artifact, @@ -42,6 +43,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { CodeEditor } from "@/components/workspace/code-editor"; import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor"; import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite"; +import { useCapabilities as useResearchCapabilities } from "@/strategy-components/api/deep-research"; import { listDurableDocumentRewrites, streamDurableDocumentRewriteJob, @@ -149,9 +151,11 @@ export function ArtifactFileDetail({ onClose, allowVirtualMarkdownEdit = false, disableDesensitize = false, - hideFileName = false, - onDownloadClick, -}: { + hideFileName = false, + onDownloadClick, + toolbarActions, + researchSessionId, +}: { className?: string; filepath: string; threadId: string; @@ -177,8 +181,15 @@ export function ArtifactFileDetail({ * (深度研究沙箱用它弹「Markdown / Word」格式选择弹窗),并隐藏工具栏中重复的 * 「另存为 Word」。默认不传 —— 其它调用方行为不变。 */ - onDownloadClick?: () => void; -}) { + onDownloadClick?: () => void; + /** Deep-research reports use protected `deep-research://` image references. */ + researchSessionId?: string; + /** + * 由宿主注入到右上角 ArtifactActions 图标组的动作。深度研究报告使用它 + * 放置全文改写入口,避免再在沙箱内容区叠一个文字按钮。 + */ + toolbarActions?: ReactNode; +}) { const { t } = useI18n(); const { artifacts, setOpen, select } = useArtifacts(); const { isMock, thread, publicShareCode } = useThread(); @@ -291,13 +302,20 @@ export function ArtifactFileDetail({ const updateArtifact = useUpdateArtifactContent(); const queryClient = useQueryClient(); const documentRewrite = useDocumentRewrite(); + const imageCapabilities = useResearchCapabilities(); const rewritePresentation = useDocumentRewritePresentation(); const hydratedRewriteScopeRef = useRef(null); const mirroredRequirementsPlanRef = useRef<{ presentationId: string | null; plan: string }>({ presentationId: null, plan: "", }); + const mirroredThinkingTextRef = useRef<{ presentationId: string | null; thinking: string }>({ + presentationId: null, + thinking: "", + }); const documentRewriteActive = documentRewrite.isActive; + const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true; + const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4; const canUseFullDocumentRewrite = canEditArtifact && !hasLocalEdits; const displayContent = documentRewriteActive ? documentRewrite.run.draft @@ -405,6 +423,29 @@ export function ArtifactFileDetail({ writtenCharCount: event.charCount, }); break; + case "illustration_started": + rewritePresentation.update(presentationId, { + stage: "illustrating", + illustrationTarget: event.section, + maxGeneratedImages: event.total || 1, + }); + break; + case "illustration_generated": + rewritePresentation.update(presentationId, { + stage: "illustrating", + illustrationTarget: event.section, + generatedImageCount: event.generatedCount ?? event.position, + maxGeneratedImages: event.total || 1, + }); + break; + case "illustration_completed": + rewritePresentation.update(presentationId, { + stage: "illustrating", + generatedImageCount: event.generatedCount, + maxGeneratedImages: event.requestedCount || 1, + warnings: event.warnings, + }); + break; case "validation_started": rewritePresentation.update(presentationId, { stage: "validation" }); break; @@ -463,6 +504,8 @@ export function ArtifactFileDetail({ instruction: historical.request.instruction, style: historical.request.style, modelName: historical.request.modelName, + generateImages: historical.request.generateImages, + maxGeneratedImages: historical.request.maxGeneratedImages, originalContent: historical.request.originalContent, rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null, requirementsPlan: typeof historicalResult.requirementsPlan === "string" @@ -497,6 +540,8 @@ export function ArtifactFileDetail({ instruction: latest.request.instruction, style: latest.request.style, modelName: latest.request.modelName, + generateImages: latest.request.generateImages, + maxGeneratedImages: latest.request.maxGeneratedImages, originalContent, rewrittenContent, requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "", @@ -537,6 +582,8 @@ export function ArtifactFileDetail({ instruction: latest.request.instruction, style: latest.request.style, modelName: latest.request.modelName ?? undefined, + generateImages: latest.request.generateImages, + maxGeneratedImages: latest.request.maxGeneratedImages, }, { jobId: latest.id, @@ -605,6 +652,19 @@ export function ArtifactFileDetail({ rewritePresentation, ]); + // Thoughts use the same rAF coalescing as the draft, then are mirrored once + // per browser frame into the regular message-list card. They never touch + // the artifact Markdown or the durable rewrite checkpoint. + useEffect(() => { + const presentationId = documentRewritePresentationId; + const thinking = documentRewrite.run.thinkingText; + if (!presentationId || !rewritePresentation || !thinking) return; + const previous = mirroredThinkingTextRef.current; + if (previous.presentationId === presentationId && previous.thinking === thinking) return; + mirroredThinkingTextRef.current = { presentationId, thinking }; + rewritePresentation.update(presentationId, { thinkingText: thinking }); + }, [documentRewrite.run.thinkingText, documentRewritePresentationId, rewritePresentation]); + useEffect(() => { const handleRewriteUndo = (event: Event) => { const detail = (event as CustomEvent<{ @@ -851,6 +911,8 @@ export function ArtifactFileDetail({ instruction: string; style: "formal_analysis" | "professional" | "concise" | "readable"; modelName: string | null; + generateImages: boolean; + maxGeneratedImages: number; }) => { if (!canUseFullDocumentRewrite) { toast.error( @@ -872,6 +934,8 @@ export function ArtifactFileDetail({ instruction: input.instruction, style: input.style, modelName: input.modelName, + generateImages: input.generateImages, + maxGeneratedImages: input.maxGeneratedImages, originalContent: draftContent, }); setDocumentRewritePresentationId(presentationId ?? null); @@ -882,6 +946,8 @@ export function ArtifactFileDetail({ instruction: input.instruction, style: input.style, modelName: input.modelName ?? undefined, + generateImages: input.generateImages, + maxGeneratedImages: input.maxGeneratedImages, expectedHash, }, { @@ -921,6 +987,29 @@ export function ArtifactFileDetail({ writtenCharCount: event.charCount, }); break; + case "illustration_started": + rewritePresentation.update(presentationId, { + stage: "illustrating", + illustrationTarget: event.section, + maxGeneratedImages: event.total || 1, + }); + break; + case "illustration_generated": + rewritePresentation.update(presentationId, { + stage: "illustrating", + illustrationTarget: event.section, + generatedImageCount: event.generatedCount ?? event.position, + maxGeneratedImages: event.total || 1, + }); + break; + case "illustration_completed": + rewritePresentation.update(presentationId, { + stage: "illustrating", + generatedImageCount: event.generatedCount, + maxGeneratedImages: event.requestedCount || 1, + warnings: event.warnings, + }); + break; case "validation_started": rewritePresentation.update(presentationId, { stage: "validation" }); break; @@ -1217,6 +1306,7 @@ export function ArtifactFileDetail({ onClick={handleSaveArtifact} /> )} + {toolbarActions} {canEditArtifact && ( )} @@ -1454,25 +1545,40 @@ export function ArtifactFileDetail({ onOpenChange={setDocumentRewriteDialogOpen} fileName={getFileName(filepath)} charCount={(displayContent ?? "").replace(/\s/g, "").length} + imageGenerationAvailable={imageGenerationAvailable} + maxGeneratedImages={maxGeneratedImages} onStart={handleStartDocumentRewrite} /> ); } -function SandboxPreviewImage({ - src, - alt, - threadId, - publicShareCode, - ...props -}: React.ImgHTMLAttributes & { - threadId: string; - publicShareCode?: string; -}) { - if (!src || typeof src !== "string") { - return null; - } +function SandboxPreviewImage({ + src, + alt, + threadId, + publicShareCode, + researchSessionId, + ...props +}: React.ImgHTMLAttributes & { + threadId: string; + publicShareCode?: string; + researchSessionId?: string; +}) { + if (!src || typeof src !== "string") { + return null; + } + + if (src.startsWith("deep-research://")) { + return researchSessionId ? ( + + ) : null; + } let url = src; if (src.startsWith("/mnt/")) { @@ -1496,21 +1602,108 @@ function SandboxPreviewImage({ src={url} alt={alt} /> - ); -} + ); +} + +/** Fetches a private deep-research image with the normal auth client. */ +function ProtectedResearchReportImage({ + sessionId, + src, + alt, + ...props +}: React.ImgHTMLAttributes & { + sessionId: string; +}) { + const [objectUrl, setObjectUrl] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + const [retryKey, setRetryKey] = useState(0); + + useEffect(() => { + setObjectUrl(null); + setLoadFailed(false); + if (!src?.startsWith("deep-research://")) { + return; + } + const name = src.slice("deep-research://".length); + if (!name) { + setLoadFailed(true); + return; + } + const controller = new AbortController(); + let nextUrl: string | null = null; + let disposed = false; + void (async () => { + try { + const response = await apiFetch( + `${getBackendBaseURL()}/api/deep-research/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(name)}`, + { method: "GET", signal: controller.signal }, + ); + if (!response.ok) { + throw new Error(`Unable to load report image (${response.status})`); + } + const blob = await response.blob(); + if (!blob.size) { + throw new Error("Report image is empty"); + } + nextUrl = URL.createObjectURL(blob); + if (disposed) return; + setObjectUrl(nextUrl); + } catch (error) { + if (!disposed && !(error instanceof DOMException && error.name === "AbortError")) { + // Never let an optional illustration break Markdown rendering, but don't + // leave the user with a misleading infinite loading label either. + setLoadFailed(true); + } + } + })(); + return () => { + disposed = true; + controller.abort(); + if (nextUrl) URL.revokeObjectURL(nextUrl); + }; + }, [retryKey, sessionId, src]); + + if (!objectUrl) { + if (loadFailed) { + return ( + + 报告配图加载失败 + + + ); + } + return 正在加载报告配图…; + } + return ( + {alt + ); +} export function ArtifactFilePreview({ content, language, - threadId, - publicShareCode, - animated = false, + threadId, + publicShareCode, + researchSessionId, + animated = false, activeCursor = false, }: { content: string; language: string; - threadId: string; - publicShareCode?: string; + threadId: string; + publicShareCode?: string; + researchSessionId?: string; animated?: boolean; activeCursor?: boolean; }) { @@ -1574,7 +1767,7 @@ export function ArtifactFilePreview({ ); }, }; - }, [content]); + }, [content, publicShareCode, researchSessionId, threadId]); useEffect(() => { if (language !== "html") { @@ -1639,16 +1832,26 @@ export function ArtifactFilePreview({ animated && "animate-[sandbox-preview-enter_320ms_ease-out]", )} > - + url.startsWith("deep-research://") + ? url + : defaultUrlTransform(url) + } + components={{ ...markdownComponents, img: (props) => ( ), }} diff --git a/frontend-web/src/components/workspace/artifacts/document-rewrite-dialog.tsx b/frontend-web/src/components/workspace/artifacts/document-rewrite-dialog.tsx index 765ceca1..ee696af3 100644 --- a/frontend-web/src/components/workspace/artifacts/document-rewrite-dialog.tsx +++ b/frontend-web/src/components/workspace/artifacts/document-rewrite-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { SparklesIcon } from "lucide-react"; +import { ImagePlusIcon, SparklesIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -20,6 +20,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; import { ModelNameSelect } from "@/components/workspace/model-name-select"; import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite"; @@ -51,16 +52,22 @@ export function DocumentRewriteDialog({ onOpenChange, fileName, charCount, + imageGenerationAvailable = false, + maxGeneratedImages = 4, onStart, }: { open: boolean; onOpenChange: (open: boolean) => void; fileName: string; charCount: number; + imageGenerationAvailable?: boolean; + maxGeneratedImages?: number; onStart: (input: { instruction: string; style: DocumentRewriteStyle; modelName: string | null; + generateImages: boolean; + maxGeneratedImages: number; }) => void; }) { const [instruction, setInstruction] = useState( @@ -68,11 +75,15 @@ export function DocumentRewriteDialog({ ); const [style, setStyle] = useState("formal_analysis"); const [modelName, setModelName] = useState(null); + const [generateImages, setGenerateImages] = useState(false); + const [imageCount, setImageCount] = useState("2"); useEffect(() => { if (!open) return; setStyle("formal_analysis"); setModelName(null); + setGenerateImages(false); + setImageCount("2"); }, [open]); const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style); @@ -136,6 +147,48 @@ export function DocumentRewriteDialog({
+ {imageGenerationAvailable && ( +
+
+ + +
+ {generateImages && ( +
+ 每次会额外消耗图片模型额度 + +
+ )} +
+ )} + + + +

+ {active ? "模型思考内容正在更新" : "模型思考内容已完成"} +

+
+          {text}
+        
+
+ + ); +} + export function DocumentRewriteTimeline({ item, }: { @@ -64,6 +110,9 @@ export function DocumentRewriteTimeline({ const current = stepPosition(item.stage); const failed = ["conflict", "error", "cancelled"].includes(item.stage); const completed = item.stage === "completed"; + const thinkingStep = ["snapshot", "requirements"].includes(item.stage) + ? "requirements" + : "writing"; const presentation = useDocumentRewritePresentation(); const [undoing, setUndoing] = useState(false); const [diffOpen, setDiffOpen] = useState(false); @@ -148,7 +197,13 @@ export function DocumentRewriteTimeline({
{STEPS.map((step, index) => { const active = !isTerminal(item.stage) && index === current; - const done = completed || index < current; + const illustrationSkipped = step.key === "illustrations" && !item.generateImages; + const illustrationFailed = + step.key === "illustrations" && + item.generateImages && + completed && + item.generatedImageCount === 0; + const done = !illustrationSkipped && !illustrationFailed && (completed || index < current); return (
)} + {step.key === "illustrations" && ( + illustrationSkipped ? ( +

+ 本次未启用配图,保留原文已有图片与链接。 +

+ ) : active ? ( +

+ {item.illustrationTarget + ? `正在为「${item.illustrationTarget}」生成配图,已完成 ${item.generatedImageCount}/${item.maxGeneratedImages} 张。` + : `正在准备 ${item.maxGeneratedImages} 张文档配图…`} +

+ ) : item.generatedImageCount > 0 ? ( +

+ 已生成并保存 {item.generatedImageCount} 张本地配图。 +

+ ) : ( +

+ 未成功生成配图,文字改写结果仍已保留。 +

+ ) + )} + {step.key === thinkingStep && ( + + )} {step.key === "validation" && item.warnings.length > 0 && (

{item.warnings.join(";")} @@ -228,6 +307,11 @@ export function DocumentRewriteTimeline({ 本次优化:{item.summary.requestedOptimizations.join("、")}。

) : null} + {item.summary.requestedImageCount ? ( +

+ 文档配图:请求 {item.summary.requestedImageCount} 张,已保存 {item.summary.generatedImageCount ?? 0} 张本地图片。 +

+ ) : null} {item.summary.headingChanges && ( item.summary.headingChanges.reordered || item.summary.headingChanges.added.length > 0 || diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index bae97491..634deb28 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -523,7 +523,13 @@ function DeepResearchProgressStep({ ? args.label : "正在撰写研究报告"; const detail = typeof args.detail === "string" ? args.detail.trim() : ""; + const thinking = typeof args.thinking === "string" ? args.thinking.trim() : ""; const active = args.status !== "complete"; + const [thinkingOpen, setThinkingOpen] = useState(false); + + useEffect(() => { + if (active && thinking) setThinkingOpen(true); + }, [active, thinking]); return ( )} + {thinking && ( + + + + + +

+ {active ? "模型思考内容正在更新" : "模型思考内容已完成"} +

+
+              {thinking}
+            
+
+
+ )}
); } diff --git a/frontend-web/src/core/document-rewrite/context.tsx b/frontend-web/src/core/document-rewrite/context.tsx index 638a3df5..1e7dbcbb 100644 --- a/frontend-web/src/core/document-rewrite/context.tsx +++ b/frontend-web/src/core/document-rewrite/context.tsx @@ -20,11 +20,17 @@ export type DocumentRewritePresentation = { instruction: string; style: string; modelName: string | null; + generateImages: boolean; + maxGeneratedImages: number; + generatedImageCount: number; + illustrationTarget: string; stage: DocumentRewriteStage; originalCharCount: number; writtenCharCount: number; /** Short model-generated plan, streamed while it understands the request. */ requirementsPlan: string; + /** Ephemeral thought trace. Never included in the Markdown draft or persisted job state. */ + thinkingText: string; warnings: string[]; summary: DocumentRewriteSummary | null; /** Kept in-memory for the line-level compare dialog; backend job hydration refills it. */ @@ -43,9 +49,14 @@ type CreatePresentationInput = Omit< DocumentRewritePresentation, | "id" | "stage" + | "generateImages" + | "maxGeneratedImages" + | "generatedImageCount" + | "illustrationTarget" | "originalCharCount" | "writtenCharCount" | "requirementsPlan" + | "thinkingText" | "warnings" | "summary" | "originalContent" @@ -60,6 +71,8 @@ type CreatePresentationInput = Omit< originalContent?: string; rewrittenContent?: string | null; requirementsPlan?: string; + generateImages?: boolean; + maxGeneratedImages?: number; }; type DocumentRewritePresentationContextValue = { @@ -94,9 +107,14 @@ export function DocumentRewritePresentationProvider({ ...input, id, stage: "snapshot", + generateImages: input.generateImages === true, + maxGeneratedImages: Math.min(Math.max(input.maxGeneratedImages ?? 2, 1), 4), + generatedImageCount: 0, + illustrationTarget: "", originalCharCount: 0, writtenCharCount: 0, requirementsPlan: input.requirementsPlan ?? "", + thinkingText: "", warnings: [], summary: null, originalContent: input.originalContent ?? "", diff --git a/frontend-web/src/core/streamdown/inline-think.ts b/frontend-web/src/core/streamdown/inline-think.ts new file mode 100644 index 00000000..b8bdc27e --- /dev/null +++ b/frontend-web/src/core/streamdown/inline-think.ts @@ -0,0 +1,90 @@ +/** + * Some OpenAI-compatible reasoning models write ``...`` into + * the ordinary text stream rather than a dedicated reasoning field. Markdown + * sandboxes must never render those fragments as document content. Callers + * can separately render the returned thinking part in a labelled UI trace. + * + * The filter is stateful because either tag can be split across arbitrary SSE + * deltas. Hold only a possible tag suffix, so confirmed Markdown continues + * to appear with the normal token-streaming cadence. + */ +export class InlineThinkTagFilter { + private static readonly OPEN_TAG = ""; + private static readonly CLOSE_TAG = ""; + + private insideThink = false; + private pending = ""; + + private possibleTagPrefixSize(content: string, tag: string) { + const lowered = content.toLowerCase(); + for (let size = Math.min(content.length, tag.length - 1); size > 0; size -= 1) { + if (lowered.slice(-size) === tag.slice(0, size)) return size; + } + return 0; + } + + pushParts(chunk: string): { text: string; thinking: string } { + if (!chunk) return { text: "", thinking: "" }; + + let remaining = this.pending + chunk; + this.pending = ""; + let visible = ""; + let thinking = ""; + + while (remaining) { + const tag = this.insideThink + ? InlineThinkTagFilter.CLOSE_TAG + : InlineThinkTagFilter.OPEN_TAG; + const tagIndex = remaining.toLowerCase().indexOf(tag); + if (tagIndex >= 0) { + if (this.insideThink) thinking += remaining.slice(0, tagIndex); + else visible += remaining.slice(0, tagIndex); + remaining = remaining.slice(tagIndex + tag.length); + this.insideThink = !this.insideThink; + continue; + } + + const suffixSize = this.possibleTagPrefixSize(remaining, tag); + const confirmed = suffixSize ? remaining.slice(0, -suffixSize) : remaining; + if (this.insideThink) thinking += confirmed; + else visible += confirmed; + this.pending = suffixSize ? remaining.slice(-suffixSize) : ""; + break; + } + + return { text: visible, thinking }; + } + + push(chunk: string) { + return this.pushParts(chunk).text; + } + + /** Flush a completed stream into its visible/thinking channels. */ + finishParts(): { text: string; thinking: string } { + if (this.insideThink) { + const thinking = this.pending; + this.insideThink = false; + this.pending = ""; + return { text: "", thinking }; + } + const tail = this.pending; + this.pending = ""; + return { text: tail, thinking: "" }; + } + + finish() { + return this.finishParts().text; + } + + /** Replace a rendered checkpoint while retaining the correct tag state. */ + replace(content: string) { + this.insideThink = false; + this.pending = ""; + return this.push(content); + } +} + +export function stripInlineThinkTags(content: string) { + const filter = new InlineThinkTagFilter(); + return filter.push(content) + filter.finish(); +} diff --git a/frontend-web/src/eightbf-roundtable/api/task-context.ts b/frontend-web/src/eightbf-roundtable/api/task-context.ts index 2d7021a6..280414a0 100644 --- a/frontend-web/src/eightbf-roundtable/api/task-context.ts +++ b/frontend-web/src/eightbf-roundtable/api/task-context.ts @@ -51,6 +51,27 @@ export interface EightBfTaskContextPayload { }; } +/** + * 外部任务系统在离线部署或本地联调时可能不可用。此回退仅用于页面展示与 + * 意图上下文,保持与岗位会商示例一致,避免左上任务卡退化成接口错误文本。 + */ +export function createEightBfFallbackTask(taskId: string): EightBfTask { + return { + id: taskId, + externalTaskId: taskId, + title: "暑期活动", + description: "围绕暑期活动的游泳夏令营开展宣传,结合线上线下渠道吸引目标人群参与,持续提升活动影响力。", + direction: "暑期活动", + overview: "面向家长群体传播暑期游泳夏令营活动信息,提升品牌曝光与触达声量。", + priority: "urgent", + taskPriority: "urgent", + targetDirection: "summer_activity", + taskStatus: "system_created", + source: "本系统创建", + raw: null, + }; +} + const TASK_DETAIL_PATH = "/task/{taskId}"; const DICTIONARY_TYPES = { priority: "task_priority", diff --git a/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx b/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx new file mode 100644 index 00000000..ec06e6f9 --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx @@ -0,0 +1,451 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { + Bot, + ChevronDown, + ChevronLeft, + Loader2, + Search, + Settings2, + UserRoundCog, +} from "lucide-react"; +import { toast } from "sonner"; + +import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel"; +import { useAgent, useAgents, type Agent } from "@/core/agents"; +import agentConfigLogo from "@/assets/position-roundtable/agent-config-logo.svg"; +import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg"; +import doubleArrowDecoration from "@/assets/position-roundtable/double-arrow-decoration.svg"; +import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput"; +import "@/position-roundtable/styles/intent-understanding.css"; +import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel"; +import { Step1Panel } from "@/roundtable-planning/components/Step1Panel"; +import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain"; +import { cn } from "@/lib/utils"; + +type IntentRecord = Record; + +export interface EightBfStep1PanelProps extends Step1PanelProps { + chain: BusinessChain | null; + canConfigureChain: boolean; + onConfigureChain: () => void; + selectedModel: string; + modelOptions: Array<{ label: string; value: string }>; + onSelectedModelChange: (model: string) => void; +} + +export function EightBfStep1Panel({ + chain, + canConfigureChain, + onConfigureChain, + selectedModel, + modelOptions, + onSelectedModelChange, + intentReady, + ...step1Props +}: EightBfStep1PanelProps) { + const [rightPanelOpen, setRightPanelOpen] = useState(true); + const [rightTab, setRightTab] = useState<"agents" | "results">("agents"); + + const openPanel = (tab: "agents" | "results") => { + if (rightPanelOpen && rightTab === tab) { + setRightPanelOpen(false); + return; + } + setRightTab(tab); + setRightPanelOpen(true); + }; + + return ( +
+ +
+ } + headerRightSlot={ +
+ + +
+ } + rightSidebar={ + + } + hideQuestionCount + inputArea={(inputProps) => ( + + )} + intentReady={intentReady} + /> +
+ ); +} + +export function EightBfStep1Sidebar({ + taskCard, + businessChain, + intentReady, +}: { + taskCard?: ReactNode; + businessChain?: ReactNode; + intentReady: object | null; +}) { + return ( +
+ {taskCard} + +
+ ); +} + +/** 第一、二步共用的意图结果面板,保持完全相同的展示结构和样式。 */ +export function EightBfIntentDelivery({ + intentReady, + businessChain, +}: { + intentReady: object | null; + businessChain?: ReactNode; +}) { + const sections = intentSections(intentReady); + return ( +
+ {sections.length > 0 && ( +
+
+
+
+ {sections.map((section) => ( +
+

{section.label}

+
    + {section.items.map((item) =>
  • {item}
  • )} +
+
+ ))} +
+
+ )} + {businessChain} +
+ ); +} + +/** 第二步不需要第一步的整列占位,只复用其任务卡和意图面板。 */ +export function EightBfStep2SidebarHeader({ + taskCard, + intentReady, +}: { + taskCard?: ReactNode; + intentReady: object | null; +}) { + return ( +
+ {taskCard} + +
+ ); +} + +export function EightBfHeaderBrand({ chainTitle }: { chainTitle?: string }) { + return ( +
+ +
+

8BF 多智能体会商

+

按业务链承接任务、对话与分析产物

+
+ + + {chainTitle ? "业务链已启动" : "等待业务链配置"} + +
+ ); +} + +export function EightBfHeaderChain({ chainTitle }: { chainTitle?: string }) { + return ( + + 当前业务链 + {chainTitle || "暂未配置"} + + ); +} + +function EightBfAgentWorkspace({ + tab, + chain, + intentReady, + canConfigureChain, + onConfigureChain, + onCollapse, +}: { + tab: "agents" | "results"; + chain: BusinessChain | null; + intentReady: object | null; + canConfigureChain: boolean; + onConfigureChain: () => void; + onCollapse: () => void; +}) { + const { agents, isLoading } = useAgents(); + const seats = chain?.seats ?? []; + const [selectedAgentId, setSelectedAgentId] = useState(null); + const { agent: selectedAgent, isLoading: selectedAgentLoading } = useAgent(selectedAgentId); + + useEffect(() => { + if (selectedAgentId && !seats.some((seat) => seat.agent_id === selectedAgentId)) { + setSelectedAgentId(null); + } + }, [seats, selectedAgentId]); + + const seatAgents = useMemo(() => { + const agentById = new Map(agents.map((agent) => [agent.id, agent])); + return seats.map((seat) => ({ seat, agent: agentById.get(seat.agent_id) ?? null })); + }, [agents, seats]); + const selectedSeat = seats.find((seat) => seat.agent_id === selectedAgentId) ?? null; + + if (tab === "results") { + return ( +
+
+ 分析结果 + +
+
+ +
+
+ ); + } + + if (selectedAgentId) { + return ( +
+
+ + +
+
+ {selectedAgentLoading ? ( +
+ 正在读取智能体配置… +
+ ) : ( + toast.info("请在中间对话区向任务理解智能体发起问题")} + showRecentChats={false} + avatarSrc={agentConfigLogo} + positionName={chain?.title || "8BF 业务链"} + skillsIcon={} + skillsTitle="技能列表" + showCommonQuestions + panelTitle="智能体配置" + embedded + hideStatusBadges + /> + )} +
+
+ ); + } + + return ( +
+
+
+

智能体配置

+

{chain?.title ? `业务链「${chain.title}」已配置的智能体` : "请先配置 8BF 业务链"}

+
+
+ {canConfigureChain && ( + + )} + +
+
+
+ + 业务链智能体 + {seatAgents.length} 个 +
+
+ {isLoading && seats.length > 0 ? ( +
正在加载智能体…
+ ) : seats.length === 0 ? ( +
当前业务链未配置智能体
+ ) : ( + seatAgents.map(({ seat, agent }, index) => ( + setSelectedAgentId(seat.agent_id)} + /> + )) + )} +
+
+ ); +} + +function EightBfAgentRow({ + seat, + agent, + index, + onSelect, +}: { + seat: ChainSeat; + agent: Agent | null; + index: number; + onSelect: () => void; +}) { + const name = agent?.name || seat.name || "未命名智能体"; + const description = agent?.description || seat.description || "查看该智能体的模型、技能与常用问题配置"; + return ( + + ); +} + +function EightBfIntentResults({ intentReady }: { intentReady: object | null }) { + const sections = intentSections(intentReady); + if (sections.length === 0) { + return

完成意图识别后,将在这里展示任务意图与关键约束。

; + } + return ( +
+ {sections.map((section) => ( +
+

{section.label}

+
    {section.items.map((item) =>
  • {item}
  • )}
+
+ ))} +
+ ); +} + +function intentSections(intentReady: object | null): Array<{ label: string; items: string[] }> { + const source = intentReady && typeof intentReady === "object" ? intentReady as IntentRecord : null; + if (!source) return []; + const entries = [ + ["核心目标", arrayValue(source.coreGoals, source.objective)], + ["风险提示", arrayValue(source.riskWarnings, source.constraints)], + ["关键要点", arrayValue(source.keyPoints, source.assumptions)], + ["战略意义", arrayValue(source.strategicSignificance)], + ] as const; + return entries + .filter(([, items]) => items.length > 0) + .map(([label, items]) => ({ label, items })); +} + +function intentSectionTone(label: string) { + switch (label) { + case "核心目标": + return "position-roundtable-intent-delivery-section--goals"; + case "风险提示": + return "position-roundtable-intent-delivery-section--constraints"; + case "关键要点": + return "position-roundtable-intent-delivery-section--purpose"; + case "战略意义": + return "position-roundtable-intent-delivery-section--significance"; + default: + return "position-roundtable-intent-delivery-section--significance"; + } +} + +function arrayValue(...values: unknown[]): string[] { + for (const value of values) { + if (Array.isArray(value)) { + const items = value.map((item) => String(item).trim()).filter(Boolean); + if (items.length > 0) return items; + } + if (typeof value === "string" && value.trim()) return [value.trim()]; + } + return []; +} diff --git a/frontend-web/src/eightbf-roundtable/components/EightBfTaskCard.tsx b/frontend-web/src/eightbf-roundtable/components/EightBfTaskCard.tsx index 48e70eb5..f3d0a09f 100644 --- a/frontend-web/src/eightbf-roundtable/components/EightBfTaskCard.tsx +++ b/frontend-web/src/eightbf-roundtable/components/EightBfTaskCard.tsx @@ -1,6 +1,5 @@ -import { Clipboard, Loader2 } from "lucide-react"; - import { cn } from "@/lib/utils"; +import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg"; import { dictionaryText, type EightBfDictionaryOption, @@ -16,6 +15,8 @@ interface EightBfTaskCardProps { }; loading?: boolean; error?: string | null; + /** 第二步左栏使用,与“智能体配置”同款图标收起整列。 */ + onCollapse?: () => void; } export function EightBfTaskCard({ @@ -23,36 +24,74 @@ export function EightBfTaskCard({ dictionaries, loading = false, error, + onCollapse, }: EightBfTaskCardProps) { const priority = dictionaryText(dictionaries.priority, task?.taskPriority); const direction = dictionaryText(dictionaries.direction, task?.targetDirection, "alias"); const status = dictionaryText(dictionaries.status, task?.taskStatus); - const tags = [priority, direction, status].filter(Boolean); + // 外部任务接口不可用时,卡片使用与岗位会商相同的示例任务;标签也必须 + // 保持可读,而不能暴露内部状态码。 + const tags = task?.raw === null + ? ["紧急", "本系统创建", "暑期活动"] + : [priority, status, direction].filter(Boolean); return ( -
-
- -

当前任务

- {loading && } -
+
+
+ {onCollapse && ( + + )} +

+ {task?.title || (task?.id ? `任务 ${task.id}` : "等待任务信息")} +

-
-

- {task?.title || (task?.id ? `任务 ${task.id}` : "等待任务信息")} -

{tags.length > 0 && ( -
- {tags.map((tag) => {tag})} +
+ {tags.map((tag, index) => ( + 1 && "border-[#a2e1f9] bg-[rgba(162,225,249,0.3)] leading-[18px] text-[#a2e1f9]", + ].filter(Boolean).join(" ")} + > + {tag} + + ))}
)} -

+

{task?.description || task?.overview || (loading ? "正在读取任务详情…" : "暂无任务描述")}

{error &&

{error}

} -
+
); } diff --git a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx index 1696ea51..e66a0331 100644 --- a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx +++ b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx @@ -1,11 +1,17 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; -import { Loader2, Save, Settings2, X } from "lucide-react"; +import { Loader2, Save, X } from "lucide-react"; import { getAccountDisplayName, getStoredAuth } from "@/core/auth"; import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard"; +import { + EightBfStep1Panel, + EightBfStep1Sidebar, + EightBfStep2SidebarHeader, +} from "@/eightbf-roundtable/components/EightBfStep1Workspace"; import { buildEightBfTaskContext, + createEightBfFallbackTask, fetchEightBfTask, fetchEightBfTaskDictionaries, normalizeEightBfTask, @@ -100,7 +106,9 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa }) .catch((error) => { if (cancelled) return; - setTaskError(error instanceof Error ? error.message : "任务详情加载失败"); + console.warn("[8bf] 任务详情请求失败,使用页面示例任务", error); + setTask(createEightBfFallbackTask(taskId)); + setTaskError(null); }) .finally(markRequestFinished); void fetchEightBfTaskDictionaries() @@ -169,18 +177,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa } }, [canConfigureChain, chains, selectedChainId]); + const openChainSettings = useCallback(() => { + if (canConfigureChain) setIsSettingsOpen(true); + }, [canConfigureChain]); + const toolbarSlot = ( <> - {canConfigureChain && ( - - )} {canConfigureChain && isSettingsOpen && (
) : undefined} toolbarSlot={toolbarSlot} + hideHeaderSaveDraft + hideHeaderSettings + newTaskLabel="新建会话" + hideDefaultStep1RightSidebar + renderStep1={(props) => ( + + )} + renderStep1LeftSidebar={({ intentReady }) => ( + + ) : undefined} + businessChain={ + + } + /> + )} + renderStep2LeftSidebarHeader={({ intentReady, onCollapse }) => ( + + ) : undefined} + /> + )} entryRailBusinessChainSlot={ svg:last-child { - margin-left: auto; - color: #0f766e; -} - -.eightbf-roundtable .eightbf-roundtable__task-card-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - border-radius: 7px; - background: #0f766e; - color: #fff; -} - -.eightbf-roundtable .eightbf-roundtable__task-card-body { - min-width: 0; - margin-top: 11px; -} - -.eightbf-roundtable .eightbf-roundtable__task-card-title { - margin: 0; - color: #0f172a; - font-size: 14px; - font-weight: 700; - line-height: 1.55; - overflow-wrap: anywhere; } .eightbf-roundtable .eightbf-roundtable__task-card-tags { - display: flex; - flex-wrap: wrap; - gap: 5px; - margin-top: 8px; + margin-top: 12px; } .eightbf-roundtable .eightbf-roundtable__task-card-tags span { - border: 1px solid #c7e6df; - border-radius: 999px; - background: #ecfdf5; - color: #0f766e; - padding: 2px 7px; - font-size: 10px; - line-height: 1.4; + border-radius: 2px; } .eightbf-roundtable .eightbf-roundtable__task-card-description { - display: -webkit-box; - margin: 9px 0 0; - color: #475569; - font-size: 11px; - line-height: 1.65; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 5; - overflow-wrap: anywhere; - white-space: pre-wrap; + margin: 12px 0 0; } .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #94a3b8; } -.eightbf-roundtable .eightbf-roundtable__task-card-error { margin: 8px 0 0; color: #b91c1c; font-size: 10px; line-height: 1.5; } +.eightbf-roundtable .eightbf-roundtable__task-card-error { margin: 8px 0 0; color: #fca5a5; font-size: 12px; line-height: 1.5; } -.dark .eightbf-roundtable .eightbf-roundtable__task-card { - border-color: rgb(92 156 219 / 45%); - background: linear-gradient(145deg, #0b315d 0%, #08264b 100%); - box-shadow: 0 8px 24px rgb(0 0 0 / 18%); +.dark .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #8fa8c5; } + +/* + * 8BF 第一阶段直接采用「岗位多智能体会商」意图工作台的三栏尺寸和深蓝表面。 + * 专属类只改变 8BF;岗位会商原有页面继续由 intent-understanding.css 管理。 + */ +.eightbf-roundtable .eightbf-roundtable__header-brand { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 12px; } -.dark .eightbf-roundtable .eightbf-roundtable__task-card-heading, -.dark .eightbf-roundtable .eightbf-roundtable__task-card-title { color: #f1f7ff; } -.dark .eightbf-roundtable .eightbf-roundtable__task-card-heading > svg:last-child { color: #65d6c6; } -.dark .eightbf-roundtable .eightbf-roundtable__task-card-tags span { border-color: rgb(101 214 198 / 40%); background: rgb(15 118 110 / 24%); color: #9ef0df; } -.dark .eightbf-roundtable .eightbf-roundtable__task-card-description { color: #c3d4e8; } -.dark .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #8fa8c5; } -.dark .eightbf-roundtable .eightbf-roundtable__task-card-error { color: #fca5a5; } +.eightbf-roundtable .eightbf-roundtable__header-brand-icon { + display: inline-flex; + width: 42px; + height: 42px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border-radius: 14px; + background: rgb(43 143 254 / 16%); + color: #2b8ffe; +} -/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */ +.eightbf-roundtable .eightbf-roundtable__header-brand-icon svg { width: 23px; height: 23px; } +.eightbf-roundtable .eightbf-roundtable__header-brand h1 { margin: 0; color: #1e293b; font-size: 16px; font-weight: 700; } +.eightbf-roundtable .eightbf-roundtable__header-brand p { margin: 2px 0 0; color: #64748b; font-size: 12px; } +.eightbf-roundtable .eightbf-roundtable__header-status { + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: 8px; + border: 1px solid rgb(16 185 129 / 30%); + border-radius: 999px; + background: rgb(16 185 129 / 8%); + color: #047857; + padding: 5px 10px; + font-size: 12px; + font-weight: 700; +} + +.eightbf-roundtable .eightbf-roundtable__header-chain { + display: inline-flex; + min-width: 230px; + max-width: 360px; + align-items: center; + gap: 8px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #fff; + color: #64748b; + padding: 8px 10px; + font-size: 12px; +} +.eightbf-roundtable .eightbf-roundtable__header-chain strong { min-width: 0; flex: 1; overflow: hidden; color: #334155; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.eightbf-roundtable .eightbf-roundtable__header-chain svg { width: 16px; height: 16px; flex: 0 0 auto; } + +/* 参考工作台的直角圆角层级:内容面、消息区与输入框均为 4px。 */ +.eightbf-roundtable #middle-viewport { border-radius: 4px; } +.eightbf-roundtable .position-roundtable-intent-panel > div:first-child, +.eightbf-roundtable .position-roundtable-intent-composer-frame, +.eightbf-roundtable .position-roundtable-intent-config-pane { border-radius: 4px; } +.eightbf-roundtable .position-roundtable-intent-panel > div:first-child { border-radius: 4px 4px 0 0; } + +.eightbf-roundtable .eightbf-roundtable__step1-sidebar { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 12px; + overflow: hidden; + padding-right: 0; +} + +/* 8BF 首步左栏不允许横向滚动;流程图的横向超出由画布自身裁切。 */ +.eightbf-roundtable[data-roundtable-step="1"] #left-sidebar, +.eightbf-roundtable .eightbf-roundtable__step1-sidebar, +.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar { + overflow-x: hidden !important; +} + +.eightbf-roundtable[data-roundtable-step="1"] #left-sidebar::-webkit-scrollbar:horizontal, +.eightbf-roundtable .eightbf-roundtable__step1-sidebar::-webkit-scrollbar:horizontal, +.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar::-webkit-scrollbar:horizontal { + display: none; + height: 0; +} + +.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar { + min-height: 0; + overflow-y: auto; +} + +.eightbf-roundtable .eightbf-roundtable__intent-sidebar-empty { + margin: 0; + padding: 12px 16px 18px; + color: #aaceff; + font-size: 14px; + line-height: 22px; +} + +/* The flow is embedded into the intent surface: only its own header line and + dotted canvas are visible. The surrounding intent panel owns the border. */ +.eightbf-roundtable .position-roundtable-intent-delivery-flow { + /* Reference flow has horizontal gutters. Reduce the width by those gutters + so the right-side magnifier is never clipped by the left-rail crop. */ + width: calc(100% - 24px) !important; + height: 580px !important; + min-height: 580px !important; + flex-basis: 580px !important; +} +.eightbf-roundtable .position-roundtable-intent-delivery-flow > div:first-child { + margin: 0 0 12px !important; +} + +.eightbf-roundtable .eightbf-roundtable__agent-list-workspace { + display: flex; + height: 100%; + min-height: 0; + flex-direction: column; + background: #032650; +} +.eightbf-roundtable .eightbf-roundtable__agent-list-workspace > header { + display: flex; + min-height: 64px; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid rgb(43 143 254 / 35%); +} +.eightbf-roundtable .eightbf-roundtable__agent-list-workspace h3 { margin: 0; color: #fff; font-size: 17px; font-weight: 500; } +.eightbf-roundtable .eightbf-roundtable__agent-list-workspace p { margin: 4px 0 0; color: #aaceff; font-size: 11px; line-height: 1.45; } +.eightbf-roundtable .eightbf-roundtable__configure-chain-button { + display: inline-flex; + height: 28px; + align-items: center; + gap: 4px; + border: 1px solid rgb(115 200 255 / 52%); + border-radius: 4px; + background: rgb(0 98 217 / 22%); + color: #dceaff; + padding: 0 8px; + font-size: 11px; +} +.eightbf-roundtable .eightbf-roundtable__configure-chain-button:hover { background: rgb(0 98 217 / 40%); } +.eightbf-roundtable .eightbf-roundtable__agent-list-search { + display: flex; + align-items: center; + gap: 7px; + margin: 12px; + border: 1px solid rgb(43 143 254 / 36%); + border-radius: 4px; + background: rgb(0 12 28 / 20%); + color: #aaceff; + padding: 8px 9px; + font-size: 12px; +} +.eightbf-roundtable .eightbf-roundtable__agent-list-search span:last-child { margin-left: auto; border-radius: 999px; background: #0b386d; padding: 1px 6px; color: #83c1ff; font-size: 10px; } +.eightbf-roundtable .eightbf-roundtable__agent-list-scroll { min-height: 0; flex: 1; overflow-y: auto; padding: 0 12px 12px; } +.eightbf-roundtable .eightbf-roundtable__agent-list-loading, +.eightbf-roundtable .eightbf-roundtable__agent-list-empty { display: flex; min-height: 100px; align-items: center; justify-content: center; gap: 7px; color: #8aa0c4; font-size: 12px; text-align: center; } +.eightbf-roundtable .eightbf-roundtable__agent-list-row { + display: flex; + width: 100%; + align-items: center; + gap: 10px; + border-bottom: 1px solid rgb(43 143 254 / 21%); + padding: 11px 2px; + color: inherit; + text-align: left; + transition: background-color .16s ease, transform .16s ease; +} +.eightbf-roundtable .eightbf-roundtable__agent-list-row:hover { background: rgb(0 98 217 / 13%); transform: translateX(2px); } +.eightbf-roundtable .eightbf-roundtable__agent-list-avatar { display: inline-flex; width: 34px; height: 34px; flex: 0 0 auto; align-items: center; justify-content: center; border: 1px solid #007e82; border-radius: 7px; background: #00306a; color: #73c8ff; } +.eightbf-roundtable .eightbf-roundtable__agent-list-avatar svg { width: 18px; height: 18px; } +.eightbf-roundtable .eightbf-roundtable__agent-list-row strong, +.eightbf-roundtable .eightbf-roundtable__agent-list-row small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.eightbf-roundtable .eightbf-roundtable__agent-list-row strong { color: #fff; font-size: 13px; font-weight: 600; } +.eightbf-roundtable .eightbf-roundtable__agent-list-row small { margin-top: 3px; color: #aaceff; font-size: 11px; } +.eightbf-roundtable .eightbf-roundtable__agent-list-index { color: #75b7f5; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; } +.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar { display: flex; height: 48px; align-items: center; justify-content: space-between; border-bottom: 1px solid rgb(43 143 254 / 35%); padding: 0 12px; } +.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child { display: inline-flex; align-items: center; gap: 2px; color: #aaceff; font-size: 12px; } +.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child:hover { color: #fff; } +.eightbf-roundtable .eightbf-roundtable__intent-results-scroll { padding: 12px; } +.eightbf-roundtable .eightbf-roundtable__intent-results-empty { margin: 0; color: #aaceff; font-size: 13px; line-height: 1.8; } +.eightbf-roundtable .eightbf-roundtable__intent-results-list { display: grid; gap: 10px; } +.eightbf-roundtable .eightbf-roundtable__intent-results-list section { border: 1px solid rgb(43 143 254 / 32%); border-radius: 4px; background: rgb(0 12 28 / 20%); padding: 12px; } +.eightbf-roundtable .eightbf-roundtable__intent-results-list h3 { margin: 0 0 8px; color: #a2e1f9; font-size: 14px; font-weight: 500; } +.eightbf-roundtable .eightbf-roundtable__intent-results-list ul { margin: 0; padding-left: 17px; color: #dceaff; font-size: 12px; line-height: 1.8; } + +/* Screenshot-equivalent dark workbench shell. */ +.dark .eightbf-roundtable { background: #061b3b; color: #e7f0ff; } +.dark .eightbf-roundtable > header { min-height: 64px; border-color: rgb(43 143 254 / 45%); background: #032650; box-shadow: none; } +.dark .eightbf-roundtable .eightbf-roundtable__header-brand h1 { color: #e7f0ff; } +.dark .eightbf-roundtable .eightbf-roundtable__header-brand p { color: #8aa0c4; } +.dark .eightbf-roundtable .eightbf-roundtable__header-status { border-color: rgb(101 214 198 / 45%); background: rgb(15 118 110 / 24%); color: #9ef0df; } +.dark .eightbf-roundtable .eightbf-roundtable__header-chain { border-color: rgb(143 196 255 / 44%); background: #082b57; color: #aaceff; } +.dark .eightbf-roundtable .eightbf-roundtable__header-chain strong { color: #e7f0ff; } +.dark .eightbf-roundtable #new-task-btn { border: 1px solid rgb(115 200 255 / 45%); border-radius: 4px; background: transparent; color: #e7f0ff; box-shadow: none; } +.dark .eightbf-roundtable #new-task-btn:hover { background: rgb(0 98 217 / 24%); } +.dark .eightbf-roundtable[data-roundtable-step="1"] > main { gap: 0; padding: 12px 0; } +.dark .eightbf-roundtable[data-roundtable-step="1"] #middle-viewport { border-color: rgb(43 143 254 / 50%); border-radius: 4px; background: #032650; box-shadow: none; } +.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { + width: 374px; + overflow: hidden; + border: 0; + border-radius: 0; + background: transparent; + padding: 0 12px; +} +.dark .eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar { + border-color: rgb(0 110 254 / 50%); + background: #032650; +} + +@media (max-width: 1280px) { + .eightbf-roundtable .eightbf-roundtable__header-chain { min-width: 180px; max-width: 220px; } + .dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { width: 326px; } + .eightbf-roundtable .position-roundtable-intent-config-pane { --prt-intent-config-width: 320px; } +} + +@media (max-width: 1024px) { + .dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { width: 294px; } + .eightbf-roundtable .eightbf-roundtable__header-status, + .eightbf-roundtable .eightbf-roundtable__header-brand p { display: none; } + .eightbf-roundtable .eightbf-roundtable__header-chain { display: none; } + .eightbf-roundtable .position-roundtable-intent-config-pane { --prt-intent-config-width: min(360px, 42vw); } +} diff --git a/frontend-web/src/eightbf-roundtable/styles/step2.css b/frontend-web/src/eightbf-roundtable/styles/step2.css index 0abc75de..a9481942 100644 --- a/frontend-web/src/eightbf-roundtable/styles/step2.css +++ b/frontend-web/src/eightbf-roundtable/styles/step2.css @@ -14,4 +14,67 @@ .eightbf-roundtable .eightbf-roundtable__chain-settings-button:hover { background: #115e59; } -/* 可在此继续覆盖会商消息、席位卡片、流程节点及展开按钮的样式。 */ +/* 展开时工作区紧贴左栏;收起时恢复通用的 16px 间距,避免窄栏贴着内容区。 */ +.dark .eightbf-roundtable[data-roundtable-step="2"] > main[data-step2-sidebar-expanded="true"] { + gap: 0; +} + +.dark .eightbf-roundtable[data-roundtable-step="2"] > main[data-step2-sidebar-expanded="false"] { + gap: 10px; +} + +/* 第二步左栏顶部直接复用第一步任务卡和意图理解区,但不复用第一步整列的 + flex: 1 行为;内容高度随意图实际内容收拢,剩余内容交给左栏统一滚动。 */ +.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable__step2-sidebar-header { + display: flex; + flex: 0 0 auto; + flex-direction: column; + gap: 12px; + min-height: 0; + overflow: visible; +} + +.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable__step2-sidebar-header + .eightbf-roundtable__intent-delivery-sidebar { + flex: 0 0 auto; + overflow: visible; +} + +/* 角色面板与列表均依内容撑开,再交由左栏的单一滚动容器承载;否则取消 + 内层滚动后,角色项会从固定高度的父面板溢出并覆盖下方内容。 */ +.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .eightbf-roundtable__step2-sidebar-header + div { + flex: 0 0 auto !important; + min-height: 0 !important; +} + +.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .custom-scrollbar { + flex: 0 0 auto; + overflow: visible; + padding-right: 0; +} + +/* 8BF 第二步沿用第一步的 4px 面板语言,避免通用会商的 rounded-2xl 混入。 */ +.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .rounded-2xl { + border-radius: 4px !important; +} + +/* 展开态与第一步左栏保持同一宽度和内边距。注意不能无条件锁定宽度,否则会 + 覆盖收起态的 w-[56px],使顶部图标按钮看起来无效。 */ +.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { + width: 374px; + padding: 0 12px; +} + +/* 第二步输入区直接复用第一步 PositionIntentInput;共识动作也使用其 + #enter-roundtable-btn 规则,保证和「确认意图并启动业务链」完全同款。 */ +.dark .eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-input { + flex: 0 0 auto; +} + +@media (max-width: 1280px) { + .dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 326px; } +} + +@media (max-width: 1024px) { + .dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 294px; } +} diff --git a/frontend-web/src/open-canvas/api/rewrite.ts b/frontend-web/src/open-canvas/api/rewrite.ts index 18ab5900..4d471fed 100644 --- a/frontend-web/src/open-canvas/api/rewrite.ts +++ b/frontend-web/src/open-canvas/api/rewrite.ts @@ -119,6 +119,10 @@ export interface DocumentRewriteRequest { instruction: string; style?: DocumentRewriteStyle; modelName?: string; + /** Generate locally saved illustration assets after the prose rewrite. */ + generateImages?: boolean; + /** Bounded server-side to 1–4. Ignored unless generateImages is true. */ + maxGeneratedImages?: number; expectedHash?: string; } @@ -140,6 +144,8 @@ export interface DocumentRewriteSummary { }; requestedOptimizations?: string[]; validationWarnings?: string[]; + generatedImageCount?: number; + requestedImageCount?: number; } export type DocumentRewriteStreamEvent = @@ -156,6 +162,15 @@ export type DocumentRewriteStreamEvent = | { type: "rewrite_delta"; delta: string; charCount: number } /** Durable full-draft recovery point used after an SSE reconnect. */ | { type: "rewrite_checkpoint"; content: string; charCount: number } + | { type: "illustration_started"; section: string; position: number; total: number } + | { + type: "illustration_generated"; + section: string; + position: number; + total: number; + generatedCount?: number; + } + | { type: "illustration_completed"; generatedCount: number; requestedCount: number; warnings: string[] } | { type: "validation_started" } | { type: "validation_completed"; passed: boolean; warnings: string[] } | { type: "comparison_ready"; summary: DocumentRewriteSummary } @@ -177,6 +192,8 @@ export interface DurableDocumentRewriteJob { instruction: string; style: DocumentRewriteStyle; modelName: string | null; + generateImages: boolean; + maxGeneratedImages: number; originalContent: string; }; } @@ -229,6 +246,30 @@ async function readDocumentRewriteEvent( content: String(parsed.content ?? ""), charCount: Number(parsed.charCount ?? 0), }; + case "illustration_started": + return { + type: "illustration_started", + section: String(parsed.section ?? ""), + position: Number(parsed.position ?? 0), + total: Number(parsed.total ?? 0), + }; + case "illustration_generated": + return { + type: "illustration_generated", + section: String(parsed.section ?? ""), + position: Number(parsed.position ?? 0), + total: Number(parsed.total ?? 0), + generatedCount: Number(parsed.generatedCount ?? parsed.position ?? 0), + }; + case "illustration_completed": + return { + type: "illustration_completed", + generatedCount: Number(parsed.generatedCount ?? 0), + requestedCount: Number(parsed.requestedCount ?? 0), + warnings: Array.isArray(parsed.warnings) + ? parsed.warnings.filter((item): item is string => typeof item === "string") + : [], + }; case "validation_started": return { type: "validation_started" }; case "validation_completed": @@ -362,6 +403,8 @@ export async function listDurableDocumentRewrites( instruction: String(request.instruction ?? ""), style: (request.style ?? "professional") as DocumentRewriteStyle, modelName: typeof request.modelName === "string" ? request.modelName : null, + generateImages: request.generateImages === true, + maxGeneratedImages: Number(request.maxGeneratedImages ?? 2), originalContent: String(request.originalContent ?? ""), }, }]; diff --git a/frontend-web/src/open-canvas/hooks/useDocumentRewrite.ts b/frontend-web/src/open-canvas/hooks/useDocumentRewrite.ts index 2612d535..3bde9ac4 100644 --- a/frontend-web/src/open-canvas/hooks/useDocumentRewrite.ts +++ b/frontend-web/src/open-canvas/hooks/useDocumentRewrite.ts @@ -9,12 +9,14 @@ import { cancelDurableDocumentRewrite, streamDocumentRewrite, } from "../api/rewrite"; +import { InlineThinkTagFilter, stripInlineThinkTags } from "@/core/streamdown/inline-think"; export type DocumentRewriteStage = | "idle" | "snapshot" | "requirements" | "writing" + | "illustrating" | "validation" | "committing" | "completed" @@ -27,6 +29,8 @@ export interface DocumentRewriteRun { draft: string; /** Live, compact execution plan produced before Markdown generation starts. */ requirementsPlan: string; + /** Ephemeral model reasoning, deliberately kept out of the Markdown draft. */ + thinkingText: string; originalCharCount: number; writtenCharCount: number; fileName: string; @@ -41,6 +45,7 @@ const INITIAL_RUN: DocumentRewriteRun = { stage: "idle", draft: "", requirementsPlan: "", + thinkingText: "", originalCharCount: 0, writtenCharCount: 0, fileName: "", @@ -67,6 +72,10 @@ export function useDocumentRewrite() { const frameRef = useRef(null); const pendingRequirementsDeltaRef = useRef(""); const requirementsFrameRef = useRef(null); + const pendingThinkingRef = useRef(""); + const thinkingFrameRef = useRef(null); + const draftThinkFilterRef = useRef(new InlineThinkTagFilter()); + const requirementsThinkFilterRef = useRef(new InlineThinkTagFilter()); const flushDelta = useCallback(() => { if (frameRef.current !== null) { @@ -86,27 +95,6 @@ export function useDocumentRewrite() { })); }, []); - const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => { - if (!delta) return; - pendingDeltaRef.current += delta; - pendingWrittenCharCountRef.current = writtenCharCount; - if (frameRef.current !== null) return; - frameRef.current = requestAnimationFrame(() => { - frameRef.current = null; - const pending = pendingDeltaRef.current; - const pendingWrittenCharCount = pendingWrittenCharCountRef.current; - pendingDeltaRef.current = ""; - pendingWrittenCharCountRef.current = null; - if (!pending) return; - setRun((previous) => ({ - ...previous, - stage: "writing", - draft: previous.draft + pending, - ...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}), - })); - }); - }, []); - const flushRequirements = useCallback(() => { if (requirementsFrameRef.current !== null) { cancelAnimationFrame(requirementsFrameRef.current); @@ -124,9 +112,35 @@ export function useDocumentRewrite() { })); }, []); - const enqueueRequirementsDelta = useCallback((delta: string) => { + const flushThinking = useCallback(() => { + if (thinkingFrameRef.current !== null) { + cancelAnimationFrame(thinkingFrameRef.current); + thinkingFrameRef.current = null; + } + const delta = pendingThinkingRef.current; + pendingThinkingRef.current = ""; if (!delta) return; - pendingRequirementsDeltaRef.current += delta; + setRun((previous) => ({ ...previous, thinkingText: previous.thinkingText + delta })); + }, []); + + const enqueueThinking = useCallback((delta: string) => { + if (!delta) return; + pendingThinkingRef.current += delta; + if (thinkingFrameRef.current !== null) return; + thinkingFrameRef.current = requestAnimationFrame(() => { + thinkingFrameRef.current = null; + const pending = pendingThinkingRef.current; + pendingThinkingRef.current = ""; + if (!pending) return; + setRun((previous) => ({ ...previous, thinkingText: previous.thinkingText + pending })); + }); + }, []); + + const enqueueRequirementsDelta = useCallback((delta: string) => { + const { text: visibleDelta, thinking } = requirementsThinkFilterRef.current.pushParts(delta); + enqueueThinking(thinking); + if (!visibleDelta) return; + pendingRequirementsDeltaRef.current += visibleDelta; if (requirementsFrameRef.current !== null) return; requirementsFrameRef.current = requestAnimationFrame(() => { requirementsFrameRef.current = null; @@ -141,7 +155,30 @@ export function useDocumentRewrite() { requirementsPlan: previous.requirementsPlan + pending, })); }); - }, []); + }, [enqueueThinking]); + + const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => { + const { text: visibleDelta, thinking } = draftThinkFilterRef.current.pushParts(delta); + enqueueThinking(thinking); + if (!visibleDelta) return; + pendingDeltaRef.current += visibleDelta; + pendingWrittenCharCountRef.current = writtenCharCount; + if (frameRef.current !== null) return; + frameRef.current = requestAnimationFrame(() => { + frameRef.current = null; + const pending = pendingDeltaRef.current; + const pendingWrittenCharCount = pendingWrittenCharCountRef.current; + pendingDeltaRef.current = ""; + pendingWrittenCharCountRef.current = null; + if (!pending) return; + setRun((previous) => ({ + ...previous, + stage: "writing", + draft: previous.draft + pending, + ...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}), + })); + }); + }, [enqueueThinking]); const cancel = useCallback(() => { controllerRef.current?.abort(); @@ -155,12 +192,13 @@ export function useDocumentRewrite() { } flushDelta(); flushRequirements(); + flushThinking(); setRun((previous) => previous.stage === "completed" ? previous : { ...previous, stage: "cancelled", error: "已停止全文改写,原文件未被修改。" }, ); - }, [flushDelta, flushRequirements]); + }, [flushDelta, flushRequirements, flushThinking]); const reset = useCallback(() => { controllerRef.current?.abort(); @@ -169,6 +207,9 @@ export function useDocumentRewrite() { pendingDeltaRef.current = ""; pendingWrittenCharCountRef.current = null; pendingRequirementsDeltaRef.current = ""; + pendingThinkingRef.current = ""; + draftThinkFilterRef.current = new InlineThinkTagFilter(); + requirementsThinkFilterRef.current = new InlineThinkTagFilter(); if (frameRef.current !== null) { cancelAnimationFrame(frameRef.current); frameRef.current = null; @@ -177,6 +218,10 @@ export function useDocumentRewrite() { cancelAnimationFrame(requirementsFrameRef.current); requirementsFrameRef.current = null; } + if (thinkingFrameRef.current !== null) { + cancelAnimationFrame(thinkingFrameRef.current); + thinkingFrameRef.current = null; + } setRun(INITIAL_RUN); }, []); @@ -202,6 +247,9 @@ export function useDocumentRewrite() { pendingDeltaRef.current = ""; pendingWrittenCharCountRef.current = null; pendingRequirementsDeltaRef.current = ""; + pendingThinkingRef.current = ""; + draftThinkFilterRef.current = new InlineThinkTagFilter(); + requirementsThinkFilterRef.current = new InlineThinkTagFilter(); setRun({ ...INITIAL_RUN, stage: "snapshot", @@ -236,28 +284,36 @@ export function useDocumentRewrite() { case "requirements_delta": enqueueRequirementsDelta(event.delta); break; + case "thinking": + enqueueThinking(event.chunk); + break; case "requirements_checkpoint": flushRequirements(); setRun((previous) => ({ ...previous, stage: "requirements", - requirementsPlan: event.content, + requirementsPlan: requirementsThinkFilterRef.current.replace(event.content), })); break; case "requirements_completed": flushRequirements(); - setRun((previous) => ({ - ...previous, - stage: "requirements", - requirementsPlan: event.content, - })); + { + const requirementsPlan = + requirementsThinkFilterRef.current.replace(event.content) + + requirementsThinkFilterRef.current.finish(); + setRun((previous) => ({ + ...previous, + stage: "requirements", + requirementsPlan, + })); + } break; case "requirements_resolved": flushRequirements(); setRun((previous) => ({ ...previous, stage: "writing", - ...(event.plan ? { requirementsPlan: event.plan } : {}), + ...(event.plan ? { requirementsPlan: stripInlineThinkTags(event.plan) } : {}), })); break; case "rewrite_delta": @@ -276,13 +332,20 @@ export function useDocumentRewrite() { setRun((previous) => ({ ...previous, stage: "writing", - draft: event.content, - writtenCharCount: event.charCount, + draft: draftThinkFilterRef.current.replace(event.content), + writtenCharCount: stripInlineThinkTags(event.content).replace(/\s/g, "").length, })); break; + case "illustration_started": + case "illustration_generated": + case "illustration_completed": + flushDelta(); + setRun((previous) => ({ ...previous, stage: "illustrating" })); + break; case "validation_started": flushDelta(); flushRequirements(); + flushThinking(); setRun((previous) => ({ ...previous, stage: "validation" })); break; case "validation_completed": @@ -300,17 +363,23 @@ export function useDocumentRewrite() { case "committed": flushDelta(); flushRequirements(); - setRun((previous) => ({ - ...previous, - draft: event.content, - writtenCharCount: event.content.replace(/\s/g, "").length, - versionId: event.versionId ?? previous.versionId, - })); - options?.onCommitted?.(event.content); + flushThinking(); + { + const committedContent = + draftThinkFilterRef.current.replace(event.content) + draftThinkFilterRef.current.finish(); + setRun((previous) => ({ + ...previous, + draft: committedContent, + writtenCharCount: committedContent.replace(/\s/g, "").length, + versionId: event.versionId ?? previous.versionId, + })); + options?.onCommitted?.(committedContent); + } break; case "done": flushDelta(); flushRequirements(); + flushThinking(); receivedTerminalEvent = true; setRun((previous) => ({ ...previous, @@ -321,6 +390,7 @@ export function useDocumentRewrite() { case "conflict": flushDelta(); flushRequirements(); + flushThinking(); receivedTerminalEvent = true; setRun((previous) => ({ ...previous, @@ -331,12 +401,14 @@ export function useDocumentRewrite() { case "cancelled": flushDelta(); flushRequirements(); + flushThinking(); receivedTerminalEvent = true; setRun((previous) => ({ ...previous, stage: "cancelled", error: event.message })); return; case "error": flushDelta(); flushRequirements(); + flushThinking(); receivedTerminalEvent = true; setRun((previous) => ({ ...previous, @@ -351,6 +423,7 @@ export function useDocumentRewrite() { if (!controller.signal.aborted && !receivedTerminalEvent) { flushDelta(); flushRequirements(); + flushThinking(); setRun((previous) => ({ ...previous, stage: "error", @@ -361,6 +434,7 @@ export function useDocumentRewrite() { if (controller.signal.aborted) return; flushDelta(); flushRequirements(); + flushThinking(); setRun((previous) => ({ ...previous, stage: "error", @@ -372,7 +446,7 @@ export function useDocumentRewrite() { } } }, - [enqueueDelta, enqueueRequirementsDelta, flushDelta, flushRequirements], + [enqueueDelta, enqueueRequirementsDelta, enqueueThinking, flushDelta, flushRequirements, flushThinking], ); useEffect( @@ -380,13 +454,14 @@ export function useDocumentRewrite() { controllerRef.current?.abort(); if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); if (requirementsFrameRef.current !== null) cancelAnimationFrame(requirementsFrameRef.current); + if (thinkingFrameRef.current !== null) cancelAnimationFrame(thinkingFrameRef.current); }, [], ); return { run, - isActive: ["snapshot", "requirements", "writing", "validation", "committing"].includes(run.stage), + isActive: ["snapshot", "requirements", "writing", "illustrating", "validation", "committing"].includes(run.stage), start, cancel, reset, diff --git a/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx b/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx index 5723d416..bf85a03f 100644 --- a/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx +++ b/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx @@ -123,6 +123,7 @@ import { advancedOptionsFromConfig, buildReportCardConfig, ReportConfigCard, + ReportOutlineEditor, } from "./ReportConfigCard"; import { ResearchSandboxPanel } from "./ResearchSandboxPanel"; import { REPORT_OUTPUT_PATH, useReportSandbox } from "./useReportSandbox"; @@ -183,6 +184,8 @@ type WorkbenchRun = { phase: string; planText: string; planReasoning: string; + /** Live-only model thinking while report Markdown is being streamed. */ + reportThinking: string; report: string; reportTargets: Record; reportVersion: number; @@ -201,6 +204,7 @@ const EMPTY_RUN: WorkbenchRun = { phase: "initializing", planText: "", planReasoning: "", + reportThinking: "", report: "", reportTargets: {}, reportVersion: 1, @@ -418,6 +422,10 @@ function DeepResearchWorkbenchInner() { presentationId: null, plan: "", }); + const mirroredReportRewriteThinkingRef = useRef<{ presentationId: string | null; thinking: string }>({ + presentationId: null, + thinking: "", + }); const sessions = useResearchSessions(); const sessionQuery = useResearchSession(selectedId); @@ -428,6 +436,10 @@ function DeepResearchWorkbenchInner() { const [localSettings] = useLocalSettings(); const { models, isLoading: modelsLoading } = useModels(); const seenEventKeys = useRef(new Set()); + // Live provider deltas can legitimately repeat the same text (including a + // single space). They have no durable sequence, so never deduplicate them + // by payload or a thought token could disappear from the visible trace. + const liveEventSerial = useRef(0); const replayedTraceJobs = useRef(new Set()); const pendingReportEvents = useRef([]); const reportAnimationFrame = useRef(null); @@ -679,6 +691,16 @@ function DeepResearchWorkbenchInner() { }); }, [reportDocumentRewrite.run.requirementsPlan, rewritePresentation]); + useEffect(() => { + const presentationId = reportRewritePresentationId.current; + const thinking = reportDocumentRewrite.run.thinkingText; + if (!presentationId || !rewritePresentation || !thinking) return; + const previous = mirroredReportRewriteThinkingRef.current; + if (previous.presentationId === presentationId && previous.thinking === thinking) return; + mirroredReportRewriteThinkingRef.current = { presentationId, thinking }; + rewritePresentation.update(presentationId, { thinkingText: thinking }); + }, [reportDocumentRewrite.run.thinkingText, rewritePresentation]); + useEffect(() => { const handleRewriteUndo = (event: Event) => { const detail = (event as CustomEvent<{ @@ -854,6 +876,9 @@ function DeepResearchWorkbenchInner() { phase: session?.status === "completed" ? "done" : run.phase, materialCount, error: run.error, + thinking: run.reportThinking, + events: run.events, + requestedImageCount: requestedReportImageCount(session?.config), }), } as Injected); } @@ -870,6 +895,7 @@ function DeepResearchWorkbenchInner() { ? buildCompletedReportMessage({ sourceCount: materialCount, summaryText: run.summaryText, + imageCount: countEmbeddedReportImages(session?.reportMarkdown), }) : buildStreamingSummaryMessage(run.summaryText), // MessageList 会将该标记转为既有的 assistant 流式渲染状态,避免摘要只在结束后才显示。 @@ -950,7 +976,9 @@ function DeepResearchWorkbenchInner() { pendingFollowUp, followUpStream, run.error, + run.events, run.phase, + run.reportThinking, run.summaryText, session, streamJobId, @@ -980,6 +1008,7 @@ function DeepResearchWorkbenchInner() { if (replayingTerminalTrace && replayedTraceJobs.current.has(streamJobId)) return; if (replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId); seenEventKeys.current = new Set(); + liveEventSerial.current = 0; pendingReportEvents.current = []; setRun({ ...EMPTY_RUN, jobId: streamJobId }); // 进行中的写作仍自动打开沙箱;历史完成记录只回放步骤与摘要,等待用户点击 @@ -997,6 +1026,7 @@ function DeepResearchWorkbenchInner() { let reportTargets = current.reportTargets; let planText = current.planText; let planReasoning = current.planReasoning; + let reportThinking = current.reportThinking; let summaryText = current.summaryText; let phase = current.phase; for (const event of queued) { @@ -1009,6 +1039,10 @@ function DeepResearchWorkbenchInner() { planReasoning += typeof event.payload.delta === "string" ? event.payload.delta : ""; continue; } + if (event.type === "report_thinking") { + reportThinking += typeof event.payload.delta === "string" ? event.payload.delta : ""; + continue; + } if (event.type === "summary_delta") { summaryText += typeof event.payload.delta === "string" ? event.payload.delta : ""; continue; @@ -1020,6 +1054,7 @@ function DeepResearchWorkbenchInner() { if (event.type === "report_reset") { report = ""; reportTargets = {}; + reportThinking = ""; reportVersion = typeof event.payload.reportVersion === "number" ? event.payload.reportVersion : reportVersion + 1; continue; } @@ -1035,7 +1070,7 @@ function DeepResearchWorkbenchInner() { reportVersion = Math.max(reportVersion, version); } } - return { ...current, report, reportTargets, reportVersion, phase, planText, planReasoning, summaryText }; + return { ...current, report, reportTargets, reportVersion, phase, planText, planReasoning, reportThinking, summaryText }; }); }; @@ -1043,12 +1078,15 @@ function DeepResearchWorkbenchInner() { streamJobId, { onEvent: (event) => { - const key = eventKey(event); + const key = event.seq === 0 && !event.liveId + ? `live-arrival:${++liveEventSerial.current}` + : eventKey(event); if (seenEventKeys.current.has(key)) return; seenEventKeys.current.add(key); if ( event.type === "plan_delta" || event.type === "plan_reasoning" || + event.type === "report_thinking" || event.type === "report_delta" || event.type === "report_chunk" || event.type === "report_reset" || @@ -1521,6 +1559,10 @@ function DeepResearchWorkbenchInner() { case "config": return ( 0 || imageSnapshot.imageTotal > 0 || imageSnapshot.images.length > 0; + const imageTotal = imageSnapshot.imageTotal || requestedImageCount; + const generatedImages = imageSnapshot.images.filter((image) => image.status === "completed").length; + const failedImages = imageSnapshot.images.filter((image) => image.status === "failed").length; const stages = [ { id: "harvest", @@ -1879,6 +1933,18 @@ function buildWritingProgressToolCalls({ boundary: chatPhaseRank("reviewing"), detail: "正在逐字写入右侧沙箱中的 report.md", }, + ...(imageStageVisible ? [{ + id: "illustrate", + label: "生成报告配图", + boundary: chatPhaseRank("exporting"), + detail: generatedImages > 0 + ? `已生成 ${generatedImages} 张报告配图并嵌入 report.md${failedImages ? `;另有 ${failedImages} 张未生成,文字报告已保留` : ""}` + : failedImages > 0 + ? `配图未生成,已保留完整文字报告${imageTotal ? `(已处理 ${failedImages}/${imageTotal} 张)` : ""}` + : imageTotal > 0 + ? `正在生成报告配图,已处理 ${generatedImages + failedImages}/${imageTotal} 张` + : "正在为报告关键章节生成配图", + }] : []), { id: "summary", label: "生成报告摘要", @@ -1901,6 +1967,7 @@ function buildWritingProgressToolCalls({ label: stage.label, detail, status: complete ? "complete" : "active", + ...(stage.id === "write" && thinking.trim() ? { thinking } : {}), }, }; }); @@ -1910,14 +1977,19 @@ function buildWritingProgressToolCalls({ function buildCompletedReportMessage({ sourceCount, summaryText, + imageCount, }: { sourceCount: number; summaryText: string; + imageCount: number; }): string { const parts = [ "## 研究报告已完成", `已基于 ${sourceCount} 条资料完成报告,正文已在右侧沙箱中打开;可通过沙箱工具栏下载 Markdown 或 Word。`, ]; + if (imageCount > 0) { + parts.push(`已生成 ${imageCount} 张报告配图,并嵌入正文对应章节。`); + } if (summaryText.trim()) { parts.push(`### 报告摘要\n\n${summaryText.trim()}`); } @@ -2104,6 +2176,7 @@ function ResearchConversation({ const hasTrace = active || events.length > 0; const streamedSourceCount = events.reduce((count, event) => count + (event.type === "source_added" ? 1 : 0), 0); const liveSourceCount = Math.max(sourceCount, streamedSourceCount); + const requestedImageCount = requestedReportImageCount(session.config); return (
@@ -2120,6 +2193,7 @@ function ResearchConversation({ error={streamError} onOpenSandbox={onOpenSandbox} active={active} + requestedImageCount={requestedImageCount} /> )} @@ -2169,6 +2243,15 @@ type TraceSource = { snippet?: string; }; +type TraceImage = { + position: number; + section: string; + status: "generating" | "completed" | "failed"; + name?: string; + alt?: string; + error?: string; +}; + type TraceSnapshot = { groups: { title: string; queries: string[] }[]; subtopics: string[]; @@ -2179,6 +2262,9 @@ type TraceSnapshot = { curationReason?: string; compressedSourceCount?: number; reportCompleted: boolean; + imageTotal: number; + images: TraceImage[]; + imagePhaseFinished: boolean; warnings: string[]; }; @@ -2198,17 +2284,30 @@ function numberValue(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function countEmbeddedReportImages(markdown?: string): number { + return (markdown?.match(/!\[[^\]]*\]\(deep-research:\/\/[^)\s]+\)/g) ?? []).length; +} + +function requestedReportImageCount(config?: Record): number { + if (config?.generate_images !== true) return 0; + const configured = numberValue(config.max_generated_images); + return Math.max(1, Math.min(4, Math.floor(configured ?? 2))); +} + function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot { const groupQueries = new Map(); const subtopics: string[] = []; const queryMap = new Map(); const sourceMap = new Map(); + const imageMap = new Map(); const selectedIds = new Set(); const removedIds = new Set(); const warnings: string[] = []; let curationReason: string | undefined; let compressedSourceCount: number | undefined; let reportCompleted = false; + let imageTotal = 0; + let imagePhaseFinished = false; const addQuery = (query: string, group = "研究课题") => { const normalized = query.trim(); @@ -2223,6 +2322,11 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot { for (const event of events) { const payload = event.payload ?? {}; + if (event.type === "phase_changed") { + const from = traceString(payload.from); + const to = traceString(payload.to); + if (from === "illustrating" || to === "exporting") imagePhaseFinished = true; + } if (event.type === "plan_created") { const plan = traceRecord(payload.plan); for (const subtopic of traceStrings(payload.subtopics).concat(traceStrings(plan.subtopics))) { @@ -2292,9 +2396,39 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot { reportCompleted = true; continue; } + if (event.type === "image_generation_started" || event.type === "image_generated") { + const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1)); + const total = numberValue(payload.total); + if (total !== undefined) imageTotal = Math.max(imageTotal, Math.floor(total)); + imageTotal = Math.max(imageTotal, position); + const prior = imageMap.get(position); + imageMap.set(position, { + position, + section: traceString(payload.section) ?? prior?.section ?? `第 ${position} 张配图`, + status: event.type === "image_generated" ? "completed" : prior?.status === "completed" ? "completed" : "generating", + name: traceString(payload.name) ?? prior?.name, + alt: traceString(payload.alt) ?? prior?.alt, + }); + continue; + } if (event.type === "warning" || event.type === "error") { const message = traceString(payload.message) ?? traceString(payload.detail); if (message && !warnings.includes(message)) warnings.push(message); + if (traceString(payload.code) === "image_generation_failed") { + const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1)); + const total = numberValue(payload.total); + if (total !== undefined) imageTotal = Math.max(imageTotal, Math.floor(total)); + imageTotal = Math.max(imageTotal, position); + const prior = imageMap.get(position); + imageMap.set(position, { + position, + section: traceString(payload.section) ?? prior?.section ?? `第 ${position} 张配图`, + status: "failed", + name: prior?.name, + alt: prior?.alt, + error: message ?? "报告配图生成失败", + }); + } } } @@ -2308,12 +2442,15 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot { curationReason, compressedSourceCount, reportCompleted, + imageTotal, + images: [...imageMap.values()].sort((left, right) => left.position - right.position), + imagePhaseFinished, warnings, }; } function phaseAtOrAfter(phase: string, boundary: string): boolean { - const order = ["initializing", "planning", "collecting", "curating", "compressing", "deepening", "writing", "reviewing", "illustrating", "exporting", "done"]; + const order = ["initializing", "planning", "collecting", "curating", "compressing", "deepening", "writing", "summarizing", "reviewing", "illustrating", "exporting", "done"]; return Math.max(0, order.indexOf(phase)) >= Math.max(0, order.indexOf(boundary)); } @@ -2345,7 +2482,7 @@ function ReasoningStream({ text }: { text: string }) { ); } -const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, planReasoning, sourceCount, reportStarted, error, onOpenSandbox, active }: { events: ProgressEvent[]; phase: string; planText: string; planReasoning: string; sourceCount: number; reportStarted: boolean; error: string | null; onOpenSandbox: () => void; active: boolean }) { +const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, planReasoning, sourceCount, reportStarted, error, onOpenSandbox, active, requestedImageCount }: { events: ProgressEvent[]; phase: string; planText: string; planReasoning: string; sourceCount: number; reportStarted: boolean; error: string | null; onOpenSandbox: () => void; active: boolean; requestedImageCount: number }) { const [expanded, setExpanded] = useState(true); const snapshot = useMemo(() => buildTraceSnapshot(events), [events]); const sourcesById = useMemo(() => new Map(snapshot.sources.map((source) => [source.id, source])), [snapshot.sources]); @@ -2358,6 +2495,14 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla const curatingDone = phaseAtOrAfter(phase, "writing") || snapshot.selectedIds.size > 0; const writingDone = snapshot.reportCompleted || phase === "done"; const streamedQueries = planningDone ? [] : parseStreamedQueries(planText); + const imageStageVisible = requestedImageCount > 0 || snapshot.imageTotal > 0 || snapshot.images.length > 0; + const imageTotal = snapshot.imageTotal || requestedImageCount; + const completedImages = snapshot.images.filter((image) => image.status === "completed").length; + const failedImages = snapshot.images.filter((image) => image.status === "failed").length; + const processedImages = completedImages + failedImages; + const imageDone = imageStageVisible && (snapshot.imagePhaseFinished || phaseAtOrAfter(phase, "exporting") || (!active && phase === "done")); + const imageCurrent = imageStageVisible && !imageDone && (phase === "illustrating" || snapshot.images.some((image) => image.status === "generating")); + const imageProgress = imageTotal ? Math.min(100, Math.round((processedImages / imageTotal) * 100)) : 0; return (
@@ -2390,6 +2535,12 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla + {imageStageVisible && 0 ? `已生成 ${completedImages} 张配图并嵌入 report.md${failedImages ? `;另有 ${failedImages} 张未生成,文字报告已保留` : ""}。` : "配图未生成,已保留完整文字报告。" : imageCurrent ? `正在生成报告配图,已处理 ${processedImages}/${imageTotal || "?"} 张。` : "报告写完后会为关键章节生成并嵌入配图。"} done={imageDone} current={imageCurrent}> + {imageTotal > 0 &&
} + {snapshot.images.length > 0 ?
{snapshot.images.map((image) =>

第 {image.position} 张 · {image.section}

{image.status === "completed" ? `${image.name ?? "已保存并嵌入报告"}` : image.status === "failed" ? image.error ?? "生成失败,已保留文字报告" : "正在调用图片模型…"}

{image.status === "completed" ? "已生成" : image.status === "failed" ? "未生成" : "生成中"}
)}
:
{imageCurrent ? "正在准备第一张报告配图…" : `最多生成 ${imageTotal || requestedImageCount} 张,并在完成后插入对应章节。`}
} + {completedImages > 0 && } + } + {snapshot.warnings.length > 0 &&

过程提示

{snapshot.warnings.map((warning) =>

{warning}

)}
} {error &&

实时连接提示:{error}。研究仍会继续,页面会从已保存的事件记录恢复。

}
} @@ -2398,7 +2549,8 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla }); function CompletionSummary({ session, sourceCount, onOpenSandbox, onExport }: { session: DeepResearchSession; sourceCount: number; onOpenSandbox: () => void; onExport: (format: "word" | "markdown" | "html") => void }) { - return

研究报告已完成

已基于 {sourceCount || session.sourceCount} 条资料完成报告,并保留来源供后续追问。报告正文在右侧沙箱中打开。

; + const imageCount = countEmbeddedReportImages(session.reportMarkdown); + return

研究报告已完成

已基于 {sourceCount || session.sourceCount} 条资料完成报告,并保留来源供后续追问。报告正文在右侧沙箱中打开。

{imageCount > 0 &&

已生成 {imageCount} 张报告配图,已嵌入正文对应章节。

}
; } function PlanReviewCard({ plan, submitting, onSubmit }: { plan: Record | undefined; submitting: boolean; onSubmit: (action: "approve" | "revise", feedback?: string) => void }) { @@ -2409,8 +2561,132 @@ function PlanReviewCard({ plan, submitting, onSubmit }: { plan: Record void; mode: ResearchMode; options: AdvancedResearchOptions; classicMode: boolean; onClassicModeChange: (value: boolean) => void; imageGenerationAvailable: boolean; imageLimit: number; onChange: (key: K, value: AdvancedResearchOptions[K]) => void }) { - return 高级研究配置本次设置会作为下一次研究的默认值;对话式收集的最终选项在报告配置卡片中确认。
onChange("language", value as AdvancedResearchOptions["language"])} options={[{ value: "zh-CN", label: "中文" }, { value: "en-US", label: "English" }]} /> onChange("tone", value as ResearchTone)} options={[{ value: "objective", label: "客观" }, { value: "formal", label: "正式" }, { value: "analytical", label: "分析" }, { value: "persuasive", label: "说服" }]} /> onChange("maxSearchResults", value)} /> onChange("maxIterations", value)} /> onChange("maxConcurrency", value)} /> onChange("maxContextWords", value)} />{(mode === "detailed" || mode === "multi_agent") && onChange("maxSubtopics", value)} />}{mode === "deep" && <> onChange("deepBreadth", value)} /> onChange("deepDepth", value)} />}{mode === "multi_agent" && onChange("maxRevisionLoops", value)} />}