diff --git a/frontend-web/src/assets/deer.svg b/frontend-web/src/assets/deer.svg new file mode 100644 index 00000000..9bbfb291 --- /dev/null +++ b/frontend-web/src/assets/deer.svg @@ -0,0 +1,6 @@ + + + + diff --git a/frontend-web/src/components/landing/hero.tsx b/frontend-web/src/components/landing/hero.tsx index afa80e20..de049ec2 100644 --- a/frontend-web/src/components/landing/hero.tsx +++ b/frontend-web/src/components/landing/hero.tsx @@ -3,6 +3,7 @@ import { ChevronRightIcon } from "lucide-react"; import Link from "next/link"; +import deerMaskUrl from "@/assets/deer.svg"; import { Button } from "@/components/ui/button"; import { FlickeringGrid } from "@/components/ui/flickering-grid"; import Galaxy from "@/components/ui/galaxy"; @@ -29,7 +30,11 @@ export function Hero({ className }: { className?: string }) { /> void; }) { const [open, setOpen] = useState(!trace.thinkingComplete); - useEffect(() => { - setOpen(!trace.thinkingComplete); - }, [trace.thinkingComplete]); return ( @@ -222,20 +220,22 @@ function MultiRoundRagRoundCard({ index, round, isLoading, + isActiveRound, rehypePlugins, onSelectRetrieval, }: { index: number; round: MultiRoundRagRound; isLoading: boolean; + isActiveRound: boolean; rehypePlugins: MessageResponseProps["rehypePlugins"]; onSelectRetrieval: (item: MultiRoundRagRetrieval) => void; }) { - const searching = Boolean(round.queryLoading); + const query = round.query.trim(); + const searching = Boolean(query && round.queryLoading); + const showQuery = Boolean(query); + const showRetrievals = showQuery || round.retrievals.length > 0; const [open, setOpen] = useState(true); - useEffect(() => { - setOpen(searching || !round.query); - }, [round.id, round.query, searching]); return ( @@ -254,7 +254,7 @@ function MultiRoundRagRoundCard({ 检索中 - ) : ( + ) : isActiveRound ? null : ( 已完成 )} )} - {round.queryShow && (round.query || searching) && ( + {showQuery && (
- {round.query || "正在生成检索词…"} + {query}
)} - {round.queryShow && ( + {showRetrievals && (
diff --git a/frontend-web/src/core/messages/utils-group.test.ts b/frontend-web/src/core/messages/utils-group.test.ts index 8d2e212d..e1e3f179 100644 --- a/frontend-web/src/core/messages/utils-group.test.ts +++ b/frontend-web/src/core/messages/utils-group.test.ts @@ -111,6 +111,28 @@ test("a lone deep-research progress bar still opens its own group", () => { assert.deepEqual(types, ["assistant:processing"]); }); +test("multi-round RAG assistant rows render even while the final answer is still empty", () => { + const types = groupTypes([ + { type: "human", id: "h1", content: "检索一下", additional_kwargs: { multi_round_rag: true } } as Message, + { + type: "ai", + id: "a1", + content: "", + additional_kwargs: { + multi_round_rag: true, + multi_round_rag_trace: { + title: "多轮检索思考过程", + rounds: [{ id: "r1", title: "思考过程", thoughtMarkdown: "正在拆解问题", query: "", queryShow: true, retrievals: [] }], + finalAnswerMarkdown: "", + thinkingComplete: false, + }, + }, + } as Message, + ]); + + assert.deepEqual(types, ["human", "assistant"]); +}); + test("tool messages still attach to an open processing group", () => { const types = groupTypes([ { diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts index 211259e9..c971b8a2 100644 --- a/frontend-web/src/core/messages/utils.ts +++ b/frontend-web/src/core/messages/utils.ts @@ -141,6 +141,16 @@ export function groupMessages( } if (message.type === "ai") { + // Multi-round RAG keeps rounds/retrievals on additional_kwargs while the + // visible answer is still empty. The default rule below only opens an + // assistant bubble when hasContent() is true, which would swallow the + // live thinking cards until the final answer arrives (or forever, if the + // turn is think/search-only). Force a normal assistant row so + // MessageListItem can render MultiRoundRagAssistantBody immediately. + if (message.additional_kwargs?.multi_round_rag === true) { + groups.push({ id: message.id, type: "assistant", messages: [message] }); + continue; + } // Deep-research interaction card: virtual ai messages injected by the // workbench (additional_kwargs.setup_research_approval) render through // the host-provided slot. Writing phases use ordinary virtual tool calls 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 90627604..5a3d63c2 100644 --- a/frontend-web/src/core/threads/use-multi-round-rag.ts +++ b/frontend-web/src/core/threads/use-multi-round-rag.ts @@ -144,20 +144,22 @@ function platformTypeChange(platform: string) { return platform || "facebook"; } +function createEmptyRound(title: string, thoughtMarkdown = ""): MultiRoundRagRound { + return { + id: createId("multi-rag-round"), + title, + thoughtMarkdown, + query: "", + queryLoading: false, + queryShow: false, + retrievals: [], + }; +} + function createEmptyTrace(): MultiRoundRagTrace { return { title: "多轮检索思考过程", - rounds: [ - { - id: createId("multi-rag-round"), - title: "正在准备检索", - thoughtMarkdown: "", - query: "", - queryLoading: true, - queryShow: true, - retrievals: [], - }, - ], + rounds: [createEmptyRound("正在准备检索")], thinkingComplete: false, finalAnswerMarkdown: "", }; @@ -370,16 +372,8 @@ export function applyMultiRoundToolCall(trace: MultiRoundRagTrace, toolValue: un round.thoughtMarkdown = mergeThink(round.thoughtMarkdown, thinkContent); } else { round.queryLoading = false; - round.queryShow = round.retrievals.length > 0; - trace.rounds.push({ - id: createId("multi-rag-round"), - title: "思考中", - thoughtMarkdown: thinkContent, - query: "", - queryLoading: true, - queryShow: true, - retrievals: [], - }); + round.queryShow = Boolean(round.query) || round.retrievals.length > 0; + trace.rounds.push(createEmptyRound("思考中", thinkContent)); } } else { round.queryLoading = false; diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index f6b02c5e..c90ceca6 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -12,6 +12,7 @@ import { useParams, useRouter } from "next/navigation"; import { useLocation } from "react-router-dom"; import { useRouteBase } from "@/core/route-base"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { Button } from "@/components/ui/button"; @@ -25,6 +26,7 @@ import { } from "@/components/workspace/agents/agent-guide"; import { AgentWelcome } from "@/components/workspace/agent-welcome"; import { ArtifactTrigger } from "@/components/workspace/artifacts"; +import { listThreadArtifacts } from "@/core/artifacts/listing"; import { ChatBox, useThreadChat } from "@/components/workspace/chats"; import { ExportTrigger } from "@/components/workspace/export-trigger"; import { InputBox } from "@/components/workspace/input-box"; @@ -98,6 +100,14 @@ const PAGE_REPORT_DESIGNER_AGENT_ID = "page-report-designer"; const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000; const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000; +function isTaskReportsJsonPath(path: string | undefined): boolean { + const normalized = (path ?? "").replace(/\\/g, "/"); + return ( + normalized === "task-reports.json" || + normalized.endsWith("/task-reports.json") + ); +} + function readPendingKnowledgeSelection(agentId: string | undefined): string[] | null { if (typeof window === "undefined" || !agentId) return null; try { @@ -504,9 +514,11 @@ export default function AgentChatPage({ // ── agentfx「入库」协助卡 ────────────────────────────────────────────────── // 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且 // 本轮流式结束后,向消息流尾部注入「是否入库」卡片。本地沙箱下 artifacts 可能 - // 记录宿主真实路径,按文件名匹配(虚拟/宿主路径都命中)。任务已有详情**不抑制** - // 卡片(入库是整体替换,由卡片文案说明覆盖语义);已导入按 thread 记忆在 - // sessionStorage(刷新后不再重复询问)、「暂不入库」为本次会话内记忆。 + // 记录宿主真实路径,按文件名匹配(虚拟/宿主路径都命中)。转换脚本已函数合并 + // 写出该文件,即使模型中断、没跑 present_files,也再扫一遍 outputs 目录。 + // 任务已有详情**不抑制**卡片(入库是整体替换,由卡片文案说明覆盖语义); + // 已导入按 thread 记忆在 sessionStorage(刷新后不再重复询问)、 + // 「暂不入库」为本次会话内记忆。 const reportImportSessionKey = threadId ? `agentfx.reportImport.done.${threadId}` : ""; @@ -519,9 +531,19 @@ export default function AgentChatPage({ } }); const [reportImportDismissed, setReportImportDismissed] = useState(false); - const reportJsonArtifactReady = (thread.values.artifacts ?? []).some((path) => - path.replace(/\\/g, "/").endsWith("/task-reports.json"), - ); + const listedReportJsonQuery = useQuery({ + queryKey: ["agentfx-task-reports", threadId], + queryFn: ({ signal }) => listThreadArtifacts(threadId, signal), + enabled: Boolean( + taskBind && !taskGate && threadId && threadId !== "new" && !thread.isLoading, + ), + retry: false, + }); + const reportJsonArtifactReady = + (thread.values.artifacts ?? []).some(isTaskReportsJsonPath) || + (listedReportJsonQuery.data?.files ?? []).some((file) => + isTaskReportsJsonPath(file.path) || file.name === "task-reports.json", + ); const showReportImportCard = taskBind && !taskGate && diff --git a/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx b/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx index b9f7b64d..aff82fb9 100644 --- a/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx +++ b/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx @@ -99,6 +99,7 @@ import { extractToolResultPayload, hasToolCalls, } from "@/core/messages/utils"; +import { normalizeSearchResults } from "@/core/messages/search-results"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { type DeepResearchSession, @@ -276,7 +277,18 @@ function assembleStreamingReport(targets: Record
@@ -621,6 +624,19 @@ export function ReportConfigCard({ )} + +
- + ) : null}
); }); diff --git a/frontend-web/src/report-collaboration/components/ReportConversationPanel.tsx b/frontend-web/src/report-collaboration/components/ReportConversationPanel.tsx index 4186d06a..627b7aec 100644 --- a/frontend-web/src/report-collaboration/components/ReportConversationPanel.tsx +++ b/frontend-web/src/report-collaboration/components/ReportConversationPanel.tsx @@ -6,7 +6,6 @@ import type { Message } from "@langchain/langgraph-sdk"; import { MessageList } from "@/components/workspace/messages/message-list"; import { ThreadContext } from "@/components/workspace/messages/context"; -import { ArtifactsProvider } from "@/components/workspace/artifacts"; import { SubtasksProvider } from "@/core/tasks/context"; import type { AgentThreadState } from "@/core/threads"; import type { BaseStream } from "@langchain/langgraph-sdk/react"; @@ -68,13 +67,11 @@ export function ReportConversationPanel({ return ( - -
- {stageSelector} - {content} - {composer} -
-
+
+ {stageSelector} + {content} + {composer} +
); } diff --git a/frontend-web/src/report-collaboration/components/ReportSandboxOverlay.tsx b/frontend-web/src/report-collaboration/components/ReportSandboxOverlay.tsx new file mode 100644 index 00000000..2bb0f042 --- /dev/null +++ b/frontend-web/src/report-collaboration/components/ReportSandboxOverlay.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { FilesIcon, XIcon } from "lucide-react"; + +import { ConversationEmptyState } from "@/components/ai-elements/conversation"; +import { Button } from "@/components/ui/button"; +import { ArtifactFileDetail, useArtifacts } from "@/components/workspace/artifacts"; + +/** + * 画布上的沙箱覆盖层:write_file 步骤条自动打开后,在右侧预览报告, + * 不另开第三栏。关闭后归还流程图。 + */ +export function ReportSandboxOverlay({ threadId }: { threadId: string }) { + const { open, selectedArtifact, setOpen, deselect } = useArtifacts(); + if (!open) return null; + + const handleClose = () => { + deselect(); + setOpen(false); + }; + + return ( +
+ {selectedArtifact ? ( + + ) : ( +
+ + } + title="沙箱预览" + description="智能体写入文件后将在此预览" + /> +
+ )} +
+ ); +} diff --git a/frontend-web/src/report-collaboration/hooks/useReportCollaborationMessages.ts b/frontend-web/src/report-collaboration/hooks/useReportCollaborationMessages.ts index cfa6f134..53ba0d66 100644 --- a/frontend-web/src/report-collaboration/hooks/useReportCollaborationMessages.ts +++ b/frontend-web/src/report-collaboration/hooks/useReportCollaborationMessages.ts @@ -16,9 +16,16 @@ import type { CollaborationMessage } from "@/report-collaboration/api/types"; import type { CollaborationState } from "@/report-collaboration/state/event-reducer"; import { buildBaseStreamFacade, + buildReportWriteFileMessages, + reportWriteFileBelongsToFocus, toLangGraphMessages, } from "@/report-collaboration/state/message-adapter"; -import { selectConversationMessages, selectOrderedVisibleMessages } from "@/report-collaboration/state/selectors"; +import { + selectConversationMessages, + selectHeadReportVersion, + selectOrderedVisibleMessages, + selectWriterAgentRun, +} from "@/report-collaboration/state/selectors"; export interface UseReportCollaborationMessagesResult { visibleMessages: CollaborationMessage[]; @@ -39,10 +46,23 @@ export function useReportCollaborationMessages( const visibleMessages = selectOrderedVisibleMessages(state); const focusedMessages = selectConversationMessages(state, focusedAgentRunId); const base = toLangGraphMessages(focusedMessages); - const displayMessages = extraCards.length > 0 ? [...base, ...extraCards] : base; + const writeFiles = reportWriteFileBelongsToFocus( + buildReportWriteFileMessages({ + sessionId: state.sessionId, + markdown: state.reportDraft?.markdown || selectHeadReportVersion(state)?.markdown || "", + writing: Boolean(state.reportDraft), + writer: selectWriterAgentRun(state), + }), + focusedAgentRunId, + ); + const displayMessages = + extraCards.length > 0 || writeFiles.length > 0 + ? [...base, ...writeFiles, ...extraCards] + : base; const streamSource = focusedAgentRunId ? focusedMessages : visibleMessages; const streaming = streamSource.some((message) => !message.completed_at && message.role !== "human") || + Boolean(state.reportDraft) || extraLoading; const displayThread = buildBaseStreamFacade(displayMessages, streaming); return { visibleMessages, focusedMessages, displayMessages, displayThread, streaming }; diff --git a/frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx b/frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx index 8590aa4f..1a08adaa 100644 --- a/frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx +++ b/frontend-web/src/report-collaboration/pages/ReportCollaborationPage.tsx @@ -1,11 +1,14 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useLocation, useNavigate, useParams } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import type { Message } from "@langchain/langgraph-sdk"; import { toast } from "sonner"; import { FileText, Sparkles } from "lucide-react"; +import { ArtifactsProvider } from "@/components/workspace/artifacts"; +import { ThreadContext } from "@/components/workspace/messages/context"; +import { SubtasksProvider } from "@/core/tasks/context"; import { getReportCollaborationDriver } from "@/report-collaboration/api/driver"; import { AgentStageSelector } from "@/report-collaboration/components/AgentStageSelector"; import { ConnectionStatusBar } from "@/report-collaboration/components/ConnectionStatusBar"; @@ -21,6 +24,7 @@ import { ReportAvailableCard, ReportPreviewDialog, } from "@/report-collaboration/components/ReportPreviewDialog"; +import { ReportSandboxOverlay } from "@/report-collaboration/components/ReportSandboxOverlay"; import { SessionHistoryPanel } from "@/report-collaboration/components/SessionHistoryPanel"; import { useReportPanelLayout } from "@/report-collaboration/hooks/useReportPanelLayout"; import { useReportCollaborationMessages } from "@/report-collaboration/hooks/useReportCollaborationMessages"; @@ -34,11 +38,11 @@ import { } from "@/report-collaboration/state/message-adapter"; import { derivePagePhase, + selectAgentRunForNode, selectAgentTabs, selectAwaitingInputNodes, selectHeadReportVersion, selectNodeStatusMap, - selectParallelMembers, selectPendingConfirmationCommands, selectPlanForCanvas, selectPreferredParallelFocus, @@ -101,6 +105,7 @@ export default function ReportCollaborationPage() { ); return ( + undefined} />} statusBar={} /> + ); } @@ -167,13 +173,21 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin const [focusedAgentRunId, setFocusedAgentRunId] = useState(null); const [focusPinned, setFocusPinned] = useState(false); const tabIds = useMemo(() => new Set(agentTabs.map((tab) => tab.agent_run_id)), [agentTabs]); + const pinnedAgent = + focusPinned && focusedAgentRunId && !tabIds.has(focusedAgentRunId) + ? (state.agentRuns[focusedAgentRunId] ?? null) + : null; const effectiveFocus = - agentTabs.length === 0 - ? null - : focusedAgentRunId && tabIds.has(focusedAgentRunId) - ? focusedAgentRunId - : agentTabs[0].agent_run_id; - const focusedTab = agentTabs.find((tab) => tab.agent_run_id === effectiveFocus) ?? null; + focusPinned && focusedAgentRunId && state.agentRuns[focusedAgentRunId] + ? focusedAgentRunId + : agentTabs.length === 0 + ? null + : focusedAgentRunId && tabIds.has(focusedAgentRunId) + ? focusedAgentRunId + : agentTabs[0].agent_run_id; + const focusedTab = + agentTabs.find((tab) => tab.agent_run_id === effectiveFocus) ?? + (effectiveFocus ? state.agentRuns[effectiveFocus] ?? null : null); const { previewPlanId, previewPlan, effectivePlanId } = useReportPlanSelection( state.plans, @@ -216,14 +230,17 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin // eslint-disable-next-line react-hooks/exhaustive-deps }, [session.actionError]); - // 并行波次:默认落到正在执行的成员;点头像/画布后保持该成员,直到波次结束。 + // 执行波次:默认落到正在输出的成员;点头像/画布后保持该成员直到该波次结束。 + // 全部结束后若用户钉住某个节点,保留回看,不强制清回全量时间线。 useEffect(() => { if (agentTabs.length === 0) { - if (focusedAgentRunId) setFocusedAgentRunId(null); - if (focusPinned) setFocusPinned(false); + if (!focusPinned && focusedAgentRunId) setFocusedAgentRunId(null); return; } - if (focusedAgentRunId && tabIds.has(focusedAgentRunId)) return; + if (focusPinned && focusedAgentRunId && tabIds.has(focusedAgentRunId)) return; + if (focusPinned && focusedAgentRunId && !tabIds.has(focusedAgentRunId)) { + setFocusPinned(false); + } const next = selectPreferredParallelFocus(agentTabs); if (next) setFocusedAgentRunId(next); }, [agentTabs, focusPinned, focusedAgentRunId, tabIds]); @@ -236,7 +253,7 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin if ( state.plans.length > 0 && !runStarted && - (phase === "proposal_ready" || phase === "planning") + phase === "proposal_ready" ) { cards.push( buildPlanGroupCard(state.plans, state.selectedPlanId, state.session?.requirement_revision ?? 1), @@ -273,17 +290,7 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin }, [state]); const findMemberOfNode = useCallback( - (nodeId: string) => { - const members = selectParallelMembers(state); - return ( - members.find((member) => member.node_run_id?.startsWith(`${nodeId}-`)) ?? - Object.values(state.agentRuns).find( - (agentRun) => - agentRun.role !== "协调者" && agentRun.node_run_id?.startsWith(`${nodeId}-`), - ) ?? - null - ); - }, + (nodeId: string) => selectAgentRunForNode(state, nodeId), [state], ); @@ -291,30 +298,29 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin (nodeId: string | null) => { setSelectedNodeId(nodeId); setDrawerOpen(Boolean(nodeId)); - // 点并行智能体节点时,消息列表切到该成员的执行过程(与头像条同一套聚焦)。 if (nodeId) { const member = findMemberOfNode(nodeId); - if (member && tabIds.has(member.agent_run_id)) { + if (member) { setFocusedAgentRunId(member.agent_run_id); setFocusPinned(true); } } }, - [findMemberOfNode, tabIds], + [findMemberOfNode], ); const handleNodeComment = useCallback( (nodeId: string, nodeName: string) => { const member = findMemberOfNode(nodeId); setComposerTarget({ nodeId, nodeName, agentRunId: member?.agent_run_id ?? null }); - if (member && tabIds.has(member.agent_run_id)) { + if (member) { setFocusedAgentRunId(member.agent_run_id); setFocusPinned(true); } setDrawerOpen(false); setFocusSignal((signal) => signal + 1); }, - [findMemberOfNode, tabIds], + [findMemberOfNode], ); const handleRestore = useCallback( @@ -395,7 +401,7 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin ? "start" : outgoingIds.size > 0 ? "agent" - : "end") as const, + : "end") as "start" | "agent" | "end", mission: selectedPlanNode?.mission ?? null, input_artifact_types: selectedPlanNode?.input_artifact_types ?? [], output_artifact_type: selectedPlanNode?.output_artifact_type ?? null, @@ -438,114 +444,128 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin } return ( - <> - navigate("/page/workspace/chats/new")} - onNewSession={() => navigate("/page/workspace/report-collaboration")} - onOpenReport={() => { - if (headVersion) { - setPreviewVersionId(headVersion.id); - setPreviewOpen(true); - } - }} - hasReport={Boolean(headVersion)} - running={runActive} - onCancelRun={() => void session.cancelRun()} - extra={ - layout.isNarrow ? ( - - ) : null - } - /> - } - conversation={ - { - setFocusedAgentRunId(agentRunId); - setFocusPinned(true); - }} - streamingAgentRunIds={streamingIds} - awaitingAgentRunIds={awaitingIds} - /> - } - emptyHint={ - effectiveFocus - ? `${focusedTab?.display_name ?? "智能体"}正在执行,输出会出现在这里` - : undefined - } - composer={ - setComposerTarget(null)} - onSend={(text) => - void session.send(text, { - nodeId: composerTarget?.nodeId ?? null, - agentRunId: composerTarget?.agentRunId ?? null, - }) - } - focusSignal={focusSignal} - /> + + + + navigate("/page/workspace/chats/new")} + onNewSession={() => navigate("/page/workspace/report-collaboration")} + onOpenReport={() => { + if (headVersion) { + setPreviewVersionId(headVersion.id); + setPreviewOpen(true); + } + }} + hasReport={Boolean(headVersion)} + running={runActive} + onCancelRun={() => void session.cancelRun()} + extra={ + layout.isNarrow ? ( + + ) : null } /> } - history={} + conversation={ + 1 + ? `并行 ${agentTabs.length} 席` + : focusedTab?.display_name ?? (agentTabs[0]?.display_name ?? null) + } + focusedAgentRunId={effectiveFocus} + onFocus={(agentRunId) => { + setFocusedAgentRunId(agentRunId); + setFocusPinned(true); + }} + onShowAll={() => { + setFocusedAgentRunId(null); + setFocusPinned(false); + }} + streamingAgentRunIds={streamingIds} + awaitingAgentRunIds={awaitingIds} + /> + } + emptyHint={ + effectiveFocus + ? `${focusedTab?.display_name ?? "智能体"}正在执行,输出会出现在这里` + : undefined + } + composer={ + setComposerTarget(null)} + onSend={(text) => + void session.send(text, { + nodeId: composerTarget?.nodeId ?? null, + agentRunId: composerTarget?.agentRunId ?? null, + }) + } + focusSignal={focusSignal} + /> + } + /> + } + history={} + /> + } + canvas={ + <> + + setDrawerOpen(false)} + onComment={handleNodeComment} + /> + + + } + statusBar={ + + } /> - } - canvas={ - <> - - setDrawerOpen(false)} - onComment={handleNodeComment} - /> - - } - statusBar={ - setPreviewOpen(false)} + versions={state.reportVersions} + activeVersionId={previewVersionId} + onSelectVersion={setPreviewVersionId} + onRestore={(versionId) => void handleRestore(versionId)} + restoreBusy={restoreBusy} /> - } - /> - setPreviewOpen(false)} - versions={state.reportVersions} - activeVersionId={previewVersionId} - onSelectVersion={setPreviewVersionId} - onRestore={(versionId) => void handleRestore(versionId)} - restoreBusy={restoreBusy} - /> - + + + ); } diff --git a/frontend-web/src/report-collaboration/state/event-reducer.ts b/frontend-web/src/report-collaboration/state/event-reducer.ts index da22f7b1..17728ef6 100644 --- a/frontend-web/src/report-collaboration/state/event-reducer.ts +++ b/frontend-web/src/report-collaboration/state/event-reducer.ts @@ -588,15 +588,31 @@ function upsertMessage( }; } +function appendReasoningDelta( + metadata: Record | null | undefined, + reasoningDelta: string, +): Record | null { + if (!reasoningDelta) return metadata ?? null; + return { + ...(metadata ?? {}), + reasoning_content: `${String(metadata?.reasoning_content ?? "")}${reasoningDelta}`, + }; +} + function appendMessageDelta(state: CollaborationState, payload: MessageDeltaPayload): CollaborationState { if (!payload.message_id) return state; const existing = state.messagesById[payload.message_id]; + const reasoningDelta = payload.reasoning_delta ?? ""; if (existing) { return { ...state, messagesById: { ...state.messagesById, - [payload.message_id]: { ...existing, content: existing.content + payload.delta }, + [payload.message_id]: { + ...existing, + content: existing.content + (payload.delta ?? ""), + metadata: appendReasoningDelta(existing.metadata, reasoningDelta), + }, }, }; } @@ -604,12 +620,13 @@ function appendMessageDelta(state: CollaborationState, payload: MessageDeltaPayl return upsertMessage(state, { message_id: payload.message_id, role: "ai", - content: payload.delta, + content: payload.delta ?? "", agent_run_id: payload.agent_run_id ?? null, node_run_id: payload.node_run_id ?? null, visibility: "visible", created_at: new Date().toISOString(), completed_at: null, + metadata: appendReasoningDelta(null, reasoningDelta), }); } @@ -699,7 +716,7 @@ function toolResultMessageId(payload: { message_id?: string; tool_call_id: strin } function applyToolResultCreated(state: CollaborationState, payload: ToolResultCreatedPayload): CollaborationState { - return upsertMessage(state, { + const withResult = upsertMessage(state, { message_id: toolResultMessageId(payload), role: "tool", content: payload.content ?? "", @@ -710,8 +727,30 @@ function applyToolResultCreated(state: CollaborationState, payload: ToolResultCr phase_id: payload.phase_id ?? null, visibility: "visible", created_at: new Date().toISOString(), - completed_at: null, + completed_at: new Date().toISOString(), }); + return completeToolOnlyOwner(withResult, payload.tool_call_id); +} + +/** 工具-only 的 ai 行在全部 tool 返回到达后收尾,避免步骤条一直转圈。 */ +function completeToolOnlyOwner(state: CollaborationState, toolCallId: string): CollaborationState { + const owner = Object.values(state.messagesById).find((message) => + message.tool_calls?.some((call) => call.id === toolCallId), + ); + if (!owner || owner.completed_at || owner.content) return state; + const allResolved = (owner.tool_calls ?? []).every((call) => + Object.values(state.messagesById).some( + (message) => message.role === "tool" && message.tool_call_id === call.id, + ), + ); + if (!allResolved) return state; + return { + ...state, + messagesById: { + ...state.messagesById, + [owner.message_id]: { ...owner, completed_at: new Date().toISOString() }, + }, + }; } function applyToolResultDelta(state: CollaborationState, payload: ToolResultDeltaPayload): CollaborationState { diff --git a/frontend-web/src/report-collaboration/state/message-adapter.ts b/frontend-web/src/report-collaboration/state/message-adapter.ts index 0f4e416f..36b3df4e 100644 --- a/frontend-web/src/report-collaboration/state/message-adapter.ts +++ b/frontend-web/src/report-collaboration/state/message-adapter.ts @@ -17,12 +17,16 @@ import type { BaseStream } from "@langchain/langgraph-sdk/react"; import type { AgentThreadState } from "@/core/threads"; import type { + AgentRun, CollaborationCommand, CollaborationMessage, ReportPlanCandidate, ReportVersion, } from "@/report-collaboration/api/types"; +/** 报告在沙箱中的虚拟路径,对齐问答页 write_file / 深度研究 stub。 */ +export const REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/report.md"; + // ── 归属标记 ───────────────────────────────────────────────────────────────── export interface ReportCollaborationKwargs { @@ -70,6 +74,12 @@ export function toLangGraphMessage(message: CollaborationMessage): Message { } as unknown as Message; } + // 思考过程:与问答页 additional_kwargs.reasoning_content 对齐,供 MessageGroup CoT 渲染。 + const reasoning = message.metadata?.reasoning_content; + if (typeof reasoning === "string" && reasoning.length > 0) { + additional_kwargs.reasoning_content = reasoning; + } + // ai:tool_calls 原样透传(args 流式期间为部分 JSON 字符串)。 return { id: message.message_id, @@ -167,6 +177,74 @@ export function buildReportAvailableCard(version: ReportVersion, versionCount: n }); } +export interface ReportWriteFileSource { + sessionId: string; + markdown: string; + writing: boolean; + writer?: AgentRun | null; +} + +/** + * 把 report.delta / 正式版本投影成原生 write_file 消息,让 MessageList + * 步骤条和 ArtifactFileDetail 沙箱走与问答页相同的渲染路径。 + */ +export function buildReportWriteFileMessages(source: ReportWriteFileSource): Message[] { + const { markdown, writing, writer } = source; + if (!writing && !markdown) return []; + + const sessionId = source.sessionId || "session"; + const messageId = `rc-write-${sessionId}-report`; + const toolCallId = `${messageId}-file`; + const additional_kwargs: Record = {}; + if (writer) { + additional_kwargs.report_collaboration = { + agentRunId: writer.agent_run_id, + nodeRunId: writer.node_run_id ?? undefined, + phaseId: writer.phase_id ?? undefined, + }; + } + + const aiMessage = { + id: messageId, + type: "ai" as const, + content: "", + name: writer?.display_name ?? "撰写员", + tool_calls: [ + { + id: toolCallId, + name: "write_file", + args: { path: REPORT_OUTPUT_PATH, content: markdown }, + }, + ], + additional_kwargs, + } as unknown as Message; + + if (writing) return [aiMessage]; + + const toolResult = { + id: `${toolCallId}-result`, + type: "tool" as const, + name: "write_file", + tool_call_id: toolCallId, + content: "ok", + additional_kwargs, + } as unknown as Message; + return [aiMessage, toolResult]; +} + +/** 注入的 write_file 行在聚焦某成员时只保留撰写员自己的。 */ +export function reportWriteFileBelongsToFocus( + messages: Message[], + focusedAgentRunId: string | null, +): Message[] { + if (!focusedAgentRunId) return messages; + return messages.filter((message) => { + const kwargs = (message.additional_kwargs as Record | undefined) + ?.report_collaboration as { agentRunId?: string } | undefined; + return kwargs?.agentRunId === focusedAgentRunId; + }); +} + // ── BaseStream 外观 ───────────────────────────────────────────────────────── /** diff --git a/frontend-web/src/report-collaboration/state/selectors.ts b/frontend-web/src/report-collaboration/state/selectors.ts index c252db68..53578085 100644 --- a/frontend-web/src/report-collaboration/state/selectors.ts +++ b/frontend-web/src/report-collaboration/state/selectors.ts @@ -90,14 +90,15 @@ function latestStartedAt(members: AgentRun[]): string { } /** - * 当前正在并行执行的那一波成员(不含协调者)。 - * 同一 phase 下 ≥2 个成员且至少一人仍在执行时才返回;波次结束后回到空数组, - * 消息列表按会商第二步收成普通聊天记录。 + * 当前正在执行的那一波成员(不含协调者)。 + * 同一 phase 下只要有人仍在执行就返回——单个模型节点也要露出过程; + * 波次结束后回到空数组,消息列表收成普通聊天记录。 + * ≥2 人时即为并行波次;1 人时为业务链上的当前节点。 */ export function selectParallelMembers(state: CollaborationState): AgentRun[] { const waves = [...membersByPhase(state).entries()] .map(([phaseId, list]) => ({ phaseId, list })) - .filter(({ list }) => list.length >= 2 && list.some((member) => isAgentStillExecuting(state, member))); + .filter(({ list }) => list.length >= 1 && list.some((member) => isAgentStillExecuting(state, member))); if (waves.length === 0) return []; const currentPhase = state.run?.current_phase ?? null; const chosen = @@ -106,11 +107,37 @@ export function selectParallelMembers(state: CollaborationState): AgentRun[] { return [...chosen.list].sort((a, b) => (a.started_at ?? "").localeCompare(b.started_at ?? "")); } -/** 头像条:仅当前并行波次的执行成员(不含协调者)。 */ +/** 头像条:当前正在执行的成员(单个模型节点或并行波次,不含协调者)。 */ export function selectAgentTabs(state: CollaborationState): AgentRun[] { return selectParallelMembers(state); } +/** 画布节点 → 对应成员(含已完成的顺序节点,供点击后回看该节点的步骤条)。 */ +export function selectAgentRunForNode( + state: CollaborationState, + nodeId: string | null, +): AgentRun | null { + if (!nodeId) return null; + const members = Object.values(state.agentRuns).filter((agentRun) => { + const nodeRunId = agentRun.node_run_id; + return typeof nodeRunId === "string" && (nodeRunId === nodeId || nodeRunId.startsWith(`${nodeId}-`)); + }); + if (members.length === 0) return null; + return ( + members.find((member) => isAgentStillExecuting(state, member)) ?? + [...members].sort((a, b) => (b.started_at ?? "").localeCompare(a.started_at ?? ""))[0] + ); +} + +/** 撰写节点对应的成员(report.delta 注入 write_file 时挂到该归属)。 */ +export function selectWriterAgentRun(state: CollaborationState): AgentRun | null { + const byRole = Object.values(state.agentRuns).find( + (agentRun) => /撰写|writer/i.test(agentRun.role) || /撰写/.test(agentRun.display_name), + ); + if (byRole) return byRole; + return selectAgentRunForNode(state, "n-write"); +} + /** 波次刚出现或当前聚焦已失效时,优先落到正在输出的成员。 */ export function selectPreferredParallelFocus( members: AgentRun[], diff --git a/frontend-web/src/report-collaboration/styles/report-collaboration.css b/frontend-web/src/report-collaboration/styles/report-collaboration.css index 40ef7fad..029da547 100644 --- a/frontend-web/src/report-collaboration/styles/report-collaboration.css +++ b/frontend-web/src/report-collaboration/styles/report-collaboration.css @@ -309,6 +309,23 @@ flex-shrink: 0; } +.rc-agent-show-all { + margin-left: auto; + flex-shrink: 0; +} + +/* 画布上的沙箱覆盖层:write_file 打开后占满右侧,关闭归还流程图 */ +.rc-sandbox-overlay { + position: absolute; + inset: 0; + z-index: 22; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--rc-bg-panel); + animation: rc-drawer-in 0.18s ease-out; +} + .rc-agent-strip-meta { display: flex; flex-shrink: 0; diff --git a/frontend-web/src/report-collaboration/tests/event-reducer.test.ts b/frontend-web/src/report-collaboration/tests/event-reducer.test.ts index 7649606c..1fb8eadd 100644 --- a/frontend-web/src/report-collaboration/tests/event-reducer.test.ts +++ b/frontend-web/src/report-collaboration/tests/event-reducer.test.ts @@ -99,6 +99,32 @@ test("message.delta 多次更新同一 messageId,completed 收尾", () => { assert.equal(state.lastSeq, 4); }); +test("message.delta 的 reasoning_delta 累加到 metadata.reasoning_content", () => { + let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot()); + state = applyCollaborationEvents(state, [ + ev(1, "message.created", { + message: { + message_id: "m_think", + role: "ai", + content: "", + agent_run_id: "coord", + visibility: "visible", + metadata: { reasoning_content: "先" }, + created_at: "2026-09-02T10:00:01Z", + completed_at: null, + }, + }), + ev(2, "message.delta", { + message_id: "m_think", + agent_run_id: "coord", + delta: "", + reasoning_delta: "拆需求", + }), + ]); + assert.equal(state.messagesById.m_think.content, ""); + assert.equal(state.messagesById.m_think.metadata?.reasoning_content, "先拆需求"); +}); + test("乱序事件先缓冲,缺口补齐后按序 drain", () => { let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot()); // seq=2 先到 → 缓冲并出现缺口 @@ -151,6 +177,7 @@ test("工具调用挂在所属 ai 消息,返回以 tool 消息配对 tool_call assert.equal(toolMessage.tool_call_id, "tc1"); assert.equal(state.messageOrder[0], "ai_owner"); assert.equal(state.messageOrder[1], "tr1"); + assert.ok(owner.completed_at, "工具-only 的 ai 行在 tool 返回后应收尾,避免步骤条一直转圈"); }); test("节点状态机守卫:cancelled 后晚到 completed 被忽略", () => { diff --git a/frontend-web/src/report-collaboration/tests/message-adapter.test.ts b/frontend-web/src/report-collaboration/tests/message-adapter.test.ts index 8b441dd0..67310e76 100644 --- a/frontend-web/src/report-collaboration/tests/message-adapter.test.ts +++ b/frontend-web/src/report-collaboration/tests/message-adapter.test.ts @@ -6,6 +6,8 @@ import { buildCommandConfirmCard, buildPlanGroupCard, buildBaseStreamFacade, + buildReportWriteFileMessages, + REPORT_OUTPUT_PATH, toLangGraphMessages, } from "../state/message-adapter"; import { selectMessagesForAgentRun, selectAgentTabs, selectParallelMembers, selectConversationMessages, stitchParallelAgentBlocks } from "../state/selectors"; @@ -84,7 +86,7 @@ function agentRun(partial: Partial & Pick { +test("头像条:同一 phase 有人仍在执行即出现,单个模型节点也会露出,不含协调者", () => { const state = createInitialCollaborationState("s1"); state.agentRuns = { coord: agentRun({ @@ -105,6 +107,26 @@ test("并行头像条:同一 phase 且至少两人仍在执行时才出现, assert.equal(selectParallelMembers(state).length, 2); }); +test("头像条:业务链上的单个执行节点也会出现", () => { + const state = createInitialCollaborationState("s1"); + state.agentRuns = { + writer: agentRun({ + agent_run_id: "agent_run_n-write", + display_name: "报告撰写", + role: "撰写员", + phase_id: "phase_n-write", + node_run_id: "n-write-attempt1", + status: "running", + }), + }; + state.nodes = { + "n-write": { node_id: "n-write", label: "报告撰写", status: "running", attempt: 1, attempts: [] }, + }; + const tabs = selectAgentTabs(state); + assert.equal(tabs.length, 1); + assert.equal(tabs[0].agent_run_id, "agent_run_n-write"); +}); + test("并行波次全部结束后头像条消失,消息列表恢复全量", () => { const state = createInitialCollaborationState("s1"); state.agentRuns = { @@ -227,6 +249,81 @@ test("方案组卡可被 groupMessages 识别且不产生正文气泡", () => { assert.deepEqual(types, ["assistant:business-card"]); }); +test("规划过程:思考+步骤条映射后排在方案卡之前,不是一句总结", () => { + const process: CollaborationMessage = { + message_id: "plan_process", + role: "ai", + content: "", + name: "协调者", + agent_run_id: "agent_run_coordinator", + visibility: "visible", + created_at: "t", + completed_at: "t", + tool_calls: [{ id: "tc_parse", name: "write_todos", args: { description: "解析需求要点" } }], + }; + const toolResult: CollaborationMessage = { + message_id: "tr_parse", + role: "tool", + content: "已提取读者与角度", + name: "write_todos", + tool_call_id: "tc_parse", + agent_run_id: "agent_run_coordinator", + visibility: "visible", + created_at: "t", + completed_at: "t", + }; + const thinking: CollaborationMessage = { + message_id: "plan_think", + role: "ai", + content: "", + name: "协调者", + agent_run_id: "agent_run_coordinator", + visibility: "visible", + created_at: "t", + completed_at: "t", + metadata: { reasoning_content: "先拆读者与覆盖范围,再装配三种策略。" }, + }; + const output = ai( + "plan_out", + "agent_run_coordinator", + "已装配均衡、深度、快速三种协作策略,下面给出方案卡。", + ); + output.completed_at = "t"; + const mapped = toLangGraphMessages([process, toolResult, thinking, output]); + const kwargs = (mapped[2] as { additional_kwargs?: Record }).additional_kwargs; + assert.equal(kwargs?.reasoning_content, "先拆读者与覆盖范围,再装配三种策略。"); + const card = buildPlanGroupCard( + [ + { + id: "p1", + proposal_group_id: "g1", + title: "方案", + strategy: "balanced_review", + summary: "", + rationale: "", + estimated_duration_seconds: 1, + estimated_cost_level: "low", + requirement_revision: 1, + roles: [], + nodes: [], + edges: [], + quality_gates: [], + report_structure_id: null, + validation: { ok: true, errors: [] }, + revision: 1, + status: "proposed", + }, + ], + null, + 1, + ); + assert.deepEqual(groupMessages([...mapped, card], (group) => group.type), [ + "assistant:processing", + "assistant", + "assistant:business-card", + ]); +}); + test("BaseStream 外观提供 MessageList 所需字段", () => { const thread = buildBaseStreamFacade([], true); assert.deepEqual(thread.messages, []); @@ -234,3 +331,39 @@ test("BaseStream 外观提供 MessageList 所需字段", () => { assert.equal(thread.isThreadLoading, false); assert.equal(typeof thread.stop, "function"); }); + +test("report.delta 投影为 write_file:流式中无 tool 返回,完成后补 tool 结果", () => { + const writing = buildReportWriteFileMessages({ + sessionId: "s1", + markdown: "# 草稿", + writing: true, + writer: agentRun({ + agent_run_id: "agent_run_n-write", + display_name: "报告撰写", + role: "撰写员", + }), + }); + assert.equal(writing.length, 1); + const aiMessage = writing[0] as { type: string; tool_calls?: { name: string; args: { path: string; content: string } }[] }; + assert.equal(aiMessage.type, "ai"); + assert.equal(aiMessage.tool_calls?.[0].name, "write_file"); + assert.equal(aiMessage.tool_calls?.[0].args.path, REPORT_OUTPUT_PATH); + assert.equal(aiMessage.tool_calls?.[0].args.content, "# 草稿"); + + const done = buildReportWriteFileMessages({ + sessionId: "s1", + markdown: "# 正式稿", + writing: false, + writer: null, + }); + assert.equal(done.length, 2); + assert.equal(done[1].type, "tool"); + assert.equal((done[1] as { tool_call_id?: string }).tool_call_id, `${writing[0].id}-file`); +}); + +test("空草稿且未在写入时不注入 write_file", () => { + assert.deepEqual( + buildReportWriteFileMessages({ sessionId: "s1", markdown: "", writing: false }), + [], + ); +}); diff --git a/frontend-web/src/strategy-components/api/deep-research.ts b/frontend-web/src/strategy-components/api/deep-research.ts index e0974926..515c5473 100644 --- a/frontend-web/src/strategy-components/api/deep-research.ts +++ b/frontend-web/src/strategy-components/api/deep-research.ts @@ -174,12 +174,20 @@ export function isTerminalJobStatus(status: JobStatus): boolean { async function readJson(res: Response, ctx: string): Promise { if (!res.ok) { let detail = `${ctx} failed (${res.status})`; + let body: unknown; try { - const body = await res.json(); - detail = body.detail || detail; + body = await res.json(); + const record = body && typeof body === "object" ? body as Record : {}; + detail = typeof record.detail === "string" ? record.detail : detail; } catch { // ignore parse error } + console.error(`[深度研究] ${ctx}_failed`, { + status: res.status, + url: res.url, + detail, + body, + }); throw new Error(detail); } return res.json() as Promise; @@ -323,12 +331,25 @@ export async function getSession(id: string): Promise { export async function updateSession( id: string, - patch: { title?: string; status?: "collecting" | "awaiting_report" }, + patch: { + title?: string; + status?: "collecting" | "awaiting_report"; + collectorMaterials?: Record[]; + }, ): Promise { const res = await apiFetch(`${BASE()}/sessions/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(patch), + body: JSON.stringify({ + ...(patch.title !== undefined ? { title: patch.title } : {}), + ...(patch.status !== undefined ? { status: patch.status } : {}), + ...(patch.collectorMaterials !== undefined + ? { + collector_materials: patch.collectorMaterials, + collectorMaterials: patch.collectorMaterials, + } + : {}), + }), }); return normalizeSession(await readJson(res, "updateSession")); } @@ -352,27 +373,90 @@ export interface StartJobOptions { entry?: "chat" | "legacy" | "regenerate"; /** Report-config card choices merged over the frozen session snapshot. */ config?: Record; + /** Topic text frozen by the browser. Empty materials still write a report. */ + query?: string; + title?: string; /** Compact collector evidence frozen before the report job starts. */ collectorMaterials?: Record[]; + /** Skip harvested / leftover sources and take the no-material writer. */ + forceNoMaterials?: boolean; } export async function startJob( sessionId: string, optionsOrRequestId?: StartJobOptions | string, -): Promise<{ jobId: string; sessionId: string; status: string; reused: boolean }> { +): Promise<{ + jobId: string; + sessionId: string; + status: string; + reused: boolean; + collectorMaterialCount?: number; + persistedSourceCount?: number; + diagnostics?: Array<{ + code?: string; + stage?: string; + message?: string; + recoverable?: boolean; + errorType?: string; + detail?: string; + [key: string]: unknown; + }>; +}> { const opts: StartJobOptions = typeof optionsOrRequestId === "string" ? { requestId: optionsOrRequestId } : optionsOrRequestId ?? {}; - const res = await apiFetch(`${BASE()}/sessions/${sessionId}/jobs`, { + const entry = opts.entry ?? "legacy"; + const collectorMaterials = opts.collectorMaterials ?? (entry === "chat" ? [] : null); + // First write posts materials to the dedicated report API. GET /jobs/{id}/stream + // only replays progress and cannot carry evidence. + const path = entry === "chat" + ? `${BASE()}/sessions/${sessionId}/report` + : `${BASE()}/sessions/${sessionId}/jobs`; + const res = await apiFetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - request_id: opts.requestId ?? createClientId(), - entry: opts.entry ?? "legacy", - config: opts.config ?? null, - collector_materials: opts.collectorMaterials ?? null, - }), + body: JSON.stringify( + entry === "chat" + ? { + request_id: opts.requestId ?? createClientId(), + query: opts.query ?? "", + title: opts.title ?? "", + config: opts.config ?? null, + collector_materials: collectorMaterials ?? [], + collectorMaterials: collectorMaterials ?? [], + force_no_materials: opts.forceNoMaterials === true, + forceNoMaterials: opts.forceNoMaterials === true, + } + : { + request_id: opts.requestId ?? createClientId(), + entry, + config: opts.config ?? null, + collector_materials: collectorMaterials, + collectorMaterials, + }, + ), }); - return readJson(res, "startJob"); + const started = await readJson<{ + jobId: string; + sessionId: string; + status: string; + reused: boolean; + collectorMaterialCount?: number; + persistedSourceCount?: number; + diagnostics?: Array<{ + code?: string; + stage?: string; + message?: string; + recoverable?: boolean; + errorType?: string; + detail?: string; + [key: string]: unknown; + }>; + }>(res, entry === "chat" ? "writeReport" : "startJob"); + for (const item of started.diagnostics ?? []) { + const code = String(item.code || "write_report_diagnostic"); + console.warn(`[深度研究] ${code}`, item); + } + return started; } export async function getJob(jobId: string): Promise { @@ -384,6 +468,9 @@ export async function cancelJob( jobId: string, ): Promise<{ jobId: string; status: string }> { const res = await apiFetch(`${BASE()}/jobs/${jobId}/cancel`, { method: "POST" }); + if (res.status === 409) { + return { jobId, status: "cancelled" }; + } return readJson(res, "cancelJob"); } @@ -402,6 +489,54 @@ export async function resumeJob( // ── SSE streaming ──────────────────────────────────────────────────────────── +const DIAGNOSTIC_EVENT_TYPES = new Set([ + "warning", + "error", + "report_reset", +]); + +/** + * Print backend diagnostics in the browser console. + * + * Production operators cannot always read Gateway logs. These frames already + * travel on the job SSE stream; echoing them here makes the real exception + * (errorType / diagnostic.detail / code) copyable from DevTools. + */ +function logDeepResearchDiagnostic(event: ProgressEvent): void { + const payload = event.payload ?? {}; + const jobFailed = + event.type === "job_status" && + (Boolean(payload.errorCode) || Boolean(payload.errorMessage) || payload.status === "failed"); + const degraded = payload.eventPersistenceDegraded === true; + const emptyCollection = + event.type === "collection_completed" && Number(payload.materialCount ?? 0) === 0; + if (!DIAGNOSTIC_EVENT_TYPES.has(event.type) && !jobFailed && !degraded && !emptyCollection) return; + + const summary = { + jobId: event.jobId, + sessionId: event.sessionId, + seq: event.seq, + type: event.type, + phase: event.phase, + code: payload.code ?? payload.errorCode ?? event.type, + message: payload.message ?? payload.errorMessage, + errorType: payload.errorType, + detail: payload.detail, + recoverable: payload.recoverable, + reason: payload.reason, + status: payload.status, + diagnostic: payload.diagnostic, + diagnostics: payload.diagnostics, + payload, + }; + const label = `[深度研究] ${String(summary.code)}`; + if (event.type === "error" || jobFailed) { + console.error(label, summary); + return; + } + console.warn(label, summary); +} + export interface StreamJobHandlers { onEvent: (event: ProgressEvent) => void; onTerminal?: (status: string) => void; @@ -428,7 +563,9 @@ export function streamJob( { method: "GET", signal: controller.signal }, ); if (!res.ok || !res.body) { - handlers.onError?.(`stream failed (${res.status})`); + const detail = `stream failed (${res.status})`; + console.error("[深度研究] stream_open_failed", { jobId, afterSeq, status: res.status }); + handlers.onError?.(detail); return; } const reader = res.body.getReader(); @@ -460,6 +597,7 @@ export function streamJob( } catch { continue; } + logDeepResearchDiagnostic(event); handlers.onEvent(event); // Check for terminal status in the payload. @@ -475,7 +613,9 @@ export function streamJob( } } catch (err) { if (!controller.signal.aborted) { - handlers.onError?.(err instanceof Error ? err.message : String(err)); + const detail = err instanceof Error ? err.message : String(err); + console.error("[深度研究] stream_disconnected", { jobId, afterSeq, detail, error: err }); + handlers.onError?.(detail); } } })(); @@ -1010,13 +1150,19 @@ export function useResearchActions() { sessionId, entry, config, + query, + title, collectorMaterials, + forceNoMaterials, }: { sessionId: string; entry?: "chat" | "legacy" | "regenerate"; config?: Record; + query?: string; + title?: string; collectorMaterials?: Record[]; - }) => startJob(sessionId, { entry, config, collectorMaterials }), + forceNoMaterials?: boolean; + }) => startJob(sessionId, { entry, config, query, title, collectorMaterials, forceNoMaterials }), onSuccess: (_result, { sessionId }) => { queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] }); queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY, sessionId] }); @@ -1026,15 +1172,32 @@ export function useResearchActions() { mutationFn: ({ sessionId, status, + collectorMaterials, }: { sessionId: string; status: "collecting" | "awaiting_report"; - }) => updateSession(sessionId, { status }), + collectorMaterials?: Record[]; + }) => updateSession(sessionId, { status, collectorMaterials }), onSuccess: (_result, { sessionId }) => { queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] }); queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY, sessionId] }); }, }), + persistCollectorMaterials: useMutation({ + mutationFn: ({ + sessionId, + collectorMaterials, + }: { + sessionId: string; + collectorMaterials: Record[]; + quiet?: boolean; + }) => updateSession(sessionId, { collectorMaterials }), + onSuccess: (_result, { sessionId, quiet }) => { + if (quiet) return; + queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] }); + queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY, sessionId] }); + }, + }), cancelJob: useMutation({ mutationFn: (jobId: string) => cancelJob(jobId), onSuccess: () => { diff --git a/frontend-web/vite.config.ts b/frontend-web/vite.config.ts index 0094b5be..7a2adba1 100644 --- a/frontend-web/vite.config.ts +++ b/frontend-web/vite.config.ts @@ -1,17 +1,48 @@ +import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; +import { defineConfig, type Plugin } from "vite"; import sourceIdentifierPlugin from "vite-plugin-source-identifier"; const rootDir = fileURLToPath(new URL(".", import.meta.url)); + +// 依赖预构建缓存被重建后(pnpm install / 重复启动 vite / --force), +// 残留的旧浏览器标签页仍会按旧版本号(?v=)请求已删除的 chunk, +// 每次都在终端刷 "The file does not exist ... in the optimize deps directory"。 +// 该插件拦截这类「文件确实不存在」的 deps 请求,返回一段触发 location.reload() +// 的模块脚本,让旧页面自愈到新版本;sessionStorage 防止真正的坏页面无限刷新。 +const staleDepsChunkSelfReload = (): Plugin => ({ + name: "stale-deps-chunk-self-reload", + configureServer(server) { + const depsDir = path.join(rootDir, "node_modules", ".vite", "deps"); + server.middlewares.use((req, res, next) => { + const match = /^\/node_modules\/\.vite\/deps\/([^?]+)/.exec(req.url ?? ""); + if (!match) { + next(); + return; + } + const filePath = path.join(depsDir, decodeURIComponent(match[1])); + if (existsSync(filePath)) { + next(); + return; + } + res.setHeader("Content-Type", "application/javascript"); + res.end( + "if(!sessionStorage.getItem('__vite_stale_chunk_reloaded__')){" + + "sessionStorage.setItem('__vite_stale_chunk_reloaded__','1');" + + "location.reload();}", + ); + }); + }, +}); // Tailwind 4 emits CSS cascade layers, which require Chrome 99+. Chrome 100 // is therefore the practical lower bound for this Win7 legacy build. const LEGACY_CHROME = 100 << 16; export default defineConfig({ - plugins: [sourceIdentifierPlugin(), react()], + plugins: [staleDepsChunkSelfReload(), sourceIdentifierPlugin(), react()], css: { // Also used by the production CSS minifier. Development CSS is handled // by the matching PostCSS compatibility pass in postcss.config.js. @@ -21,20 +52,20 @@ export default defineConfig({ }, }, }, - resolve: { - alias: [ - { - // react-syntax-highlighter@16.1.1 exposes an async `sqlMore` - // loader, but its own highlight.js@10.x dependency has no - // `sql_more` module. Reuse the bundled SQL grammar so Rollup can - // resolve every generated language loader without leaving a broken - // browser-time external import. - find: /^highlight\.js\/lib\/languages\/sql_more$/, - replacement: "highlight.js/lib/languages/sql", - }, - { - find: "@/env", - replacement: path.resolve(rootDir, "src/env.ts"), + resolve: { + alias: [ + { + // react-syntax-highlighter@16.1.1 exposes an async `sqlMore` + // loader, but its own highlight.js@10.x dependency has no + // `sql_more` module. Reuse the bundled SQL grammar so Rollup can + // resolve every generated language loader without leaving a broken + // browser-time external import. + find: /^highlight\.js\/lib\/languages\/sql_more$/, + replacement: "highlight.js/lib/languages/sql", + }, + { + find: "@/env", + replacement: path.resolve(rootDir, "src/env.ts"), }, { find: "next/navigation", @@ -64,9 +95,9 @@ export default defineConfig({ target: "chrome100", }, server: { - // 与 scripts/env.sh FRONTEND_PORT=5174、圆桌 wujie 嵌入文档一致。 - port: 5173, - // 无界宿主跨域 fetch 子应用静态资源时,Vite 需回显请求的 Origin。 - cors: true, + // 与 scripts/env.sh FRONTEND_PORT=5174、圆桌 wujie 嵌入文档一致。 + port: 5173, + // 无界宿主跨域 fetch 子应用静态资源时,Vite 需回显请求的 Origin。 + cors: true, }, });