diff --git a/server/server.go b/server/server.go index fefd45e..c7f1d40 100644 --- a/server/server.go +++ b/server/server.go @@ -1004,7 +1004,9 @@ func (s *Server) control(w http.ResponseWriter, r *http.Request) { case "pause": s.engine.Pause(t.ID, agent.AbortPausedByUser) // Main Agent turns run outside Engine's planner/worker context so they need - // their own cancellation. New turns are rejected while the task is paused. + // their own cancellation: pausing terminates the CURRENTLY running turn. New + // turns are still allowed while paused — the main-agent console is independent + // of task pause (the chat handler no longer rejects on IsPaused). s.cancelTaskChat(t.ID, agent.AbortChatPausedWithTask) case "resume": s.engine.Run(s.ctx, t) // ensure loops are alive, then un-pause + nudge @@ -3117,10 +3119,8 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { writeErr(w, 409, "任务正在删除,无法发送新消息") return } - if s.engine.IsPaused(t.ID) { - writeErr(w, 409, "任务已暂停,无法发送新消息") - return - } + // 注意:任务暂停(paused)不拦截主 Agent 对话。主 Agent 编排会话独立于 planner/ + // worker 的暂停,暂停中仍可继续对话(暂停只终止其正在进行的那一轮,见 control())。 var req struct { Message string `json:"message"` Attachments []chatAttachment `json:"attachments,omitempty"` // 方式1 上传的文件(路径相对任务工作目录) @@ -3133,9 +3133,9 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { // turn busy before its first activity/file write, including rule-mode turns. // If deletion wins the race, the second barrier check rejects this request. s.chatMu.Lock() - if s.engine.IsDeleting(t.ID) || s.engine.IsPaused(t.ID) { + if s.engine.IsDeleting(t.ID) { s.chatMu.Unlock() - writeErr(w, 409, "任务正在删除或已暂停,无法发送新消息") + writeErr(w, 409, "任务正在删除,无法发送新消息") return } if s.chatBusy[t.ID] { diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx index 1e18d1b..1396229 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx @@ -359,8 +359,6 @@ export function SessionsTab({ taskId }: { taskId: string }) { const [olderIntents, setOlderIntents] = React.useState([]); const [intentsHasMore, setIntentsHasMore] = React.useState(false); const [loadingOlderIntents, setLoadingOlderIntents] = React.useState(false); - // Local-only chat messages overlaid onto the main-agent transcript. - const [chatExtra, setChatExtra] = React.useState([]); const [input, setInput] = React.useState(""); const [sending, setSending] = React.useState(false); const [stopping, setStopping] = React.useState(false); @@ -613,7 +611,6 @@ export function SessionsTab({ taskId }: { taskId: string }) { // ID) resumes from the DB, so a dropped realtime link self-heals; seq-merge dedups. React.useEffect(() => { setStore({}); - setChatExtra([]); setSseLive(false); setActiveId(MAIN_ID); // a stale worker id from the previous task must not leak in snapshotRef.current = 0; @@ -892,13 +889,11 @@ export function SessionsTab({ taskId }: { taskId: string }) { // worker session shows only its intent's activity, led by the intent objective. const activity = React.useMemo(() => { const items = activeState?.items ?? []; - if (isMain) { - // chatExtra is an optimistic local echo — drop any entry the persisted (SSE) - // copy already covers so messages don't show twice after the backend round-trips. - const seen = new Set(items.map((a) => `${a.kind} ${a.summary}`)); - const pending = chatExtra.filter((a) => !seen.has(`${a.kind} ${a.summary}`)); - return [...items, ...pending].sort((a, b) => a.seq - b.seq); - } + // The main-agent console renders purely from server data: the human turn is + // persisted+broadcast by the backend BEFORE it returns, so it arrives over the + // same SSE stream (worker="mainagent") as every agent step — no client-side + // optimistic echo, hence no fabricated seq that could collide with real DB ids. + if (isMain) return items; if (isPlanner || isSystem) return items; // Worker session: the intent leads the transcript as a right-aligned "user"-style // message (the task handed to this worker), followed by its execution steps. @@ -918,7 +913,6 @@ export function SessionsTab({ taskId }: { taskId: string }) { isPlanner, isSystem, activeState, - chatExtra, active.id, active.title, active.last_activity, @@ -1021,36 +1015,19 @@ export function SessionsTab({ taskId }: { taskId: string }) { const text = input.trim(); const atts = attachments; if ((!text && atts.length === 0) || sending) return; - const base = [...(store.main?.items ?? []), ...chatExtra]; - const nextSeq = Math.max(0, ...base.map((a) => a.seq)) + 1; - const userMsg: Activity = { - seq: nextSeq, - worker: "mainagent", - ts: new Date().toISOString(), - kind: "user", - summary: text, - // 有附件时把 {text, attachments} 塞进 detail,乐观气泡直接渲染卡片,无需再拉取。 - detail: atts.length > 0 ? JSON.stringify({ text, attachments: atts }) : undefined, - }; - // optimistic echo of the human turn only; the agent's steps + final answer - // stream back live via SSE (worker="mainagent"), so we don't append the reply. - setChatExtra((prev) => [...prev, userMsg]); + // No optimistic echo: the backend persists+broadcasts the human turn before it + // returns, so it streams back over SSE (worker="mainagent") with its real DB + // seq — the transcript renders it from server data like every other step. Clear + // the composer eagerly for responsiveness; restore it if the send fails. setInput(""); setAttachments([]); setSending(true); api .chat(text, taskId, atts.length > 0 ? atts : undefined) - .catch(() => { - setChatExtra((prev) => [ - ...prev, - { - seq: nextSeq + 1, - worker: "mainagent", - ts: new Date().toISOString(), - kind: "text", - summary: "(发送失败,请稍后重试)", - }, - ]); + .catch((e) => { + setInput(text); // restore so the user doesn't lose their text / attachments + setAttachments(atts); + toast.error("发送失败:" + ((e as Error).message || "请稍后重试")); }) .finally(() => setSending(false)); }