chat: 持久化继续消息,chat-history 交替历史

基线 main 811912c(PR #125 Muse 风格之后):服务端与单测与
9538825 相同;React 侧在 Muse 风格 ContinuePanel 上重放
chat-history 集成(新版输入框占位文案保持 Muse 风格的“消息”),
console.html 由新版 src 重新构建。

- 新表 task_continue_messages:POST /continue 的用户消息与状态改回
  queued 同一事务落盘,409 被拒不留 phantom 消息
- GET /tasks/{id}/chat-history:prompt + 继续 + 后续消息 + 链路按
  时间交错拼好返回;部署前继续以 legacy_continue_count 如实标注
- React ContinuePanel 优先用 chat-history,旧服务端自动回退
This commit is contained in:
host committed 2026-10-29 07:01:57 +08:00
1 parent 811912c841
commit f02c513729
9 files changed
+524 -40

No files matched your search

+1
View File
@@ -38,6 +38,7 @@ export const paths = {
taskApprovals: (id: string) => "/tasks/" + enc(id) + "/approvals",
taskApprovalDecision: (id: string, aid: string, verb: string) => "/tasks/" + enc(id) + "/approvals/" + enc(aid) + "/" + verb,
taskFollowups: (id: string) => "/tasks/" + enc(id) + "/followups",
taskChatHistory: (id: string) => "/tasks/" + enc(id) + "/chat-history",
taskMessages: (id: string) => "/tasks/" + enc(id) + "/messages",
taskMessagesTo: (target: string) => "/tasks/" + enc(target) + "/messages",
taskThread: (id: string) => "/tasks/" + enc(id) + "/thread",
+7
View File
@@ -17,6 +17,13 @@ export interface Task {
export interface Review { id: string; target_type?: string; branch?: string; sha?: string; instructions?: string; status?: string; created_at?: number; findings?: string }
export interface Approval { id: string; kind?: string; status?: string; summary?: string; decision?: string; decided_by?: string; decided_at?: number }
export interface Followup { mode?: string; status?: string; sender_task_id?: string; body?: string }
// Chat history from GET /tasks/:id/chat-history: server-merges the task
// prompt, persisted continue messages, followups and trace entries in
// 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 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 }
export interface Member { user_id: string; username?: string; role?: string }
+2 -1
View File
@@ -1,5 +1,5 @@
import { createContext, useContext } from "react";
import type { Automation, Env, Member, Project, Review, Approval, Followup, PeerMessage, Task, ThreadInfo, Triage, Worker, SkillItem, McpServer, Agent, DispatchBody, ReviewBody, TraceEntry } from "./api/types";
import type { Automation, Env, Member, Project, Review, Approval, Followup, PeerMessage, Task, ThreadInfo, Triage, Worker, SkillItem, McpServer, Agent, DispatchBody, ReviewBody, TraceEntry, ChatHistory } from "./api/types";
export interface ConsoleCtx {
keySaved: boolean; keyState: string;
@@ -30,6 +30,7 @@ export interface ConsoleCtx {
reviews: Review[]; reviewMsg: string; reviewError: string; refreshReviews: () => void; postReview: (body: ReviewBody) => Promise<void>; submitFindings: (id: string) => void;
traceEntries: TraceEntry[]; traceNote: string; traceDone: string; traceKind: string; setTraceKind: (v: string) => void; downloadTrace: () => void; traceMsg: string;
sendChatMessage: (text: string) => Promise<void>; chatMsg: string;
chatHistory: ChatHistory | null;
followups: Followup[];
approvals: Approval[]; approvalMsg: string; refreshApprovals: () => void; decideApproval: (id: string, decision: string) => void;
peerMessages: PeerMessage[]; peerMsg: string; postPeer: (mode: string, target: string, text: string) => Promise<void>;
@@ -126,6 +126,7 @@ export function useConsoleStore(): ConsoleCtx {
traceEntries: detail.traceEntries, traceNote: detail.traceNote, traceDone: detail.traceDone,
traceKind: detail.traceKind, setTraceKind: detail.setTraceKind, downloadTrace: detail.downloadTrace, traceMsg: detail.traceMsg,
sendChatMessage: detail.sendChatMessage, chatMsg: detail.chatMsg,
chatHistory: detail.chatHistory,
followups: detail.followups,
approvals: detail.approvals, approvalMsg: detail.approvalMsg, refreshApprovals: detail.refreshApprovals, decideApproval: detail.decideApproval,
peerMessages: detail.peerMessages, peerMsg: detail.peerMsg, postPeer: detail.postPeer,
+18 -5
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { api, getKey } from "../api/client";
import { paths } from "../api/paths";
import { TRACE_TERMINAL } from "../api/types";
import type { Agent, Approval, Followup, PeerMessage, Review, Task, ThreadInfo, TraceEntry } from "../api/types";
import type { Agent, Approval, ChatHistory, Followup, PeerMessage, Review, Task, ThreadInfo, TraceEntry } from "../api/types";
// Task detail state: the selected task, its trace SSE/one-shot stream,
// and every structured sub-list. Chat sending lives here too:
@@ -36,6 +36,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
const [threadMsg, setThreadMsg] = useState("");
const [threadList, setThreadList] = useState<ThreadInfo[]>([]);
const [chatMsg, setChatMsg] = useState("");
const [chatHistory, setChatHistory] = useState<ChatHistory | null>(null);
const traceAbortRef = useRef<AbortController | null>(null);
const agentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentTaskRef = useRef<Task | null>(null);
@@ -72,6 +73,15 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
catch { setFollowups([]); }
}, []);
// True alternating history: server-merged prompt + persisted
// continues + followups + trace, in timestamp order. On failure
// (old server without /chat-history) keep null and ContinuePanel
// falls back to its previous prompt + trace + followups view.
const loadChatHistory = useCallback(async (taskId: string) => {
try { const d = await api<ChatHistory>(paths.taskChatHistory(taskId)); setChatHistory(d); }
catch { setChatHistory(null); }
}, []);
const loadReviews = useCallback(async (taskId?: string) => {
const id = taskId || currentTaskRef.current?.id;
if (!id) return;
@@ -226,6 +236,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
void startTrace(taskId, t.status);
void loadAgents(taskId);
void loadCoordChildren(t);
void loadChatHistory(taskId);
void loadFollowups(taskId);
void loadPeerMessages(taskId);
void loadThread(taskId);
@@ -236,7 +247,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
setCurrentTask(null);
setDetailBody("加载失败:" + e.message);
}
}, [stopTrace, startTrace, loadAgents, loadCoordChildren, loadFollowups, loadPeerMessages, loadThread, loadThreads, loadReviews, loadApprovals, getRole]);
}, [stopTrace, startTrace, loadAgents, loadCoordChildren, loadChatHistory, loadFollowups, loadPeerMessages, loadThread, loadThreads, loadReviews, loadApprovals, getRole]);
const refreshDetailLists = useCallback(async () => {
const t = currentTaskRef.current;
@@ -250,6 +261,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
setDetailBody(body || "(暂无 result/error 输出)");
} catch { /* light header refresh must not clear the detail */ }
void loadFollowups(t.id);
void loadChatHistory(t.id);
void loadReviews(t.id);
void loadApprovals(t.id);
void loadCoordChildren(t);
@@ -257,7 +269,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
void loadPeerMessages(t.id);
void loadThread(t.id);
void loadThreads(t.id);
}, [loadFollowups, loadReviews, loadApprovals, loadCoordChildren, loadAgents, loadPeerMessages, loadThread, loadThreads]);
}, [loadFollowups, loadChatHistory, loadReviews, loadApprovals, loadCoordChildren, loadAgents, loadPeerMessages, loadThread, loadThreads]);
const doAction = useCallback(async (kind: string) => {
const t = currentTaskRef.current;
@@ -370,11 +382,12 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
await api(paths.taskFollowups(t.id), "POST", { body: v, mode: "direct" });
setChatMsg("已直接发送");
await loadFollowups(t.id);
await loadChatHistory(t.id);
} else {
setChatMsg("merged/merge-failed 任务不可继续,也不可发后续消息");
}
} catch (e: any) { setChatMsg("发送失败:" + e.message); }
}, [showDetail, onTasksReload, loadFollowups]);
}, [showDetail, onTasksReload, loadFollowups, loadChatHistory]);
const downloadTrace = useCallback(async () => {
const t = currentTaskRef.current;
@@ -399,7 +412,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR
currentTask, detailBody, actionMsg, actionReasons, costBox, forkVisible, setForkVisible, forkMsg,
coordChildren, agents, agentsNote, reviews, reviewMsg, reviewError, traceEntries, traceNote, traceDone,
traceKind, setTraceKind, traceMsg, followups, approvals, approvalMsg, peerMessages, peerMsg,
threadText, threadGateNote, threadMsg, threadList, chatMsg,
threadText, threadGateNote, threadMsg, threadList, chatMsg, chatHistory,
showDetail, refreshDetailLists, doAction, taskCost, forkTask, postReview, submitFindings,
decideApproval, postPeer, threadAction, sendChatMessage, downloadTrace,
refreshReviews: () => void loadReviews(), refreshApprovals: () => void loadApprovals(),
@@ -5,25 +5,33 @@ import { traceEntryToChatMessage, type ChatMessage } from "../../components/chat
import { TRACE_TERMINAL } from "../../api/types";
// Continue chat: same ChatList/ChatBubble/ChatInput as the trace timeline.
// History is the task prompt (user) + message-kind trace entries
// (assistant) + followups already sent (user). Live followups and trace
// entries carry no timestamps, so they cannot be interleaved exactly:
// followups are appended after trace messages and this is stated here
// and in the final report, not hidden by a fabricated order.
// Sending uses only live APIs: terminal tasks -> POST /tasks/:id/continue,
// queued/running tasks -> POST /tasks/:id/followups (direct). No new API.
// 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).
export default function ContinuePanel() {
const ctx = useConsole();
const t = ctx.currentTask;
const messages = useMemo<ChatMessage[]>(() => {
if (!t) return [];
if (ctx.chatHistory) {
return ctx.chatHistory.messages.map((m) => ({
id: m.id, role: m.role, text: m.text, label: m.label,
}));
}
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.traceEntries, ctx.followups]);
}, [t, ctx.chatHistory, ctx.traceEntries, ctx.followups]);
if (!t) return <div className="muted">点任务行查看详情后,这里显示继续会话</div>;
const terminal = TRACE_TERMINAL.includes(t.status);
const running = ["queued", "running"].includes(t.status);
@@ -33,8 +41,15 @@ export default function ContinuePanel() {
: running
? "运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)"
: "merged/merge-failed 任务不可继续,也不可发后续消息";
const legacy = ctx.chatHistory ? (ctx.chatHistory.legacy_continue_count ?? null) : null;
return (
<div className="chat-column">
{legacy !== null && legacy > 0 ? (
<div className="muted">此前有 {legacy} 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。</div>
) : null}
{ctx.chatHistory && ctx.chatHistory.trace_note ? (
<div className="muted">{ctx.chatHistory.trace_note}</div>
) : null}
<ChatList messages={messages} empty="(暂无会话消息)" />
<ChatInput
disabled={!canSend}