console: 对话只保留一处实时单列(ChatGPT 式)

根因:ConversationSection 同时渲染 TracePanel(prompt +
全部链路条目)和 ContinuePanel(GET /chat-history,
服务端已把 prompt + 继续 + 后续 + 全部链路种类按时间
合并、并去掉了后续消息回声),简单任务的两份名单
显示同一组用户 + 助手卡片,看起来就是对话重复。
改法:删 TracePanel 名单,只留 ContinuePanel 这一列;
TracePanel 唯一不重复的控件(链路类型筛选、下载完整
链路)移到这一列顶部,筛选只过滤链路消息、用户消息
恒保留;实时性不丢:基础是 10 秒轮询的 chat-history,
SSE 新链路条目按 id 去重合并进来,轮询拿到后不再
重复;无 chat-history 的旧服务端回退路径也改成渲染
全部链路种类(不再只渲染 message),并跳过 fu- 回声。
This commit is contained in:
host committed 2026-10-30 01:40:20 +08:00
1 parent b306b56517
commit c192c5dec8
6 files changed
+95 -92

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@ export interface Followup { mode?: string; status?: string; sender_task_id?: str
// timestamp order (see server _task_chat_history). ts_ms is null for
// trace entries that carried no ts; the server order is authoritative,
// the client must not re-sort.
export interface ChatHistoryMessage { id: string; role: "user" | "assistant" | "tool" | "status"; source?: string; label?: string; text: string; ts_ms?: number | null }
export interface ChatHistoryMessage { id: string; role: "user" | "assistant" | "tool" | "status"; source?: string; kind?: string; label?: string; text: string; ts_ms?: number | null }
export interface ChatHistory { task_id?: string; status?: string; messages: ChatHistoryMessage[]; continue_messages_stored?: number; legacy_continue_count?: number | null; trace_note?: string }
export interface PeerMessage { task_id?: string; mode?: string; status?: string; body?: string }
export interface Automation { id: string; name?: string; status?: string; due?: boolean; interval_seconds?: number; next_run_at?: number }
@@ -2,7 +2,7 @@ import type { TraceEntry } from "../../api/types";
import { traceLineText } from "../../api/client";
export type ChatRole = "user" | "assistant" | "tool" | "status";
export interface ChatMessage { id: string; role: ChatRole; text: string; label?: string }
export interface ChatMessage { id: string; role: ChatRole; text: string; label?: string; kind?: string }
// Mapping is by live trace kind ONLY. Trace entries carry no timestamp
// and no sender role in the live file, so no role is invented from
@@ -18,15 +18,15 @@ export function traceEntryToChatMessage(e: TraceEntry, i: number): ChatMessage {
// 成模型没写完(2026-10-29 实弹:468 字 final_answer 断在 fu)。
// 完整回复在任务的 result_text(右侧详情),不在链路条目里。
const text = (e.phase ? "[" + e.phase + "] " : "") + (e.text || "");
return { id, role: "assistant", text: e.truncated ? text + "\n(链路摘要已截断至 300 字,完整回复见右侧任务结果)" : text, label: "助手" };
return { id, role: "assistant", kind: e.kind, text: e.truncated ? text + "\n(链路摘要已截断至 300 字,完整回复见右侧任务结果)" : text, label: "助手" };
}
if (e.kind === "exec" || e.kind === "file" || e.kind === "tool" || e.kind === "call") {
const label = e.kind === "exec" ? "命令" : e.kind === "file" ? "文件" : e.kind === "tool" ? "工具" : "调用";
return { id, role: "tool", text: traceLineText(e), label };
return { id, role: "tool", kind: e.kind, text: traceLineText(e), label };
}
if (e.kind === "plan") return { id, role: "status", text: e.text || "" };
if (e.kind === "reasoning") return { id, role: "status", text: "思考:" + (e.summary || "") };
if (e.kind === "agent-start") return { id, role: "status", text: "子代理开始:" + (e.name || "") + (e.prompt ? "(" + e.prompt + ")" : "") };
if (e.kind === "agent-done") return { id, role: "status", text: "子代理结束:" + (e.name || "") + " " + (e.status || "") };
return { id, role: "status", text: traceLineText(e) };
if (e.kind === "plan") return { id, role: "status", kind: e.kind, text: e.text || "" };
if (e.kind === "reasoning") return { id, role: "status", kind: e.kind, text: "思考:" + (e.summary || "") };
if (e.kind === "agent-start") return { id, role: "status", kind: e.kind, text: "子代理开始:" + (e.name || "") + (e.prompt ? "(" + e.prompt + ")" : "") };
if (e.kind === "agent-done") return { id, role: "status", kind: e.kind, text: "子代理结束:" + (e.name || "") + " " + (e.status || "") };
return { id, role: "status", kind: e.kind, text: traceLineText(e) };
}
@@ -1,10 +1,12 @@
import { useConsole } from "../context";
import TracePanel from "./detail/TracePanel";
import ContinuePanel from "./detail/ContinuePanel";
// Dashboard conversation section: trace timeline above, continue input
// below. Dispatch lives in its own section above on the same page, so
// it is NOT duplicated here when no task is selected — only the hint.
// Dashboard conversation section: ONE ChatGPT-style conversation column
// (ContinuePanel). It used to render TracePanel above it, which showed
// the same user + assistant pair a second time, because chat-history
// already merges every trace kind into the conversation. Dispatch
// lives in its own section above on the same page, so it is NOT
// duplicated here when no task is selected — only the hint.
export default function ConversationSection() {
const ctx = useConsole();
if (!ctx.currentTask) {
@@ -16,8 +18,6 @@ export default function ConversationSection() {
}
return (
<div className="conversation">
<TracePanel />
<div className="conv-divider" />
<ContinuePanel />
</div>
);
@@ -1,37 +1,64 @@
import { useMemo } from "react";
import { useConsole } from "../../context";
import { Bar } from "../../components/ui";
import { ChatList, ChatInput } from "../../components/chat/Chat";
import { traceEntryToChatMessage, type ChatMessage } from "../../components/chat/mapTrace";
import { TRACE_TERMINAL } from "../../api/types";
// Continue chat: same ChatList/ChatBubble/ChatInput as the trace timeline.
// Preferred source is GET /chat-history (server-merged true alternating
// history: prompt + persisted continues + followups + trace, in
// timestamp order). Only when that endpoint is unavailable (old server)
// fall back to the previous view: prompt + message-kind trace entries
// + followups appended after — that fallback cannot interleave exactly
// and cannot show pre-persistence continues, and it says so nowhere
// else, so the fallback path is kept only as a compatibility shim.
// Sending uses only live APIs: terminal tasks -> POST /tasks/:id/continue
// (the server now persists that message in the same transaction),
// queued/running tasks -> POST /tasks/:id/followups (direct).
// The ONLY conversation column (ChatGPT-style single list).
//
// Why there used to be two: ConversationSection rendered TracePanel
// (prompt + every trace entry as chat bubbles) AND this panel
// (GET /chat-history, which already merges prompt + continues +
// followups + every trace kind, timestamp-ordered, with the followup
// echo deduped server-side). For a simple task both lists showed the
// same user + assistant pair, so the conversation appeared twice.
// Now TracePanel's list is gone; its only non-duplicative controls
// (kind filter + download full trace) live on this single list.
//
// Real-time, without duplication: the base is chat-history (refreshed
// every 10 s by refreshDetailLists). Live SSE trace entries are merged
// in only when their id is not already in chat-history, so a running
// task's new entries appear instantly and are not shown twice once
// the next poll picks them up. Kind filtering keeps user messages
// always and filters only trace messages, so filtering never hides
// the conversation itself. The old no-chat-history fallback now also
// renders every trace kind (not just message), with fu- echo entries
// skipped because the same followup is appended as a user message.
export default function ContinuePanel() {
const ctx = useConsole();
const t = ctx.currentTask;
const messages = useMemo<ChatMessage[]>(() => {
if (!t) return [];
let base: ChatMessage[];
if (ctx.chatHistory) {
return ctx.chatHistory.messages.map((m) => ({
id: m.id, role: m.role, text: m.text, label: m.label,
base = ctx.chatHistory.messages.map((m) => ({
id: m.id, role: m.role, text: m.text, label: m.label, kind: m.kind,
}));
const seen = new Set(base.map((m) => m.id));
const live = ctx.traceEntries
.filter((e) => e.id && !seen.has(e.id) && !String(e.id).startsWith("fu-"))
.map((e, i) => traceEntryToChatMessage(e, i));
base = [...base, ...live];
} else {
const head: ChatMessage[] = t.prompt ? [{ id: "task-prompt", role: "user", text: t.prompt }] : [];
const trace = ctx.traceEntries
.filter((e) => !String(e.id || "").startsWith("fu-"))
.map((e, i) => traceEntryToChatMessage(e, i));
const followups: ChatMessage[] = ctx.followups.map((f, i) => ({
id: "followup-" + i, role: "user" as const, text: String(f.body || ""), label: "后续消息",
}));
base = [...head, ...trace, ...followups];
}
const head: ChatMessage[] = t.prompt ? [{ id: "task-prompt", role: "user", text: t.prompt }] : [];
const assistant = ctx.traceEntries.filter((e) => e.kind === "message").map((e, i) => traceEntryToChatMessage(e, i));
const followups: ChatMessage[] = ctx.followups.map((f, i) => ({
id: "followup-" + i, role: "user" as const, text: String(f.body || ""), label: "后续消息",
}));
return [...head, ...assistant, ...followups];
}, [t, ctx.chatHistory, ctx.traceEntries, ctx.followups]);
const kind = ctx.traceKind;
const filtered = kind
? base.filter((m) => m.role === "user" || !m.kind || m.kind === kind)
: base;
const tail: ChatMessage[] = ctx.traceDone
? [{ id: "trace-done", role: "status", text: "链路已结束(" + ctx.traceDone + ")" }]
: [];
return [...filtered, ...tail];
}, [t, ctx.chatHistory, ctx.traceEntries, ctx.followups, ctx.traceKind, ctx.traceDone]);
if (!t) return <div className="muted">点任务行查看详情后,这里显示继续会话</div>;
const terminal = TRACE_TERMINAL.includes(t.status);
const running = ["queued", "running"].includes(t.status);
@@ -44,6 +71,18 @@ export default function ContinuePanel() {
const legacy = ctx.chatHistory ? (ctx.chatHistory.legacy_continue_count ?? null) : null;
return (
<div className="chat-column">
<div className="muted">实时对话</div>
<Bar>
<select aria-label="链路类型" value={ctx.traceKind} onChange={(e) => ctx.setTraceKind(e.target.value)}>
<option value="">链路类型:全部</option>
{["exec", "file", "call", "tool", "message", "reasoning", "plan", "agent-start", "agent-done"].map((k) => (
<option key={k} value={k}>{k === "exec" ? "命令" : k === "file" ? "文件" : k === "call" ? "调用" : k === "tool" ? "工具" : k === "message" ? "消息" : k === "reasoning" ? "思考" : k === "plan" ? "计划" : "子代理"}</option>
))}
</select>
<button onClick={ctx.downloadTrace}>下载完整链路</button>
<span className="muted">{ctx.traceMsg}</span>
</Bar>
{ctx.traceNote ? <div><span className="err">{ctx.traceNote}</span></div> : null}
{legacy !== null && legacy > 0 ? (
<div className="muted">此前有 {legacy} 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。</div>
) : null}
@@ -57,6 +96,7 @@ export default function ContinuePanel() {
onSend={(text) => void ctx.sendChatMessage(text)}
/>
<div className="muted">{hint}。{ctx.chatMsg}</div>
<div className="muted">对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。</div>
</div>
);
}
@@ -1,37 +0,0 @@
import { useMemo } from "react";
import { useConsole } from "../../context";
import { Bar } from "../../components/ui";
import { ChatList } from "../../components/chat/Chat";
import { traceEntryToChatMessage, type ChatMessage } from "../../components/chat/mapTrace";
export default function TracePanel() {
const ctx = useConsole();
const messages = useMemo<ChatMessage[]>(() => {
const t = ctx.currentTask;
const head: ChatMessage[] = t && t.prompt ? [{ id: "task-prompt", role: "user", text: t.prompt }] : [];
const pool = ctx.traceKind ? ctx.traceEntries.filter((e) => e.kind === ctx.traceKind) : ctx.traceEntries;
const body = pool.map((e, i) => traceEntryToChatMessage(e, i));
const tail: ChatMessage[] = ctx.traceDone ? [{ id: "trace-done", role: "status", text: "链路已结束(" + ctx.traceDone + ")" }] : [];
return [...head, ...body, ...tail];
}, [ctx.currentTask, ctx.traceEntries, ctx.traceKind, ctx.traceDone]);
return (
<>
<div className="muted">实时链路</div>
<Bar>
<select aria-label="链路类型" value={ctx.traceKind} onChange={(e) => ctx.setTraceKind(e.target.value)}>
<option value="">链路类型:全部</option>
{["exec", "file", "call", "tool", "message", "reasoning", "plan", "agent-start", "agent-done"].map((k) => (
<option key={k} value={k}>{k === "exec" ? "命令" : k === "file" ? "文件" : k === "call" ? "调用" : k === "tool" ? "工具" : k === "message" ? "消息" : k === "reasoning" ? "思考" : k === "plan" ? "计划" : "子代理"}</option>
))}
</select>
<button onClick={ctx.downloadTrace}>下载完整链路</button>
<span className="muted">{ctx.traceMsg}</span>
</Bar>
{ctx.traceNote ? <div><span className="err">{ctx.traceNote}</span></div> : null}
<div className="chat-column">
<ChatList messages={messages} empty="(暂无链路条目)" />
</div>
<div className="muted">对话时间线:首条是本任务 prompt(用户);助手回复来自 message 条目,命令/文件/工具/调用是工具评论框,计划/思考/子代理开始结束是时间线上的状态文字。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。</div>
</>
);
}