From 6b59d4126e4d19ab50e20d058c0da9771cb4678f Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:14:54 -0400 Subject: [PATCH] =?UTF-8?q?fix(agent):=20=E4=B8=BB=20Agent=20=E6=AF=8F?= =?UTF-8?q?=E4=B8=AA=E4=BC=9A=E8=AF=9D=E5=9D=87=E5=8F=AF=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=AE=9E=E6=97=B6=E5=BE=BD=E7=AB=A0=E6=BB=9E?= =?UTF-8?q?=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版把非当前段设为只读、且仅当前段可发消息,与"像会话页一样在任一 会话都能对话"的诉求不符;同时实时徽章改用 recentLive 后,turn 结束仍 会在 5s 窗口内继续显示"实时流式中"。 - server: chat 接口接收目标段 seg,消息写到用户正在看的会话段(缺省=最新段) - web: 去掉只读/当前/历史区分,任一主 Agent 会话都可直接对话;send 带 seg - web: 实时徽章改回按真实运行状态(sending/mainChatRunning)判断,且收到 终结记录(result/报错)即刻熄灭;输入框同样在终结记录后立即解禁 --- server/server.go | 15 +++- .../tasks/detail/_tabs/sessions-tab.tsx | 87 ++++++++++--------- web/src/lib/api.ts | 4 +- web/src/lib/types.ts | 1 - 4 files changed, 57 insertions(+), 50 deletions(-) diff --git a/server/server.go b/server/server.go index 057d20f..0b0b6fc 100644 --- a/server/server.go +++ b/server/server.go @@ -3450,6 +3450,7 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { var req struct { Message string `json:"message"` Attachments []chatAttachment `json:"attachments,omitempty"` // 方式1 上传的文件(路径相对任务工作目录) + Seg *int `json:"seg,omitempty"` // 目标主会话分段;缺省=最新段 } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, 400, err.Error()) @@ -3474,10 +3475,16 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { s.chatCancel[t.ID] = cancel s.chatMu.Unlock() - // The whole turn belongs to the current main-agent segment. Read it once (chatBusy - // serializes turns, so it can't change under us) and stamp every mainagent row with - // it, so this turn's transcript + activity land in the segment the user is viewing. - mainSeg, _ := t.Store.CurrentMainSeg() + // The turn belongs to whichever main-agent segment the user is chatting in (any + // segment is interactive, like the top-level chat conversations). Stamp every + // mainagent row with it so this turn's transcript + activity land in that segment. + // Missing seg (older clients) falls back to the newest segment. + mainSeg := 0 + if req.Seg != nil && *req.Seg >= 0 { + mainSeg = *req.Seg + } else { + mainSeg, _ = t.Store.CurrentMainSeg() + } segPtr := &mainSeg // Persist + broadcast the human turn so the 主 Agent 编排会话 survives page 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 832ebac..789892a 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 @@ -252,7 +252,6 @@ const MAIN_SESSION: Session = { live: true, last_activity: "", seg: 0, - isCurrentMain: true, }; // The planner session is, like the main-agent session, a fixed UI affordance with @@ -398,16 +397,6 @@ function SessionItem({ {unread > 99 ? "99+" : unread} ) : null} - {s.role === "mainagent" && s.isCurrentMain && !s.live && ( - - 当前 - - )} - {s.role === "mainagent" && !s.isCurrentMain && ( - - 历史 - - )} {s.live && ( @@ -1164,11 +1153,34 @@ export function SessionsTab({ taskId }: { taskId: string }) { [store], ); const currentMainKey = mainSessionKey(currentSeg); - const mainLive = sending || (mainChatRunning ?? recentLive(currentMainKey)); const plannerLive = recentLive("plan"); - // One switchable session per main-agent segment (newest-first). Only the current - // (highest) segment is live/writable; older ones are read-only history. + // Which main segment is streaming RIGHT NOW. A main turn is serialized per task, so at + // most one segment is live. Gate on the real running flag (sending / mainChatRunning) + // rather than "recent activity", and drop it the moment the turn's terminal record + // (kind='result', or an error) lands — otherwise the badge lingers for STREAM_WINDOW_MS + // after the agent already finished. null = nothing running. + const liveMainSeg = React.useMemo(() => { + if (!(sending || mainChatRunning)) return null; + // the streaming segment is the one with the freshest activity (incl. the just-sent turn) + let seg = currentSeg; + let bestTs = -1; + for (const m of mainSegs) { + const raw = store[mainSessionKey(m.seq)]?.lastTs; + const ts = raw ? Date.parse(raw) : -1; + if (ts > bestTs) { + bestTs = ts; + seg = m.seq; + } + } + const items = store[mainSessionKey(seg)]?.items ?? []; + const last = items[items.length - 1]; + if (last && (last.kind === "result" || (last.kind === "text" && last.is_error))) return null; + return seg; + }, [sending, mainChatRunning, mainSegs, store, currentSeg]); + + // Every main-agent segment is an independent, interactive session (like the top-level + // chat conversations) — you can talk in any of them, newest-first. const mainSessions = React.useMemo( () => mainSegs.map((m) => ({ @@ -1176,12 +1188,11 @@ export function SessionsTab({ taskId }: { taskId: string }) { role: "mainagent", title: mainSessionTitle(m.seq), status: "running", - live: m.seq === currentSeg && mainLive, + live: m.seq === liveMainSeg, last_activity: m.created_at, seg: m.seq, - isCurrentMain: m.seq === currentSeg, })), - [mainSegs, currentSeg, mainLive], + [mainSegs, liveMainSeg], ); const sessions = React.useMemo( @@ -1198,11 +1209,19 @@ export function SessionsTab({ taskId }: { taskId: string }) { const active = sessions.find((s) => s.id === activeId) ?? MAIN_SESSION; const isMain = active.role === "mainagent"; - const isMainCurrent = isMain && (active.seg ?? 0) === currentSeg; // only the current segment is writable const isPlanner = active.role === "planner"; const isSystem = active.role === "system"; const activeKey = keyForSession(active); const activeState = store[activeKey]; + // A main turn is serialized per task (chat lock), so "busy" is task-wide: while any + // main segment is mid-turn the active composer is disabled. Clear it the moment the + // active session's terminal record (kind='result', or an error) lands, so the input + // re-enables immediately instead of waiting for the next chat-status poll. + const activeItems = activeState?.items ?? []; + const activeLast = activeItems[activeItems.length - 1]; + const activeSettled = + !!activeLast && (activeLast.kind === "result" || (activeLast.kind === "text" && activeLast.is_error)); + const mainBusy = isMain && (sending || (!activeSettled && (mainChatRunning ?? recentLive(activeKey)))); // 折叠态(手机端)标题栏要替代整张列表:显示当前会话名 + 其它会话的未读合计, // 否则收起后既不知道自己在看哪个会话,也看不到别处有新消息。 const activeDisplayTitle = (active.role === "worker" ? sessionMeta.get(active.id)?.title : "") || active.title; @@ -1400,7 +1419,7 @@ export function SessionsTab({ taskId }: { taskId: string }) { setSending(true); chatStatusRequestRef.current++; api - .chat(text, taskId, atts.length > 0 ? atts : undefined) + .chat(text, taskId, atts.length > 0 ? atts : undefined, active.seg ?? 0) .then(({ mode }) => { chatStatusRequestRef.current++; setMainChatRunning(mode === "llm"); @@ -1770,30 +1789,12 @@ export function SessionsTab({ taskId }: { taskId: string }) { ) : (
- {isMainCurrent ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。" : "暂无活动记录。"} + {isMain ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。" : "暂无活动记录。"}
)} - {isMain && !isMainCurrent ? ( -
- 这是历史会话(只读)。切回当前会话或新建会话以继续对话。 -
- - -
-
- ) : isMain ? ( + {isMain ? (
{attachments.length > 0 && (
@@ -1835,9 +1836,9 @@ export function SessionsTab({ taskId }: { taskId: string }) { onKeyDown={(e) => { if (!shouldSubmitOnKey(e, sendMode)) return; e.preventDefault(); - if (!mainLive) send(); + if (!mainBusy) send(); }} - disabled={mainLive} + disabled={mainBusy} className="max-h-36 min-h-9 overflow-y-auto" /> @@ -1845,13 +1846,13 @@ export function SessionsTab({ taskId }: { taskId: string }) { size="icon-xs" variant="ghost" onClick={() => fileInputRef.current?.click()} - disabled={mainLive || uploading} + disabled={mainBusy || uploading} title="上传文件" aria-label="上传文件" > {uploading ? : } - {mainLive ? ( + {mainBusy ? ( - post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }), + chat: (message: string, task?: string, attachments?: ChatAttachment[], seg?: number) => + post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments, seg }), chatStatus: (taskId: string) => get<{ running: boolean }>(`/tasks/${taskId}/chat/status`), // 方式1 文件上传:落到会话/任务工作目录 uploads/,返回可供 agent Read 的相对路径。 chatUpload: async (scope: "task" | "session" | "staging", id: string, files: File[]) => { diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 2df07e0..b5bab8a 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -831,7 +831,6 @@ export interface Session { source_task_id?: string; inherited?: boolean; seg?: number; // main-agent session: which conversation segment (0 = original) - isCurrentMain?: boolean; // main-agent session: the writable current segment } // ---- Security ----