diff --git a/.gitignore b/.gitignore index ba42257b..663c0f2e 100644 --- a/.gitignore +++ b/.gitignore @@ -84,5 +84,12 @@ offline-backend-20260512/backend/tests/* deploy .history +# Repo-root offline dependency pack (local only) +/dist/ + +# Frontend offline pack working directories +frontend-web/.offline-pack-build/ +frontend-web/.offline-extract-test/ + # Frontend skills components must be tracked (excluded from the broad skills/ ignore above) !frontend-web/src/components/workspace/skills/ diff --git a/frontend-web/src/components/workspace/messages/markdown-content.tsx b/frontend-web/src/components/workspace/messages/markdown-content.tsx index dc0888c5..739bcd77 100644 --- a/frontend-web/src/components/workspace/messages/markdown-content.tsx +++ b/frontend-web/src/components/workspace/messages/markdown-content.tsx @@ -8,7 +8,6 @@ import { type MessageResponseProps, } from "@/components/ai-elements/message"; import { streamdownPlugins } from "@/core/streamdown"; -import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; import { CitationLink } from "../citations/citation-link"; @@ -36,13 +35,13 @@ export function MarkdownContent({ }: MarkdownContentProps) { const components = useMemo(() => { return { - a: (props: AnchorHTMLAttributes) => { - if (props.href?.startsWith("#reference-")) { - return ; - } - if (typeof props.children === "string") { - const match = /^citation:(.+)$/.exec(props.children); - if (match) { + a: (props: AnchorHTMLAttributes) => { + if (props.href?.startsWith("#reference-")) { + return ; + } + if (typeof props.children === "string") { + const match = /^citation:(.+)$/.exec(props.children); + if (match) { const [, text] = match; return {text}; } @@ -67,8 +66,6 @@ export function MarkdownContent({ if (!content) return null; - const displayContent = desensitize(content); - return ( - {displayContent} + {content} ); } diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index 4daa04ce..3c35df6f 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -42,7 +42,6 @@ import { type RuntimeDisplaySource, } from "@/core/skill-display/runtime"; import { humanMessagePlugins } from "@/core/streamdown"; -import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; import { CitationLink } from "../citations/citation-link"; @@ -259,7 +258,7 @@ function MessageContent_({
- {desensitize(contentToDisplay)} + {contentToDisplay}
@@ -273,7 +272,7 @@ function MessageContent_({ - {desensitize(reasoningContent)} + {reasoningContent} - {desensitize(contentToDisplay)} + {contentToDisplay} ) : null; return ( diff --git a/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts b/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts index c9b61a34..4f7f262d 100644 --- a/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts +++ b/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts @@ -57,6 +57,16 @@ export interface UseDraftPersistenceOptions { * 否则保持原 false 让进入 Step 2 时正常初始化。 */ setStep2InitGate: (initialized: boolean) => void; + /** + * Step 1 是否正在等待任务理解回复。由 true→false 视为「本轮模型回答完」, + * 触发一次自动保存(详见 autoSave 的两个 effect)。 + */ + intentSending: boolean; + /** + * Step 2 已收尾(streaming===false)的对话气泡数量。每次递增意味着总控/某个 + * 子智能体刚交付完一轮,触发一次自动保存。流式增量不改变该计数,因此不会刷屏式保存。 + */ + step2FinalizedCount: number; } export interface UseDraftPersistenceReturn { @@ -96,6 +106,8 @@ export function useDraftPersistence( hydrateStep2, cancelAllStreams, setStep2InitGate, + intentSending, + step2FinalizedCount, } = options; const [drafts, setDrafts] = useState([]); @@ -112,6 +124,11 @@ export function useDraftPersistence( const currentDraftIdRef = useRef(null); // 串行化所有写操作,避免并发 create。 const saveQueueRef = useRef>(Promise.resolve()); + // 当前步骤镜像 —— 自动保存(不带 furthestStep)落到 create 分支时,用它兜底建档步骤。 + const currentStepRef = useRef(currentStep); + useEffect(() => { + currentStepRef.current = currentStep; + }, [currentStep]); // 包一层 setter:同时更新 state 与同步 ref。主页面通过它 setCurrentDraftId(null)。 const setCurrentDraftId = useCallback>>( @@ -157,9 +174,15 @@ export function useDraftPersistence( * 串行执行一次保存:有 id 走 update(不带 title,保护重命名);无 id 走 create * (写一次派生标题)。create/update 成功后同步 currentDraftIdRef。 * 返回 draft id(无可保存内容返回 null)。 + * + * `furthestStep` 可选: + * - 传值(前进切换 / 手动保存 / ensureDraft)→ update 时一并推进 furthest_step; + * - 省略(每轮模型回答完的自动保存)→ update **不带** furthest_step,只回写 + * step1/step2 内容,避免把「回到上一步继续研讨」误判成 furthest 回退。create + * 分支用 currentStepRef 兜底建档步骤。 */ const enqueueSave = useCallback( - (furthestStep: 1 | 2 | 3): Promise => { + (furthestStep?: 1 | 2 | 3): Promise => { const run = async (): Promise => { const step1 = getStep1Snapshot(); const step2 = getStep2Snapshot(); @@ -167,7 +190,11 @@ export function useDraftPersistence( const id = currentDraftIdRef.current; if (id) { - const updated = await updateDraft(id, { furthestStep, step1, step2 }); + const updated = await updateDraft(id, { + ...(furthestStep !== undefined ? { furthestStep } : {}), + step1, + step2, + }); if (updated) return updated.id; // 服务端已不存在(被删/换账号)→ 落回新建。 } @@ -176,7 +203,7 @@ export function useDraftPersistence( intentReady: step1.intentReady, interactiveMessages: step1.interactiveMessages, }), - furthestStep, + furthestStep: furthestStep ?? ((currentStepRef.current as 1 | 2 | 3) || 1), step1, step2, }); @@ -227,6 +254,41 @@ export function useDraftPersistence( // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentStep]); + /** + * 每轮模型回答完成后落一次草稿。不带 furthestStep(见 enqueueSave),只回写内容。 + * 仅当本次保存是「新建」时才刷新列表 —— 普通增量回写无需让下拉重排/多发一个 GET。 + */ + const autoSave = useCallback(async (): Promise => { + const hadId = currentDraftIdRef.current; + const id = await enqueueSave(); + if (id && !hadId) await refreshList(); + }, [enqueueSave, refreshList]); + + // ── 触发器 1:Step 1 任务理解一轮回复结束(isIntentSending true→false)。 ── + const prevIntentSendingRef = useRef(intentSending); + useEffect(() => { + const was = prevIntentSendingRef.current; + prevIntentSendingRef.current = intentSending; + // 此刻 React 已 flush 完终态 setState(气泡内容/INTENT_READY), + // 快照 getter 读到的是收尾后的最新状态,不会丢最后一段。 + if (was && !intentSending) void autoSave(); + }, [intentSending, autoSave]); + + // ── 触发器 2:Step 2 某个气泡刚收尾(已收尾计数递增)。 ── + // 加载草稿会把历史对话整批 hydrate 进来,计数从 0 跳到 N —— 那不是一次真实交付, + // 用 suppress latch 跳过这一跳变(handleLoadDraft 在 hydrate 前置位)。 + const prevFinalizedCountRef = useRef(step2FinalizedCount); + const suppressStep2AutoSaveRef = useRef(false); + useEffect(() => { + const prev = prevFinalizedCountRef.current; + prevFinalizedCountRef.current = step2FinalizedCount; + if (suppressStep2AutoSaveRef.current) { + suppressStep2AutoSaveRef.current = false; + return; + } + if (step2FinalizedCount > prev) void autoSave(); + }, [step2FinalizedCount, autoSave]); + const handleLoadDraft = useCallback( (id: string) => { void (async () => { @@ -248,6 +310,9 @@ export function useDraftPersistence( // 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect // 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。 loadedFromDraftRef.current = true; + // hydrate 会让 step2 已收尾计数从 0 跳到 N,抑制由此触发的「假交付」自动保存, + // 避免刚加载就把同样内容原样回写一遍、并把该草稿顶到列表最前。 + suppressStep2AutoSaveRef.current = true; if (draft.step1) hydrateStep1(draft.step1 as Step1Snapshot); if (draft.step2) hydrateStep2(draft.step2 as Step2Snapshot); diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 91832475..71e990b1 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -17,11 +17,12 @@ * 1) special 顺序派活,**不要**改回 `Promise.all`(详见 §5.4) * 2) step1InitGenRef / step2InitRef 闸门用于规避 StrictMode 双 mount 副作用 * 3) 离开 Step 2 不销毁 threadIds,允许返回继续看 - * 4) 草稿自动保存仅在前进切换(1→2、2→3)触发,后退不保存 + * 4) 草稿自动保存:前进切换(1→2、2→3)推进 furthestStep;此外每轮模型回答完 + * (Step 1 一轮任务理解结束 / Step 2 每个气泡收尾)也增量回写一次,后者不动 furthestStep * 5) Persona 抽屉改的 model 不会立即重启编排,下一轮才生效 * 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级 */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Activity, Check, @@ -173,11 +174,21 @@ export default function RoundtablePlanningPage() { }; }, [step1.intentReady, step1.interactiveMessages]); + // Step 2 已收尾(非 streaming)的气泡数量 —— 递增即「某轮模型刚交付完」, + // 作为草稿自动保存的触发信号(详见 useDraftPersistence)。流式增量不计入, + // 所以不会在 token 级别刷屏式保存。 + const step2FinalizedCount = useMemo( + () => step2.step2RoundtableDialogues.filter((d) => !d.streaming).length, + [step2.step2RoundtableDialogues], + ); + // 草稿 hook —— 同时依赖 step1 + step2 的 snapshot / hydrate。 const drafts = useDraftPersistence({ currentStep, setCurrentStep, triggerToast, + intentSending: step1.isIntentSending, + step2FinalizedCount, getStep1Snapshot: () => ({ intentThreadId: step1.intentThreadId, intentReady: step1.intentReady,