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

+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(),