+
- {phaseLabel ?? `并行 ${tabs.length} 席`}
+ {stripLabel}
{runningCount > 0 && {runningCount} 进行中}
- {tabs.map((tab) => {
+ {visibleTabs.map((tab) => {
const streaming = streamingAgentRunIds.has(tab.agent_run_id);
const awaiting = awaitingAgentRunIds.has(tab.agent_run_id);
const status = avatarStatus(tab, streaming, awaiting);
@@ -108,6 +123,11 @@ export const AgentStageSelector = memo(function AgentStageSelector({
);
})}
+ {onShowAll && (tabs.length <= 1 || Boolean(pinnedAgent)) ? (
+
+ ) : 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,
},
});