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:
1 parent
811912c841
commit
f02c513729
9 files changed
+524
-40
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user