fix(agent): 主 Agent 每个会话均可交互,修实时徽章滞留

上一版把非当前段设为只读、且仅当前段可发消息,与"像会话页一样在任一
会话都能对话"的诉求不符;同时实时徽章改用 recentLive 后,turn 结束仍
会在 5s 窗口内继续显示"实时流式中"。

- server: chat 接口接收目标段 seg,消息写到用户正在看的会话段(缺省=最新段)
- web: 去掉只读/当前/历史区分,任一主 Agent 会话都可直接对话;send 带 seg
- web: 实时徽章改回按真实运行状态(sending/mainChatRunning)判断,且收到
  终结记录(result/报错)即刻熄灭;输入框同样在终结记录后立即解禁
This commit is contained in:
Autumn-27 committed 2026-09-10 13:14:54 -04:00
1 parent b4bbe612df
commit 6b59d4126e
4 files changed
+57 -50

No files matched your search

+11 -4
View File
@@ -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
@@ -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}
</span>
) : null}
{s.role === "mainagent" && s.isCurrentMain && !s.live && (
<span className="inline-flex shrink-0 items-center rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
当前
</span>
)}
{s.role === "mainagent" && !s.isCurrentMain && (
<span className="inline-flex shrink-0 items-center rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
历史
</span>
)}
{s.live && (
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<span className="size-1 animate-pulse rounded-full bg-blue-500" />
@@ -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<number | null>(() => {
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<Session[]>(
() =>
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 }) {
<Transcript activity={activity} live={active.live} taskId={taskId} chat={isMain} />
) : (
<div className="pl-9 text-xs text-muted-foreground">
{isMainCurrent ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。" : "暂无活动记录。"}
{isMain ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。" : "暂无活动记录。"}
</div>
)}
</div>
</ScrollArea>
{isMain && !isMainCurrent ? (
<div className="flex flex-col items-center gap-2 border-t p-3 text-center text-xs text-muted-foreground">
<span>这是历史会话(只读)。切回当前会话或新建会话以继续对话。</span>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => setActiveId(mainSessionId(currentSeg))}>
回到当前会话
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={() => setConfirmNewMain(true)}
disabled={creatingMain}
>
新建会话
</Button>
</div>
</div>
) : isMain ? (
{isMain ? (
<div className="border-t p-3">
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
@@ -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"
/>
<InputGroupAddon align="block-end">
@@ -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 ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />}
</InputGroupButton>
{mainLive ? (
{mainBusy ? (
<InputGroupButton
className="ml-auto"
size="icon-xs"
+2 -2
View File
@@ -721,8 +721,8 @@ export const api = {
if (!r.ok) throw new Error(`report: ${r.status}`);
return r.text();
},
chat: (message: string, task?: string, attachments?: ChatAttachment[]) =>
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[]) => {
-1
View File
@@ -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 ----