代码提交

This commit is contained in:
董宏凯 committed 2026-08-30 01:25:20 +08:00
1 parent 4ac7bc21cd
commit 17cf849361
110 files changed
+17007 -518

No files matched your search

+94 -135
View File
@@ -31,6 +31,7 @@ import {
import {
collectHumanMessageIds,
hasServerEchoedOptimisticHuman,
isStaleStreamFinish,
mergeStreamDisplayValues,
shouldDiscardTerminalOverlay,
} from "./stream-display";
@@ -190,11 +191,9 @@ function getRunMetadataStorage(): {
/**
* SDK ``useStream`` stores the in-flight run id at ``lg:stream:{threadId}``
* but **deletes that key on stream onSuccess**. A premature SSE close
* (proxy timeout / false-positive disconnect) looks like success, so the
* reconnect key vanishes while the backend run is still occupying the
* thread. We keep a second key the SDK never touches and only clear it
* after ``runs.get`` confirms a terminal status.
* but **deletes that key on stream onSuccess**. We keep a second key the SDK
* never touches so a 409 / dropped-SSE exception can still rejoin. Normal
* SSE close clears it immediately — do not wait for ``runs.get``.
*/
const ACTIVE_RUN_STORAGE_PREFIX = "df:active-run:";
@@ -276,36 +275,18 @@ export async function stopThreadRun(
/**
* 协助卡提交前:若上一轮还在流式(弱模型出卡后偶发未停干净),先取消服务端 run
* 并断开本地 SSE,避免 409「已有活跃 run」或用户点了选项却发不出去。
*
* 不在发送前 GET /runs —— 协助卡出现时图多半已经结束,为兜底去查会让每次
* 点选项都卡住。未在流式则直接发;真遇到 409 再走 confirmOrRejoin。
*/
export async function stopActiveThreadBeforeClarificationReply(
threadId: string | null | undefined,
thread: { isLoading?: boolean; stop: () => void | Promise<void> },
): Promise<void> {
let storedRunId: string | null = null;
if (threadId && typeof window !== "undefined") {
try {
storedRunId =
normalizeStoredRunId(
window.sessionStorage.getItem(`lg:stream:${threadId}`),
) || readActiveRunId(threadId);
} catch {
storedRunId = null;
}
if (!thread.isLoading) {
if (threadId) clearActiveRunId(threadId);
return;
}
// 协助卡出现时图已经 jump_to=end,run 多半已是 terminal。再 stop()/cancel
// 会再触发 onFinish → getState overlay,把刚发出的用户回复冻住、整段攒着出。
if (storedRunId && threadId) {
try {
const run = await getAPIClient().runs.get(threadId, storedRunId);
if (run && !isInflightRunStatus(run.status)) {
clearActiveRunId(threadId);
return;
}
} catch {
/* 404 / 网络:按下面的 best-effort stop 走 */
}
}
if (!thread.isLoading && !storedRunId) return;
try {
await stopThreadRun(threadId);
} catch {
@@ -320,7 +301,7 @@ export async function stopActiveThreadBeforeClarificationReply(
await new Promise((resolve) => setTimeout(resolve, 120));
}
function getStreamErrorMessage(error: unknown): string {
function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) {
return error;
}
@@ -340,21 +321,21 @@ function getStreamErrorMessage(error: unknown): string {
return nestedError;
}
}
return "Request failed.";
}
/**
* React production error #185 is the minified form of "Maximum update depth
* exceeded". It is a client render-loop guard, not a backend/model failure,
* so it must never be surfaced as the answer's stream error.
*/
function isReactMaximumUpdateDepthError(message: string): boolean {
return (
/maximum update depth exceeded/i.test(message) ||
/minified react error\s*#185\b/i.test(message) ||
/react\.dev\/errors\/185\b/i.test(message)
);
}
return "Request failed.";
}
/**
* React production error #185 is the minified form of "Maximum update depth
* exceeded". It is a client render-loop guard, not a backend/model failure,
* so it must never be surfaced as the answer's stream error.
*/
function isReactMaximumUpdateDepthError(message: string): boolean {
return (
/maximum update depth exceeded/i.test(message) ||
/minified react error\s*#185\b/i.test(message) ||
/react\.dev\/errors\/185\b/i.test(message)
);
}
const TERMINAL_RUN_STATUSES = new Set([
"success",
@@ -363,12 +344,9 @@ const TERMINAL_RUN_STATUSES = new Set([
"interrupted",
]);
// A completed server run normally sends a terminal SSE event. In local
// development, however, a Vite hot update, a proxy disconnect, or a brief
// network interruption can make the browser miss that final event while the
// backend continues (and successfully completes) the run. Do not probe on
// every render: only treat the stream as suspicious after it has been quiet
// for a while, then reconcile it from the authoritative run/state endpoints.
// Only probe /runs after an *exception* path has already set streamHoldOpen
// (dropped SSE, 409, React #185). Normal Q&A must not GET /runs on finish or
// every 5s — that made every send wait for a status round-trip.
const STREAM_RECONCILIATION_IDLE_MS = 12_000;
const STREAM_RECONCILIATION_POLL_MS = 5_000;
// A run can legitimately be quiet while a long tool call / slow model write is
@@ -608,14 +586,9 @@ export function useThreadStream({
"terminal" | "rejoined" | "unknown"
>
>(async () => "unknown");
// Keep the send button in "stop" until we confirm the backend run is
// terminal. A premature SSE close must not look like the turn ended.
const [streamHoldOpen, setStreamHoldOpen] = useState(
() =>
Boolean(
threadId && typeof window !== "undefined" && readActiveRunId(threadId),
),
);
// Held only on exception recovery (dropped SSE / 409). Normal turns rely
// on SDK `isLoading` so the send button unlocks as soon as SSE closes.
const [streamHoldOpen, setStreamHoldOpen] = useState(false);
const sendInFlightRef = useRef(false);
const sendEpochRef = useRef(0);
// Filled after useStream; onFinish/onError may read it asynchronously.
@@ -687,17 +660,17 @@ export function useThreadStream({
runMetadataStorageRef.current = getRunMetadataStorage();
}
const thread = useStream<AgentThreadState>({
client: getAPIClient(isMock),
assistantId: assistantId ?? "lead_agent",
threadId: onStreamThreadId,
// The SDK's current implementation falls back to synchronous external-
// store notifications when this option is omitted. A network chunk can
// contain dozens of token events, causing nested React renders and the
// intermittent production-only React #185 error. Coalesce notifications
// to one visual update per frame; stream data itself is not dropped.
throttle: 16,
reconnectOnMount: runMetadataStorageRef.current
const thread = useStream<AgentThreadState>({
client: getAPIClient(isMock),
assistantId: assistantId ?? "lead_agent",
threadId: onStreamThreadId,
// The SDK's current implementation falls back to synchronous external-
// store notifications when this option is omitted. A network chunk can
// contain dozens of token events, causing nested React renders and the
// intermittent production-only React #185 error. Coalesce notifications
// to one visual update per frame; stream data itself is not dropped.
throttle: 16,
reconnectOnMount: runMetadataStorageRef.current
? () => runMetadataStorageRef.current!
: false,
fetchStateHistory: { limit: 1 },
@@ -707,7 +680,6 @@ export function useThreadStream({
if (meta.run_id) {
activeRunIdRef.current = meta.run_id;
persistActiveRunId(meta.thread_id, meta.run_id);
setStreamHoldOpen(true);
}
const metadataAgentId = context.agent_id ?? context.agent_name;
const extraMetadata =
@@ -862,21 +834,21 @@ export function useThreadStream({
toast(e.message);
}
},
onError(error) {
markStreamActivity();
const message = getStreamErrorMessage(error);
// A synchronous SDK store notification used to let React's update-depth
// guard escape into the stream promise. Do not present that internal UI
// exception as a model failure. Reconcile/rejoin on the next task so the
// backend run can continue and the current answer is preserved.
if (isReactMaximumUpdateDepthError(message)) {
window.setTimeout(() => {
setStreamHoldOpen(true);
void confirmOrRejoinRef.current("error");
}, 0);
return;
}
// Replacing a stalled local SSE reader aborts the in-flight fetch. That
onError(error) {
markStreamActivity();
const message = getStreamErrorMessage(error);
// A synchronous SDK store notification used to let React's update-depth
// guard escape into the stream promise. Do not present that internal UI
// exception as a model failure. Reconcile/rejoin on the next task so the
// backend run can continue and the current answer is preserved.
if (isReactMaximumUpdateDepthError(message)) {
window.setTimeout(() => {
setStreamHoldOpen(true);
void confirmOrRejoinRef.current("error");
}, 0);
return;
}
// Replacing a stalled local SSE reader aborts the in-flight fetch. That
// is expected and must not look like a user-facing "network" failure.
if (isAbortLikeError(error)) {
return;
@@ -944,34 +916,30 @@ export function useThreadStream({
}
toast.error(message);
},
onFinish(state) {
onFinish(state, runMeta) {
lastStreamActivityAtRef.current = Date.now();
// Do not trust a clean SSE close as "the run is done". streamResumable
// chats keep the backend run alive on disconnect; the SDK still fires
// onFinish and would otherwise restore the send button.
//
// If a newer turn is already streaming (common: 用户协助卡提交后,上一轮
// interrupt 的迟到 onFinish 才到), do not freeze/rejoin — that would
// steal the live SSE and dump tokens only at the end.
if (threadStreamRef.current?.isLoading || sendInFlightRef.current) {
// Trust a clean SSE close. Querying /runs here blocked every send for
// ~5s to paper over a rare premature disconnect; that case is handled
// when the next send hits 409 (or onError for a dropped stream).
if (
isStaleStreamFinish({
finishingRunId: runMeta?.run_id,
currentRunId: activeRunIdRef.current,
})
) {
return;
}
setStreamHoldOpen(true);
void confirmOrRejoinRef.current("finish").then((result) => {
if (result === "rejoined") {
return;
}
if (threadStreamRef.current?.isLoading || sendInFlightRef.current) {
return;
}
setStreamHoldOpen(false);
// run 真正终结(非 rejoin):若深度研究报告流仍 active(tool_finished
// 帧丢失/被取消),标记中断,避免进度条永久转圈。
const finThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
if (finThreadId) finalizeResearchReportStream(finThreadId);
const finThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
if (finThreadId) {
clearActiveRunId(finThreadId);
finalizeResearchReportStream(finThreadId);
}
activeRunIdRef.current = null;
setStreamHoldOpen(false);
if (state?.values) {
listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
});
}
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
},
});
@@ -1009,14 +977,6 @@ export function useThreadStream({
if (streamReconciliationInFlightRef.current && reason === "poll") {
return "unknown";
}
// Stale finish/error from a prior interrupt must not joinStream over a
// turn the user already started (协助卡提交后的实时流会被打断并攒批)。
if (
(reason === "finish" || reason === "error") &&
(sendInFlightRef.current || threadStreamRef.current?.isLoading)
) {
return "rejoined";
}
streamReconciliationInFlightRef.current = true;
const epochAtStart = sendEpochRef.current;
try {
@@ -1192,11 +1152,10 @@ export function useThreadStream({
if (dropped && idleFor < 800) {
return;
}
const stored = activeRunIdRef.current ?? readActiveRunId(activeThreadId);
if (!stored && !streamHoldOpen && threadStreamRef.current?.isLoading) {
return;
}
if (!stored && !streamHoldOpen) {
// Do not probe just because a run id is stored. Only the exception
// path sets streamHoldOpen; polling otherwise made every turn wait
// on GET /runs before the user could send again.
if (!streamHoldOpen) {
return;
}
await confirmOrRejoin("poll");
@@ -1705,20 +1664,20 @@ export function useThreadStream({
thread.isLoading,
) ?? thread.values;
const mergedMessages =
const mergedMessages =
Array.isArray(mergedValues?.messages)
? (mergedValues.messages as Message[])
: thread.messages;
const visibleThreadError =
thread.error &&
isReactMaximumUpdateDepthError(getStreamErrorMessage(thread.error))
? undefined
: thread.error;
const mergedThread = {
...thread,
error: visibleThreadError,
: thread.messages;
const visibleThreadError =
thread.error &&
isReactMaximumUpdateDepthError(getStreamErrorMessage(thread.error))
? undefined
: thread.error;
const mergedThread = {
...thread,
error: visibleThreadError,
values: mergedValues,
messages:
optimisticMessages.length > 0
@@ -5,6 +5,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import {
collectHumanMessageIds,
hasServerEchoedOptimisticHuman,
isStaleStreamFinish,
mergeStreamDisplayValues,
shouldDiscardTerminalOverlay,
} from "./stream-display";
@@ -28,6 +29,30 @@ test("live streaming ignores frozen checkpoint messages so tokens can paint", ()
assert.deepEqual(afterStream?.messages, overrides.messages);
});
test("late onFinish of a prior run is stale once a newer run is live", () => {
assert.equal(
isStaleStreamFinish({
finishingRunId: "old-run",
currentRunId: "new-run",
}),
true,
);
assert.equal(
isStaleStreamFinish({
finishingRunId: "run-1",
currentRunId: "run-1",
}),
false,
);
assert.equal(
isStaleStreamFinish({
finishingRunId: "run-1",
currentRunId: null,
}),
false,
);
});
test("a later send discards the previous run's getState overlay", () => {
assert.equal(
shouldDiscardTerminalOverlay({
@@ -51,6 +51,25 @@ export function shouldDiscardTerminalOverlay(opts: {
return false;
}
/**
* True when an SSE onFinish belongs to a *previous* run, after the user
* already started a newer turn (协助卡提交是典型场景).
*
* Do **not** use `sendInFlight` / `isLoading` for this: `submit()` stays
* pending until the current stream ends, so those flags are still true on a
* normal same-turn finish and would skip unlock for every Q&A.
*/
export function isStaleStreamFinish(opts: {
finishingRunId?: string | null;
currentRunId?: string | null;
}): boolean {
return Boolean(
opts.finishingRunId &&
opts.currentRunId &&
opts.finishingRunId !== opts.currentRunId,
);
}
export function collectHumanMessageIds(
...groups: Array<Message[] | undefined>
): Set<string> {
+97 -57
View File
@@ -1,21 +1,23 @@
"use client";
import {
Bot,
ChevronLeft,
ChevronRight,
Compass,
Clock,
DownloadIcon,
FilePenLine,
MessageSquare,
PenTool,
Pin,
PinOff,
Search,
Star,
Users2,
type LucideIcon,
X,
} from "lucide-react";
import Link from "next/link";
import { motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -48,6 +50,9 @@ import {
import { formatTimeAgo } from "@/core/utils/datetime";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { useAIWritingSessions } from "@/open-canvas/hooks/useAIWritingSessions";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import { useResearchSessions } from "@/strategy-components/api/deep-research";
import { RoundtableHistoryTab } from "./chats-history/RoundtableHistoryTab";
import { WritingHistoryTab } from "./chats-history/WritingHistoryTab";
@@ -79,19 +84,19 @@ const HISTORY_TABS: {
id: "roundtable",
label: "多智能体会商",
description: "多角色协作研讨",
icon: Bot,
icon: Users2,
},
{
id: "writing",
label: "AI写作",
description: "写作任务与草稿",
icon: FilePenLine,
icon: PenTool,
},
{
id: "research",
label: "深度研究",
description: "研究任务与报告",
icon: Search,
icon: Compass,
},
];
@@ -126,11 +131,20 @@ export default function ChatsPage({
const [importOpen, setImportOpen] = useState(false);
// 非对话 Tab 的记录总数由 Tab 组件回传(客户端过滤后的条数)。
const [tabTotal, setTabTotal] = useState<number | null>(null);
// 顶部的数字角标与参考页一致,始终显示各类记录的实际总数,而不是当前筛选结果。
const { drafts: roundtableTabDrafts, refresh: refreshRoundtableTabDrafts } = useRoundtableDrafts();
const { sessions: writingTabSessions, refresh: refreshWritingTabSessions } = useAIWritingSessions();
const { data: researchTabData } = useResearchSessions(100);
useEffect(() => {
document.title = `${t.pages.chats} - ${t.pages.appName}`;
}, [t.pages.chats, t.pages.appName]);
useEffect(() => {
void refreshRoundtableTabDrafts();
void refreshWritingTabSessions();
}, [refreshRoundtableTabDrafts, refreshWritingTabSessions]);
// Debounce the search box so each keystroke doesn't fire a backend query.
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
@@ -224,7 +238,7 @@ export default function ChatsPage({
"group/chat-row relative flex min-w-0 items-start gap-3 rounded-[10px] border bg-card p-3 transition-[border-color,box-shadow,background-color] duration-150 sm:items-center sm:p-3.5",
pinned
? "border-red-200 bg-gradient-to-r from-red-50/50 via-white to-white shadow-xs hover:border-red-300 hover:shadow-sm"
: "border-neutral-200/80 bg-white hover:border-red-200 hover:bg-red-50/20 hover:shadow-xs",
: "border-neutral-200/80 bg-white hover:!border-red-300 hover:!bg-gradient-to-r hover:!from-red-50/50 hover:!via-white hover:!to-white hover:shadow-xs",
)}
>
<Button
@@ -248,12 +262,12 @@ export default function ChatsPage({
>
<div className="flex min-w-0 items-center gap-2">
{pinned && (
<span className="inline-flex items-center gap-1 rounded border border-red-200/50 bg-red-100/80 px-1.5 py-0.5 text-[10px] font-semibold text-red-700">
<span className="inline-flex items-center gap-1 rounded border !border-red-200 bg-red-100/80 px-1.5 py-0.5 text-[10px] font-semibold text-red-700">
<Pin className="size-3 rotate-45 text-red-600" />
置顶
</span>
)}
<span className="inline-flex items-center gap-1 rounded-full border border-red-200/70 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700">
<span className="inline-flex items-center gap-1 rounded-full border !border-red-200 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700">
<MessageSquare className="size-3 text-red-600" />
对话
</span>
@@ -298,6 +312,12 @@ export default function ChatsPage({
// 头部计数:对话 Tab 用后端总数,其余 Tab 用组件回传的过滤后条数。
const displayTotal = activeTab === "chats" ? totalCount : tabTotal;
const activeTabConfig = HISTORY_TABS.find((tab) => tab.id === activeTab)!;
const historyTabCounts: Record<HistoryTab, number> = {
chats: allThreadsCount ?? 0,
roundtable: roundtableTabDrafts.length,
writing: writingTabSessions.length,
research: researchTabData?.total ?? 0,
};
return (
<WorkspaceContainer>
@@ -315,52 +335,77 @@ export default function ChatsPage({
<WorkspaceBody>
<div className="flex size-full min-w-0 flex-col">
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
<div className="relative flex w-full max-w-5xl items-center justify-center px-4 sm:px-6">
<div
className="inline-flex max-w-full overflow-x-auto rounded-2xl border border-red-100 bg-white p-1.5 shadow-xs backdrop-blur-sm"
role="tablist"
aria-label="历史记录类型"
>
{HISTORY_TABS.map((tab) => {
const isActive = activeTab === tab.id;
const Icon = tab.icon;
<div className="relative w-full max-w-5xl px-4 sm:px-6">
<div className="flex w-full flex-col items-center">
<div
id="primary-tabs-nav"
className="inline-flex rounded-2xl border border-red-100 bg-white p-1.5 shadow-xs backdrop-blur-sm"
role="tablist"
aria-label="历史记录类型"
>
{HISTORY_TABS.map((tab) => {
const isActive = activeTab === tab.id;
const Icon = tab.icon;
const count = historyTabCounts[tab.id];
return (
<button
key={tab.id}
id={`history-tab-${tab.id}`}
type="button"
className={cn(
"relative flex shrink-0 select-none items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
isActive
? "font-semibold text-red-700 shadow-xs"
: "text-neutral-600 hover:bg-red-50/50 hover:text-red-600",
)}
role="tab"
aria-selected={isActive}
aria-controls={`history-panel-${tab.id}`}
title={tab.description}
onClick={() => setActiveTab(tab.id)}
>
{isActive && (
return (
<button
key={tab.id}
id={`history-tab-${tab.id}`}
type="button"
className={cn(
"relative flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
isActive
? "font-semibold text-red-700 shadow-xs"
: "text-neutral-600 hover:bg-red-50/50 hover:text-red-600",
)}
role="tab"
aria-selected={isActive}
aria-controls={`history-panel-${tab.id}`}
title={tab.description}
onClick={() => setActiveTab(tab.id)}
>
{isActive && (
<motion.div
layoutId="activeTabBadge"
className="absolute inset-0 rounded-xl border !border-red-200 bg-red-50/90 shadow-xs"
transition={{ type: "spring", stiffness: 450, damping: 35 }}
aria-hidden="true"
/>
)}
<span
className="absolute inset-0 rounded-xl border border-red-200 bg-red-50/90 shadow-xs"
aria-hidden="true"
/>
)}
<span className="relative z-10 flex items-center gap-2">
<Icon className="size-4" aria-hidden="true" />
<span>{tab.label}</span>
</span>
</button>
);
})}
className={cn(
"relative z-10 flex items-center gap-2",
isActive
? "font-semibold text-red-700"
: "text-neutral-600",
)}
>
<Icon className="size-4" aria-hidden="true" />
<span>{tab.label}</span>
</span>
{count > 0 && (
<span
className={cn(
"relative z-10 rounded-full px-1.5 py-[0.05rem] font-mono text-[11px] transition-colors",
isActive
? "bg-red-600 font-bold text-white"
: "bg-neutral-100 text-neutral-500",
)}
>
{count}
</span>
)}
</button>
);
})}
</div>
</div>
{activeTab === "chats" && (
<Button
variant="outline"
size="sm"
className="absolute right-4 sm:right-6"
className="absolute right-4 top-1/2 -translate-y-1/2 sm:right-6"
onClick={() => setImportOpen(true)}
>
<DownloadIcon className="size-4" />
@@ -376,9 +421,9 @@ export default function ChatsPage({
id={`history-panel-${activeTab}`}
role="tabpanel"
aria-labelledby={`history-tab-${activeTab}`}
className="space-y-4 rounded-2xl border border-red-100 bg-white p-4 shadow-xs sm:p-5"
className="space-y-4 rounded-2xl border border-red-200 bg-white p-4 shadow-xs sm:p-5"
>
<div className="w-full space-y-3.5">
{activeTab !== "roundtable" && <div className="w-full space-y-3.5">
<div className="flex items-center justify-between gap-3">
{activeTab === "chats" ? (
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 md:pb-0" role="group" aria-label="对话筛选">
@@ -387,7 +432,7 @@ export default function ChatsPage({
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/30",
chatFilter === "all"
? "bg-red-600 font-semibold text-white shadow-xs"
? "border !border-red-600 bg-red-600 font-semibold text-white shadow-xs"
: "border border-neutral-200 bg-white text-neutral-600 hover:border-red-200 hover:bg-red-50/60 hover:text-red-700",
)}
aria-pressed={chatFilter === "all"}
@@ -499,7 +544,7 @@ export default function ChatsPage({
</span>
)}
</div>
</div>
</div>}
{activeTab === "chats" && (
<div className="space-y-3 pt-5">
{currentPage === 1 && pinnedThreads.map(renderThread)}
@@ -543,12 +588,7 @@ export default function ChatsPage({
</div>
)}
{activeTab === "roundtable" && (
<div className="pt-5">
<RoundtableHistoryTab
query={search}
onTotalChange={handleTabTotalChange}
/>
</div>
<RoundtableHistoryTab onTotalChange={handleTabTotalChange} />
)}
{activeTab === "writing" && (
<div className="pt-5">
@@ -3,113 +3,326 @@
/**
* 「对话历史」页 —— 多智能体会商 Tab。
*
* 数据与原侧栏「多智能体会商」列表同源(当前用户的个人草稿 roundtable_drafts,
* useRoundtableDrafts 不带 taskId);点击行为也一致:暂存 sessionStorage
* 「roundtable:pending-draft」后跳转会商页,由页面挂载时消费并加载该草稿。
* 任务维度会商(task-draft,按 taskId 全任务共享、不分权)不随个人历史走,
* 仍只在对应任务工作区里查看。
* 卡片与筛选区按最新「智能工作区管理 (1)」的 ConversationRow /
* FilterAndSearchBar 适配。会商步骤、状态、模式、互动数和模型都来自实际草稿快照。
*/
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Bot, Share2 } from "lucide-react";
import {
ArrowUpDown,
Clock,
Cpu,
ExternalLink,
Search,
Tag,
Trash2,
Users2,
X,
} from "lucide-react";
import { toast } from "sonner";
import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import type { DraftMeta } from "@/roundtable-planning/api/drafts";
import { desensitize } from "@/lib/desensitize";
import {
DeleteIcon,
HistoryRenameDialog,
HistoryRow,
HistoryRowIconButton,
HistoryStatusPill,
HistoryTypeBadge,
RenameIcon,
} from "./history-tab-shared";
getDraft,
type DraftMeta,
type DraftRecord,
} from "@/roundtable-planning/api/drafts";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import { desensitize } from "@/lib/desensitize";
const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning";
const ROUNDTABLE_STEP_LABEL: Record<DraftMeta["furthestStep"], string> = {
1: "议题梳理",
2: "多智能体会商",
3: "报告生成",
};
type SortType = "updated_desc" | "updated_asc" | "messages_desc";
interface RoundtableItem {
draft: DraftMeta;
record?: DraftRecord;
status: ReturnType<typeof progressOf>;
businessChainName: string;
messageCount: number;
model?: string;
tags: string[];
}
/**
* 列表接口会返回草稿已推进到的工作台步骤以及最新后台作业状态。两者须同时展示:
* `furthestStep` 告诉用户做到哪一环,`status` 告诉用户是否已真正完成或被中断。
*/
function progressOf(draft: DraftMeta): {
label: string;
className: string;
stepLabel: string;
tone: "completed" | "active" | "waiting" | "failed" | "cancelled";
} {
const stepLabel = `第 ${draft.furthestStep} 步 · ${ROUNDTABLE_STEP_LABEL[draft.furthestStep]}`;
const stepLabel = `第 ${draft.furthestStep} 步`;
if (draft.status === "done" || (!draft.status && draft.furthestStep === 3)) {
return { label: "已完成", className: "text-emerald-600", stepLabel };
return { label: "已完成", tone: "completed", stepLabel };
}
if (draft.status === "error") {
return { label: "执行失败", className: "text-rose-500", stepLabel: `停在${stepLabel}` };
return { label: "执行失败", tone: "failed", stepLabel: `停在${stepLabel}` };
}
if (draft.status === "cancelled") {
return { label: "已取消", className: "text-muted-foreground", stepLabel: `停在${stepLabel}` };
return { label: "已取消", tone: "cancelled", stepLabel: `停在${stepLabel}` };
}
if (draft.status === "awaiting_input") {
return { label: "等待确认", className: "text-amber-500", stepLabel: `当前${stepLabel}` };
return { label: "等待确认", tone: "waiting", stepLabel: `当前${stepLabel}` };
}
return { label: "进行中", className: "text-primary", stepLabel: `当前${stepLabel}` };
return { label: "进行中", tone: "active", stepLabel: `当前${stepLabel}` };
}
function modeOf(record?: DraftRecord): string {
switch (record?.step2?.orchestrationMode) {
case "chain":
return "业务链会商";
case "dag":
return "DAG 编排";
default:
return "自由模式";
}
}
function businessChainNameOf(draft: DraftMeta, record?: DraftRecord): string {
return record?.step2?.chain?.title ?? draft.chainTitle ?? modeOf(record);
}
function interactionCount(record?: DraftRecord): number {
if (!record) return 0;
return (
(record.step1?.interactiveMessages?.length ?? 0) +
(record.step2?.step2RoundtableDialogues?.length ?? 0) +
(record.step3?.summaryReport?.dialogues?.length ?? 0) +
(record.step3?.reportDialogues?.length ?? 0) +
(record.step3?.dashboardDialogues?.length ?? 0)
);
}
function modelOf(record?: DraftRecord): string | undefined {
return record?.step3?.summaryReport?.model ?? record?.step3?.model;
}
function tagsOf(draft: DraftMeta, record?: DraftRecord): string[] {
const chain = record?.step2?.chain?.title ?? draft.chainTitle;
return chain ? [chain] : [];
}
function timestamp(value: string): number {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function renderHighlightedTitle(text: string, query: string) {
if (!query.trim()) return text;
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const parts = text.split(new RegExp(`(${escaped})`, "gi"));
return parts.map((part, index) =>
part.toLowerCase() === query.toLowerCase() ? (
<mark key={index} className="rounded-xs bg-red-100 px-0.5 font-semibold text-red-900">
{part}
</mark>
) : (
part
),
);
}
function RoundtableHistoryRow({
item,
searchQuery,
onDelete,
onOpen,
}: {
item: RoundtableItem;
searchQuery: string;
onDelete: () => void;
onOpen: () => void;
}) {
const { draft, status } = item;
const statusClass = {
completed: "border-emerald-200/80 bg-emerald-50 text-emerald-700",
active: "border-red-200/80 bg-red-50 text-red-600",
waiting: "border-amber-200/80 bg-amber-50 text-amber-700",
failed: "border-rose-200/80 bg-rose-50 text-rose-600",
cancelled: "border-neutral-200/80 bg-neutral-50 text-neutral-500",
}[status.tone];
const statusDotClass = {
completed: "bg-emerald-500",
active: "bg-red-500",
waiting: "bg-amber-500",
failed: "bg-rose-500",
cancelled: "bg-neutral-400",
}[status.tone];
return (
<div
id={`conversation-item-${draft.id}`}
onClick={(event) => {
if ((event.target as HTMLElement).closest("button, input")) return;
onOpen();
}}
className="group relative flex cursor-pointer items-start justify-between rounded-xl border border-neutral-200/80 bg-white p-4 transition-all duration-150 hover:!border-red-300 hover:!bg-gradient-to-r hover:!from-red-50/50 hover:!via-white hover:!to-white hover:shadow-xs sm:items-center"
>
<div className="flex min-w-0 flex-1 items-start gap-3.5 pr-4 sm:items-center">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full border !border-red-200 bg-red-50/90 px-2 py-0.5 text-[11px] font-semibold text-red-700">
<span className="relative flex size-1.5">
{status.tone === "active" && (
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
)}
<span className="relative inline-flex size-1.5 rounded-full bg-red-600" />
</span>
<Users2 className="size-3 text-red-600" />
会商
</span>
<h3 className="truncate text-sm font-semibold text-neutral-900 transition-colors group-hover:text-red-700">
{renderHighlightedTitle(desensitize(draft.title || "未命名会商"), searchQuery)}
</h3>
</div>
<div className="flex flex-wrap items-center gap-2 pt-0.5 text-[11px] text-neutral-400">
<span className={`inline-flex items-center gap-1 rounded border !border-red-200 px-1.5 py-0.5 text-[10px] font-medium ${statusClass}`}>
<span className={`size-1.5 rounded-full ${statusDotClass}`} />
{status.label}
</span>
<span className="inline-flex items-center rounded border border-neutral-200/60 bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-600">
{status.stepLabel}
</span>
<span className="inline-flex items-center gap-1 rounded border border-neutral-200/40 bg-neutral-50 px-1.5 py-0.5 text-[10px] text-neutral-500">
<Cpu className="size-2.5 text-neutral-400" />
{item.businessChainName}
</span>
<span className="inline-flex items-center gap-1 text-neutral-400">
<Clock className="size-3 text-neutral-400" />
{formatRelative(draft.updatedAt)}
</span>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
<div className="hidden items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 sm:flex">
<button
id={`delete-btn-${draft.id}`}
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
className="rounded-md p-1.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-600"
title="删除会商"
aria-label="删除会商"
>
<Trash2 className="size-3.5" />
</button>
<button
id={`open-preview-btn-${draft.id}`}
type="button"
onClick={(event) => {
event.stopPropagation();
onOpen();
}}
className="rounded-md p-1.5 text-neutral-500 transition-colors hover:bg-red-50 hover:text-red-700"
title="打开会商详情"
aria-label="打开会商详情"
>
<ExternalLink className="size-3.5" />
</button>
</div>
</div>
</div>
);
}
export function RoundtableHistoryTab({
query,
onTotalChange,
}: {
query: string;
onTotalChange?: (total: number) => void;
}) {
const navigate = useNavigate();
const { drafts, loading, refresh, rename, remove } = useRoundtableDrafts();
const [renameId, setRenameId] = useState<string | null>(null);
const [renameTitle, setRenameTitle] = useState("");
const [shareId, setShareId] = useState<string | null>(null);
const { drafts, loading, refresh, remove } = useRoundtableDrafts();
const [records, setRecords] = useState<Record<string, DraftRecord>>({});
const [searchQuery, setSearchQuery] = useState("");
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [sortType, setSortType] = useState<SortType>("updated_desc");
useEffect(() => {
void refresh();
}, [refresh]);
const keyword = query.trim().toLowerCase();
const filtered = keyword
? drafts.filter((draft) =>
(draft.title || "未命名草稿").toLowerCase().includes(keyword),
)
: drafts;
useEffect(() => {
let cancelled = false;
if (drafts.length === 0) {
setRecords({});
return () => {
cancelled = true;
};
}
void Promise.all(
drafts.map(async (draft) => {
try {
return await getDraft(draft.id);
} catch {
return null;
}
}),
).then((fullDrafts) => {
if (cancelled) return;
setRecords(
Object.fromEntries(
fullDrafts
.filter((record): record is DraftRecord => record !== null)
.map((record) => [record.id, record]),
),
);
});
return () => {
cancelled = true;
};
}, [drafts]);
const allItems = useMemo<RoundtableItem[]>(
() =>
drafts.map((draft) => {
const record = records[draft.id];
return {
draft,
record,
status: progressOf(draft),
businessChainName: businessChainNameOf(draft, record),
messageCount: interactionCount(record),
model: modelOf(record),
tags: tagsOf(draft, record),
};
}),
[drafts, records],
);
const availableTags = useMemo(
() => [...new Set(allItems.flatMap((item) => item.tags))],
[allItems],
);
const filteredItems = useMemo(() => {
const keyword = searchQuery.trim().toLowerCase();
return allItems
.filter((item) => {
if (selectedTag && !item.tags.includes(selectedTag)) return false;
return !keyword || (item.draft.title || "未命名会商").toLowerCase().includes(keyword);
})
.sort((left, right) => {
if (sortType === "messages_desc") return right.messageCount - left.messageCount;
const delta = timestamp(right.draft.updatedAt) - timestamp(left.draft.updatedAt);
return sortType === "updated_desc" ? delta : -delta;
});
}, [allItems, searchQuery, selectedTag, sortType]);
useEffect(() => {
onTotalChange?.(filtered.length);
}, [filtered.length, onTotalChange]);
onTotalChange?.(filteredItems.length);
}, [filteredItems.length, onTotalChange]);
const openDraft = (draft: DraftMeta) => {
sessionStorage.setItem("roundtable:pending-draft", draft.id);
navigate(ROUNDTABLE_PATH);
};
const handleRename = async (id: string, title: string) => {
try {
await rename(id, title);
toast.success("已重命名");
} catch {
toast.error("重命名失败,请重试");
} finally {
setRenameId(null);
}
};
const handleDelete = async (draft: DraftMeta) => {
const deleteDraft = async (draft: DraftMeta) => {
if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return;
try {
await remove(draft.id);
@@ -119,90 +332,135 @@ export function RoundtableHistoryTab({
}
};
if (loading && drafts.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
加载中…
</div>
);
}
if (filtered.length === 0) {
return (
<div className="text-muted-foreground py-16 text-center text-sm">
{keyword ? "没有匹配的会商记录" : "暂无会商记录"}
</div>
);
}
return (
<>
<div>
{filtered.map((draft) => {
const progress = progressOf(draft);
return (
<HistoryRow
key={draft.id}
badge={<HistoryTypeBadge icon={Bot} label="会商" />}
title={desensitize(draft.title || "未命名草稿")}
meta={
<>
<HistoryStatusPill
label={progress.label}
className={progress.className}
/>
<span className="text-muted-foreground/60">·</span>
<span className="max-w-48 truncate" title={progress.stepLabel}>
{progress.stepLabel}
</span>
<span className="text-muted-foreground/60">·</span>
<span className="max-w-40 truncate" title={draft.chainTitle || "自由模式"}>
{draft.chainTitle || "自由模式"}
</span>
<span className="text-muted-foreground/60">·</span>
<span>{formatRelative(draft.updatedAt)}</span>
</>
}
onOpen={() => openDraft(draft)}
<div className="w-full space-y-4">
<div className="w-full space-y-3.5">
<div className="flex flex-col items-stretch justify-between gap-3 md:flex-row md:items-center">
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 md:pb-0">
<button
id="filter-all-btn"
type="button"
onClick={() => setSelectedTag(null)}
className={`inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-medium transition-all ${
!selectedTag
? "border !border-red-600 bg-red-600 font-semibold text-white shadow-xs"
: "border border-neutral-200 bg-white text-neutral-600 hover:border-red-200 hover:bg-red-50/60 hover:text-red-700"
}`}
>
<HistoryRowIconButton
title="重命名"
onClick={() => {
setRenameId(draft.id);
setRenameTitle(draft.title || "");
}}
<span>全部</span>
<span className={`rounded-full px-1.5 py-0.2 font-mono text-[11px] ${!selectedTag ? "bg-red-700 font-semibold text-white" : "bg-neutral-100 text-neutral-500"}`}>
{allItems.length}
</span>
</button>
{selectedTag && (
<div className="inline-flex items-center gap-1 rounded-lg bg-red-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-xs">
<Tag className="size-3 text-red-100" />
<span>{selectedTag}</span>
<button
type="button"
onClick={() => setSelectedTag(null)}
className="ml-1 hover:text-red-200"
title="清除标签筛选"
aria-label="清除标签筛选"
>
<X className="size-3" />
</button>
</div>
)}
</div>
<div className="relative flex-1 md:max-w-md">
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-neutral-400" />
<input
id="conversation-search-input"
type="search"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="搜索会商记录…"
className="w-full rounded-xl border border-neutral-200 bg-neutral-50/80 py-2 pl-9 pr-16 text-sm text-neutral-900 transition-all placeholder:text-neutral-400 hover:bg-white focus:border-red-500 focus:bg-white focus:outline-hidden focus:ring-2 focus:ring-red-500/20"
/>
{searchQuery ? (
<button
id="clear-search-query-btn"
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 text-neutral-400 hover:text-red-600"
aria-label="清空搜索"
>
<RenameIcon />
</HistoryRowIconButton>
<HistoryRowIconButton title="分享" onClick={() => setShareId(draft.id)}>
<Share2 className="text-muted-foreground size-4" />
</HistoryRowIconButton>
<HistoryRowIconButton
title="删除"
onClick={() => void handleDelete(draft)}
<X className="size-3.5" />
</button>
) : (
<div className="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 items-center gap-0.5 rounded bg-neutral-200/60 px-1.5 py-0.5 font-mono text-[10px] text-neutral-400 sm:flex">
⌘K
</div>
)}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-red-50 pt-1 text-xs text-neutral-500">
<div className="flex items-center gap-3 overflow-x-auto">
<span className="whitespace-nowrap font-medium text-neutral-500">
共 <strong className="font-bold text-red-600">{filteredItems.length}</strong> 条会商记录
{searchQuery && <span className="ml-1 text-neutral-400">(匹配 “{searchQuery}”)</span>}
</span>
{availableTags.length > 0 && (
<div className="hidden items-center gap-1.5 text-xs lg:flex">
<span className="text-neutral-400">快捷标签:</span>
{availableTags.slice(0, 4).map((tag) => (
<button
key={tag}
type="button"
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
className={`rounded-md px-2 py-0.5 transition-colors ${
selectedTag === tag
? "bg-red-600 font-medium text-white shadow-2xs"
: "border !border-red-200 bg-red-50 text-red-700 hover:bg-red-100 hover:text-red-800"
}`}
>
#{tag}
</button>
))}
</div>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-lg border border-neutral-200 bg-white px-2 py-1 shadow-2xs">
<ArrowUpDown className="size-3 text-neutral-400" />
<select
id="sort-select-dropdown"
value={sortType}
onChange={(event) => setSortType(event.target.value as SortType)}
className="cursor-pointer bg-transparent text-xs text-neutral-700 focus:outline-hidden"
>
<DeleteIcon />
</HistoryRowIconButton>
</HistoryRow>
);
})}
<option value="updated_desc">最近活跃优先</option>
<option value="updated_asc">最早创建优先</option>
<option value="messages_desc">消息数量最多</option>
</select>
</div>
</div>
</div>
</div>
<HistoryRenameDialog
open={renameId !== null}
initialTitle={renameTitle}
dialogTitle="重命名会商"
onCancel={() => setRenameId(null)}
onConfirm={(title) => renameId && void handleRename(renameId, title)}
/>
<ShareRoundtableDraftDialog
draftId={shareId}
open={shareId !== null}
onOpenChange={(o) => {
if (!o) setShareId(null);
}}
/>
</>
{loading && allItems.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">加载中…</div>
) : filteredItems.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">
{searchQuery || selectedTag ? "没有匹配的会商记录" : "暂无会商记录"}
</div>
) : (
<div className="space-y-2">
{filteredItems.map((item) => (
<RoundtableHistoryRow
key={item.draft.id}
item={item}
searchQuery={searchQuery}
onDelete={() => void deleteDraft(item.draft)}
onOpen={() => openDraft(item.draft)}
/>
))}
</div>
)}
</div>
);
}
@@ -43,7 +43,7 @@ export function HistoryTypeBadge({
label: string;
}) {
return (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-red-200/70 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700">
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border !border-red-200 bg-red-50 px-2 py-0.5 text-[11px] font-semibold text-red-700">
<Icon className="size-3 text-red-600" />
{label}
</span>
@@ -68,7 +68,7 @@ export function HistoryRow({
children?: ReactNode;
}) {
return (
<div className="group/chat-row relative mb-3 flex min-w-0 rounded-[10px] border border-neutral-200/80 bg-white transition-[border-color,box-shadow,background-color] duration-150 last:mb-0 hover:border-red-200 hover:bg-red-50/20 hover:shadow-xs">
<div className="group/chat-row relative mb-3 flex min-w-0 rounded-[10px] border border-neutral-200/80 bg-white transition-[border-color,box-shadow,background-color] duration-150 last:mb-0 hover:!border-red-300 hover:!bg-gradient-to-r hover:!from-red-50/50 hover:!via-white hover:!to-white hover:shadow-xs">
<button
type="button"
onClick={onOpen}