fix(frontend): show raw chat messages and improve roundtable draft saves

This commit is contained in:
fengxiangdebaozi committed 2026-06-02 10:45:20 +08:00
1 parent 0d8cdf3afb
commit 93e702f9ca
5 files changed
+92 -13

No files matched your search

+7
View File
@@ -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/
@@ -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";
@@ -67,8 +66,6 @@ export function MarkdownContent({
if (!content) return null;
const displayContent = desensitize(content);
return (
<MessageResponse
className={className}
@@ -76,7 +73,7 @@ export function MarkdownContent({
rehypePlugins={rehypePlugins}
components={components}
>
{displayContent}
{content}
</MessageResponse>
);
}
@@ -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_({
<TaskTrigger title="">
<div className="text-muted-foreground flex w-full cursor-default items-center gap-2 text-sm select-none">
<Loader className="size-4" />
<span>{desensitize(contentToDisplay)}</span>
<span>{contentToDisplay}</span>
</div>
</TaskTrigger>
</Task>
@@ -273,7 +272,7 @@ function MessageContent_({
<AIElementMessageContent className={className}>
<Reasoning isStreaming={isLoading}>
<ReasoningTrigger />
<ReasoningContent>{desensitize(reasoningContent)}</ReasoningContent>
<ReasoningContent>{reasoningContent}</ReasoningContent>
</Reasoning>
<MessageTokenUsage
enabled={tokenUsageEnabled}
@@ -292,7 +291,7 @@ function MessageContent_({
components={components}
parseIncompleteMarkdown={false}
>
{desensitize(contentToDisplay)}
{contentToDisplay}
</AIElementMessageResponse>
) : null;
return (
@@ -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<DraftListItem[]>([]);
@@ -112,6 +124,11 @@ export function useDraftPersistence(
const currentDraftIdRef = useRef<string | null>(null);
// 串行化所有写操作,避免并发 create。
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
// 当前步骤镜像 —— 自动保存(不带 furthestStep)落到 create 分支时,用它兜底建档步骤。
const currentStepRef = useRef<number>(currentStep);
useEffect(() => {
currentStepRef.current = currentStep;
}, [currentStep]);
// 包一层 setter:同时更新 state 与同步 ref。主页面通过它 setCurrentDraftId(null)。
const setCurrentDraftId = useCallback<React.Dispatch<React.SetStateAction<string | null>>>(
@@ -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<string | null> => {
(furthestStep?: 1 | 2 | 3): Promise<string | null> => {
const run = async (): Promise<string | null> => {
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<void> => {
const hadId = currentDraftIdRef.current;
const id = await enqueueSave();
if (id && !hadId) await refreshList();
}, [enqueueSave, refreshList]);
// ── 触发器 1:Step 1 任务理解一轮回复结束(isIntentSending true→false)。 ──
const prevIntentSendingRef = useRef<boolean>(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<number>(step2FinalizedCount);
const suppressStep2AutoSaveRef = useRef<boolean>(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);
@@ -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,