fix(frontend): show raw chat messages and improve roundtable draft saves
This commit is contained in:
1 parent
0d8cdf3afb
commit
93e702f9ca
5 files changed
+99
-20
No files matched your search
@@ -84,5 +84,12 @@ offline-backend-20260512/backend/tests/*
|
|||||||
deploy
|
deploy
|
||||||
.history
|
.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 skills components must be tracked (excluded from the broad skills/ ignore above)
|
||||||
!frontend-web/src/components/workspace/skills/
|
!frontend-web/src/components/workspace/skills/
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
type MessageResponseProps,
|
type MessageResponseProps,
|
||||||
} from "@/components/ai-elements/message";
|
} from "@/components/ai-elements/message";
|
||||||
import { streamdownPlugins } from "@/core/streamdown";
|
import { streamdownPlugins } from "@/core/streamdown";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { CitationLink } from "../citations/citation-link";
|
import { CitationLink } from "../citations/citation-link";
|
||||||
@@ -36,13 +35,13 @@ export function MarkdownContent({
|
|||||||
}: MarkdownContentProps) {
|
}: MarkdownContentProps) {
|
||||||
const components = useMemo(() => {
|
const components = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
if (props.href?.startsWith("#reference-")) {
|
if (props.href?.startsWith("#reference-")) {
|
||||||
return <CitationLink {...props} />;
|
return <CitationLink {...props} />;
|
||||||
}
|
}
|
||||||
if (typeof props.children === "string") {
|
if (typeof props.children === "string") {
|
||||||
const match = /^citation:(.+)$/.exec(props.children);
|
const match = /^citation:(.+)$/.exec(props.children);
|
||||||
if (match) {
|
if (match) {
|
||||||
const [, text] = match;
|
const [, text] = match;
|
||||||
return <CitationLink {...props}>{text}</CitationLink>;
|
return <CitationLink {...props}>{text}</CitationLink>;
|
||||||
}
|
}
|
||||||
@@ -67,8 +66,6 @@ export function MarkdownContent({
|
|||||||
|
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|
||||||
const displayContent = desensitize(content);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageResponse
|
<MessageResponse
|
||||||
className={className}
|
className={className}
|
||||||
@@ -76,7 +73,7 @@ export function MarkdownContent({
|
|||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={rehypePlugins}
|
||||||
components={components}
|
components={components}
|
||||||
>
|
>
|
||||||
{displayContent}
|
{content}
|
||||||
</MessageResponse>
|
</MessageResponse>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,6 @@ import {
|
|||||||
type RuntimeDisplaySource,
|
type RuntimeDisplaySource,
|
||||||
} from "@/core/skill-display/runtime";
|
} from "@/core/skill-display/runtime";
|
||||||
import { humanMessagePlugins } from "@/core/streamdown";
|
import { humanMessagePlugins } from "@/core/streamdown";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { CitationLink } from "../citations/citation-link";
|
import { CitationLink } from "../citations/citation-link";
|
||||||
@@ -259,7 +258,7 @@ function MessageContent_({
|
|||||||
<TaskTrigger title="">
|
<TaskTrigger title="">
|
||||||
<div className="text-muted-foreground flex w-full cursor-default items-center gap-2 text-sm select-none">
|
<div className="text-muted-foreground flex w-full cursor-default items-center gap-2 text-sm select-none">
|
||||||
<Loader className="size-4" />
|
<Loader className="size-4" />
|
||||||
<span>{desensitize(contentToDisplay)}</span>
|
<span>{contentToDisplay}</span>
|
||||||
</div>
|
</div>
|
||||||
</TaskTrigger>
|
</TaskTrigger>
|
||||||
</Task>
|
</Task>
|
||||||
@@ -273,7 +272,7 @@ function MessageContent_({
|
|||||||
<AIElementMessageContent className={className}>
|
<AIElementMessageContent className={className}>
|
||||||
<Reasoning isStreaming={isLoading}>
|
<Reasoning isStreaming={isLoading}>
|
||||||
<ReasoningTrigger />
|
<ReasoningTrigger />
|
||||||
<ReasoningContent>{desensitize(reasoningContent)}</ReasoningContent>
|
<ReasoningContent>{reasoningContent}</ReasoningContent>
|
||||||
</Reasoning>
|
</Reasoning>
|
||||||
<MessageTokenUsage
|
<MessageTokenUsage
|
||||||
enabled={tokenUsageEnabled}
|
enabled={tokenUsageEnabled}
|
||||||
@@ -292,7 +291,7 @@ function MessageContent_({
|
|||||||
components={components}
|
components={components}
|
||||||
parseIncompleteMarkdown={false}
|
parseIncompleteMarkdown={false}
|
||||||
>
|
>
|
||||||
{desensitize(contentToDisplay)}
|
{contentToDisplay}
|
||||||
</AIElementMessageResponse>
|
</AIElementMessageResponse>
|
||||||
) : null;
|
) : null;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ export interface UseDraftPersistenceOptions {
|
|||||||
* 否则保持原 false 让进入 Step 2 时正常初始化。
|
* 否则保持原 false 让进入 Step 2 时正常初始化。
|
||||||
*/
|
*/
|
||||||
setStep2InitGate: (initialized: boolean) => void;
|
setStep2InitGate: (initialized: boolean) => void;
|
||||||
|
/**
|
||||||
|
* Step 1 是否正在等待任务理解回复。由 true→false 视为「本轮模型回答完」,
|
||||||
|
* 触发一次自动保存(详见 autoSave 的两个 effect)。
|
||||||
|
*/
|
||||||
|
intentSending: boolean;
|
||||||
|
/**
|
||||||
|
* Step 2 已收尾(streaming===false)的对话气泡数量。每次递增意味着总控/某个
|
||||||
|
* 子智能体刚交付完一轮,触发一次自动保存。流式增量不改变该计数,因此不会刷屏式保存。
|
||||||
|
*/
|
||||||
|
step2FinalizedCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseDraftPersistenceReturn {
|
export interface UseDraftPersistenceReturn {
|
||||||
@@ -96,6 +106,8 @@ export function useDraftPersistence(
|
|||||||
hydrateStep2,
|
hydrateStep2,
|
||||||
cancelAllStreams,
|
cancelAllStreams,
|
||||||
setStep2InitGate,
|
setStep2InitGate,
|
||||||
|
intentSending,
|
||||||
|
step2FinalizedCount,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const [drafts, setDrafts] = useState<DraftListItem[]>([]);
|
const [drafts, setDrafts] = useState<DraftListItem[]>([]);
|
||||||
@@ -112,6 +124,11 @@ export function useDraftPersistence(
|
|||||||
const currentDraftIdRef = useRef<string | null>(null);
|
const currentDraftIdRef = useRef<string | null>(null);
|
||||||
// 串行化所有写操作,避免并发 create。
|
// 串行化所有写操作,避免并发 create。
|
||||||
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||||
|
// 当前步骤镜像 —— 自动保存(不带 furthestStep)落到 create 分支时,用它兜底建档步骤。
|
||||||
|
const currentStepRef = useRef<number>(currentStep);
|
||||||
|
useEffect(() => {
|
||||||
|
currentStepRef.current = currentStep;
|
||||||
|
}, [currentStep]);
|
||||||
|
|
||||||
// 包一层 setter:同时更新 state 与同步 ref。主页面通过它 setCurrentDraftId(null)。
|
// 包一层 setter:同时更新 state 与同步 ref。主页面通过它 setCurrentDraftId(null)。
|
||||||
const setCurrentDraftId = useCallback<React.Dispatch<React.SetStateAction<string | null>>>(
|
const setCurrentDraftId = useCallback<React.Dispatch<React.SetStateAction<string | null>>>(
|
||||||
@@ -157,9 +174,15 @@ export function useDraftPersistence(
|
|||||||
* 串行执行一次保存:有 id 走 update(不带 title,保护重命名);无 id 走 create
|
* 串行执行一次保存:有 id 走 update(不带 title,保护重命名);无 id 走 create
|
||||||
* (写一次派生标题)。create/update 成功后同步 currentDraftIdRef。
|
* (写一次派生标题)。create/update 成功后同步 currentDraftIdRef。
|
||||||
* 返回 draft id(无可保存内容返回 null)。
|
* 返回 draft id(无可保存内容返回 null)。
|
||||||
|
*
|
||||||
|
* `furthestStep` 可选:
|
||||||
|
* - 传值(前进切换 / 手动保存 / ensureDraft)→ update 时一并推进 furthest_step;
|
||||||
|
* - 省略(每轮模型回答完的自动保存)→ update **不带** furthest_step,只回写
|
||||||
|
* step1/step2 内容,避免把「回到上一步继续研讨」误判成 furthest 回退。create
|
||||||
|
* 分支用 currentStepRef 兜底建档步骤。
|
||||||
*/
|
*/
|
||||||
const enqueueSave = useCallback(
|
const enqueueSave = useCallback(
|
||||||
(furthestStep: 1 | 2 | 3): Promise<string | null> => {
|
(furthestStep?: 1 | 2 | 3): Promise<string | null> => {
|
||||||
const run = async (): Promise<string | null> => {
|
const run = async (): Promise<string | null> => {
|
||||||
const step1 = getStep1Snapshot();
|
const step1 = getStep1Snapshot();
|
||||||
const step2 = getStep2Snapshot();
|
const step2 = getStep2Snapshot();
|
||||||
@@ -167,7 +190,11 @@ export function useDraftPersistence(
|
|||||||
|
|
||||||
const id = currentDraftIdRef.current;
|
const id = currentDraftIdRef.current;
|
||||||
if (id) {
|
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;
|
if (updated) return updated.id;
|
||||||
// 服务端已不存在(被删/换账号)→ 落回新建。
|
// 服务端已不存在(被删/换账号)→ 落回新建。
|
||||||
}
|
}
|
||||||
@@ -176,7 +203,7 @@ export function useDraftPersistence(
|
|||||||
intentReady: step1.intentReady,
|
intentReady: step1.intentReady,
|
||||||
interactiveMessages: step1.interactiveMessages,
|
interactiveMessages: step1.interactiveMessages,
|
||||||
}),
|
}),
|
||||||
furthestStep,
|
furthestStep: furthestStep ?? ((currentStepRef.current as 1 | 2 | 3) || 1),
|
||||||
step1,
|
step1,
|
||||||
step2,
|
step2,
|
||||||
});
|
});
|
||||||
@@ -227,6 +254,41 @@ export function useDraftPersistence(
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [currentStep]);
|
}, [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(
|
const handleLoadDraft = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -248,6 +310,9 @@ export function useDraftPersistence(
|
|||||||
// 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect
|
// 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect
|
||||||
// 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。
|
// 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。
|
||||||
loadedFromDraftRef.current = true;
|
loadedFromDraftRef.current = true;
|
||||||
|
// hydrate 会让 step2 已收尾计数从 0 跳到 N,抑制由此触发的「假交付」自动保存,
|
||||||
|
// 避免刚加载就把同样内容原样回写一遍、并把该草稿顶到列表最前。
|
||||||
|
suppressStep2AutoSaveRef.current = true;
|
||||||
|
|
||||||
if (draft.step1) hydrateStep1(draft.step1 as Step1Snapshot);
|
if (draft.step1) hydrateStep1(draft.step1 as Step1Snapshot);
|
||||||
if (draft.step2) hydrateStep2(draft.step2 as Step2Snapshot);
|
if (draft.step2) hydrateStep2(draft.step2 as Step2Snapshot);
|
||||||
|
|||||||
@@ -17,11 +17,12 @@
|
|||||||
* 1) special 顺序派活,**不要**改回 `Promise.all`(详见 §5.4)
|
* 1) special 顺序派活,**不要**改回 `Promise.all`(详见 §5.4)
|
||||||
* 2) step1InitGenRef / step2InitRef 闸门用于规避 StrictMode 双 mount 副作用
|
* 2) step1InitGenRef / step2InitRef 闸门用于规避 StrictMode 双 mount 副作用
|
||||||
* 3) 离开 Step 2 不销毁 threadIds,允许返回继续看
|
* 3) 离开 Step 2 不销毁 threadIds,允许返回继续看
|
||||||
* 4) 草稿自动保存仅在前进切换(1→2、2→3)触发,后退不保存
|
* 4) 草稿自动保存:前进切换(1→2、2→3)推进 furthestStep;此外每轮模型回答完
|
||||||
|
* (Step 1 一轮任务理解结束 / Step 2 每个气泡收尾)也增量回写一次,后者不动 furthestStep
|
||||||
* 5) Persona 抽屉改的 model 不会立即重启编排,下一轮才生效
|
* 5) Persona 抽屉改的 model 不会立即重启编排,下一轮才生效
|
||||||
* 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级
|
* 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Check,
|
Check,
|
||||||
@@ -173,11 +174,21 @@ export default function RoundtablePlanningPage() {
|
|||||||
};
|
};
|
||||||
}, [step1.intentReady, step1.interactiveMessages]);
|
}, [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。
|
// 草稿 hook —— 同时依赖 step1 + step2 的 snapshot / hydrate。
|
||||||
const drafts = useDraftPersistence({
|
const drafts = useDraftPersistence({
|
||||||
currentStep,
|
currentStep,
|
||||||
setCurrentStep,
|
setCurrentStep,
|
||||||
triggerToast,
|
triggerToast,
|
||||||
|
intentSending: step1.isIntentSending,
|
||||||
|
step2FinalizedCount,
|
||||||
getStep1Snapshot: () => ({
|
getStep1Snapshot: () => ({
|
||||||
intentThreadId: step1.intentThreadId,
|
intentThreadId: step1.intentThreadId,
|
||||||
intentReady: step1.intentReady,
|
intentReady: step1.intentReady,
|
||||||
|
|||||||
Reference in new issue
Block a user