@spzxg/cli-ui 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { g as yu, c as ft, a as xu } from "./cli-ui-embed-hutDQhkT.js";
1
+ import { g as yu, c as ft, a as xu } from "./cli-ui-embed-C0F64Ln7.js";
2
2
  function vu(e, t) {
3
3
  for (var n = 0; n < t.length; n++) {
4
4
  const i = t[n];
package/dist/cli-ui.js CHANGED
@@ -1,4 +1,4 @@
1
- import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-hutDQhkT.js";
1
+ import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-C0F64Ln7.js";
2
2
  export {
3
3
  a as CliBoard,
4
4
  i as CliBrowserView,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spzxg/cli-ui",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "任务中心(cli-task-center)对话任务的前端积木:cli-msglist 消息对话列表、cli-chat 对话,数据全部来自 center 服务。",
6
6
  "main": "src/index.ts",
@@ -76,6 +76,19 @@ const REASON_TEXT: Record<string, string> = {
76
76
  node_reassigned: "执行机器换到了别的产品线", node_lost: "执行节点长时间连不上",
77
77
  };
78
78
 
79
+ // 一次性任务(含续派)的子任务状态(center TaskStatus)。
80
+ const BATCH_STATUS: Record<string, { dotCls: DotCls; statusText: string }> = {
81
+ succeeded: { dotCls: "g", statusText: "子任务已完成" }, failed: { dotCls: "x", statusText: "子任务失败" }, cancelled: { dotCls: "x", statusText: "子任务已取消" },
82
+ dispatching: { dotCls: "a", statusText: "正在派发" }, running: { dotCls: "a", statusText: "运行中" }, verifying: { dotCls: "a", statusText: "正在核对结果" },
83
+ queued: { dotCls: "a", statusText: "等待执行" }, blocked: { dotCls: "a", statusText: "等待执行" }, paused: { dotCls: "a", statusText: "已暂停" },
84
+ orphaned: { dotCls: "a", statusText: "执行节点暂时连不上,恢复后继续" },
85
+ };
86
+ function batchStatusView(taskStatus: string | null, jobStatus: string | null): { dotCls: DotCls; statusText: string } {
87
+ if (taskStatus && BATCH_STATUS[taskStatus]) return BATCH_STATUS[taskStatus];
88
+ if (jobStatus && TERMINAL_JOB.has(jobStatus)) return { dotCls: jobStatus === "succeeded" ? "g" : "x", statusText: jobStatus === "succeeded" ? "已完成" : jobStatus === "cancelled" ? "已取消" : "已失败" };
89
+ return { dotCls: "a", statusText: "进行中" };
90
+ }
91
+
79
92
  /** 「智能体在工作」那一行:跑了多久,很久没输出时提示可能卡住。与 Operator 的 RunningIndicator 一致。 */
80
93
  const QUIET_AFTER_MS = 180_000;
81
94
  function RunningIndicator({ agent, agentName, startedAt, lastActivityRef }: { agent: string | null; agentName: string; startedAt: number | null; lastActivityRef: React.MutableRefObject<number> }) {
@@ -107,7 +120,6 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
107
120
  // 配置按内容比较:宿主每次渲染传新对象也不会让消息行全部重渲染。
108
121
  const configKey = JSON.stringify(config ?? {});
109
122
  const cfg = useMemo(() => resolveChatConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
110
- const readOnly = !!readOnlyProp || !cfg.composer.show;
111
123
  const store = useCliStore();
112
124
  const selected = useCliState((s) => s.selected);
113
125
  // 显示哪个对话:
@@ -157,8 +169,13 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
157
169
  })), [allMessages, cfg.transcript.tools]);
158
170
  const running = conv ? isRunning(conv) : false;
159
171
  const job = entry?.job;
160
- // 这个对话对应的子任务:指定了就是它,否则对话任务的第一个(「新开」时借用第一个的智能体显示)。
161
- const task = (taskId ? job?.tasks?.find((t) => t.id === taskId) : undefined) ?? (taskId ? undefined : job?.tasks?.[0]);
172
+ // 一次性任务(含续派,不是对话任务):只读查看其中一个子任务——没有轮次,不能发消息、结束、回答。
173
+ const batch = !!conv?.batch || (!!job && job.mode !== "conversation");
174
+ const readOnly = !!readOnlyProp || !cfg.composer.show || batch;
175
+ // 这个对话对应的子任务:指定了就是它,否则对话任务的第一个(「新开」时借用第一个的智能体显示);
176
+ // 一次性任务没指定时是实时流挑的那个(最近一个派发过的)。
177
+ const shownTaskId = taskId ?? (batch ? conv?.task?.id : undefined);
178
+ const task = (shownTaskId ? job?.tasks?.find((t) => t.id === shownTaskId) : undefined) ?? (shownTaskId || batch ? undefined : job?.tasks?.[0]);
162
179
  const info = taskId ? job?.conversation?.conversations?.find((c) => c.task_id === taskId) : undefined;
163
180
  const agent = task?.agent ?? conv?.turns[conv.turns.length - 1]?.agent ?? null;
164
181
  const agentText = labelOf(agent);
@@ -176,15 +193,17 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
176
193
  const continuedBy = conv?.continuedBy ?? (taskId ? null : job?.conversation?.continued_by_job_id ?? null);
177
194
  const jobTitle = job?.title ?? conv?.job?.title;
178
195
  // 对话任务里有几个对话时,标题是「对话任务 / 这个对话」。
179
- const manyConvs = (job?.conversation?.conversations?.length ?? 0) > 1;
196
+ const manyConvs = (batch ? job?.tasks?.length ?? 0 : job?.conversation?.conversations?.length ?? 0) > 1;
180
197
  const convName = conv?.task?.title ?? info?.title ?? task?.title;
181
198
  const convTitle = isNew ? `${jobTitle ? `${jobTitle} / ` : ""}新对话`
182
- : taskId && manyConvs && convName && convName !== jobTitle ? `${jobTitle ?? "对话"} / ${convName}`
199
+ : (taskId || batch) && manyConvs && convName && convName !== jobTitle ? `${jobTitle ?? "对话"} / ${convName}`
183
200
  : jobTitle ?? (key ? "对话" : "新对话");
184
201
  const awaitingAnswer = useMemo(() => hasOpenAsk(messages), [messages]);
185
202
  const lastTurn = conv?.turns[conv.turns.length - 1];
186
203
  const awaiting = !ended && !running && !!lastTurn?.asked && lastTurn.status === "succeeded";
187
- const loading = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error";
204
+ // 一次性任务的子任务还没派发(没有节点对话)、或者流已经结束还没有转录:不再转圈,转录里有说明。
205
+ const loading = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error" && conv.stream !== "ended" && !(conv.batch && !conv.remoteTaskId);
206
+ const batchStatus = batch ? conv?.batchTask?.status ?? task?.status ?? null : null;
188
207
 
189
208
  // 用量:任务详情里的累计 + 实时流里上次刷新后的增量(「新开」的对话还没有用量,不借第一个对话的)。
190
209
  const usageTask = isNew ? undefined : task;
@@ -194,12 +213,13 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
194
213
 
195
214
  const { dotCls, statusText } = useMemo((): { dotCls: DotCls; statusText: string } => {
196
215
  if (!key || isNew) return { dotCls: "r", statusText: "未开始" };
216
+ if (batch) return batchStatusView(batchStatus, jobStatus);
197
217
  if (ended) return { dotCls: status === "succeeded" ? "g" : "x", statusText: REASON_TEXT[closedReason ?? ""] ?? (status === "succeeded" ? "已结束" : status === "cancelled" ? "已取消" : "已失败") };
198
218
  if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
199
219
  if (running) return { dotCls: "a", statusText: "运行中" };
200
220
  if (status === "queued") return { dotCls: "a", statusText: "等待执行节点" };
201
221
  return { dotCls: "a", statusText: "进行中" };
202
- }, [key, isNew, ended, status, closedReason, awaiting, running]);
222
+ }, [key, isNew, batch, batchStatus, jobStatus, ended, status, closedReason, awaiting, running]);
203
223
 
204
224
  // ── 处理器(引用稳定,MessageView 的 memo 才有效) ──
205
225
  // 输入框之外的操作(回答提问、撤回排队、结束对话、停止)失败时在顶部提示,不静默吞掉。
@@ -591,7 +611,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
591
611
  {cfg.transcript.runningIndicator && running && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
592
612
  {!hasBelow && queued.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho={false} handlers={handlers} view={view} />)}
593
613
  {ended && (
594
- <div className="session-label"><span className="ln" />{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}<span className="ln" /></div>
614
+ <div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
595
615
  )}
596
616
  {continuedBy && (
597
617
  <div className="load-older"><button className="btn btn-line btn-sm" onClick={() => go(continuedBy)}>转到接续的新对话</button></div>
@@ -2,7 +2,7 @@ import { createSseParser } from "./sse.js";
2
2
  import { NEW_TASK, parseConvKey } from "./conversation.js";
3
3
  import type {
4
4
  BoardInfo, BrowserMessage, BrowserStatus, CenterJob, CenterTask, CenterTurn, DiffResult, DirListing, FileInfo,
5
- NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot,
5
+ BatchStreamSnapshot, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot,
6
6
  } from "./types.js";
7
7
 
8
8
  /**
@@ -246,7 +246,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
246
246
  const parser = createSseParser(({ event, data }) => {
247
247
  let payload: unknown;
248
248
  try { payload = JSON.parse(data); } catch { return; }
249
- if (event === "snapshot") handlers.onSnapshot?.(payload as StreamSnapshot);
249
+ if (event === "snapshot") handlers.onSnapshot?.(payload as StreamSnapshot | BatchStreamSnapshot);
250
250
  else if (event === "chunk") handlers.onChunk?.((payload as { events?: StreamItem[] }).events ?? []);
251
251
  else if (event === "end") handlers.onEnd?.(payload as StreamEnd);
252
252
  });
@@ -278,7 +278,8 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
278
278
 
279
279
  export interface StreamHandlers {
280
280
  onOpen?: () => void;
281
- onSnapshot?: (snapshot: StreamSnapshot) => void;
281
+ /** 对话任务是 StreamSnapshot;一次性任务(含续派)是 BatchStreamSnapshot(见 isBatchSnapshot)。 */
282
+ onSnapshot?: (snapshot: StreamSnapshot | BatchStreamSnapshot) => void;
282
283
  onChunk?: (events: StreamItem[]) => void;
283
284
  onEnd?: (end: StreamEnd) => void;
284
285
  /** 流断了(error 为 null = 服务端正常关闭)。onEnd 之后也会触发一次。 */
@@ -6,13 +6,16 @@
6
6
  * · 节点转录(transcript + node 事件):Agent 实际的对话过程,规则与 Operator 的 useTaskStream 一致。
7
7
  * 界面以节点转录为主;转录还没有(没派发、节点连不上)时用轮次的 input / reply 兜底。
8
8
  */
9
- import type { AskAnswers, AskQuestion, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, JobStatus, Message, Msg, NodeEvent, StreamItem, StreamSnapshot, StreamTask, ToolData, TurnUsage } from "./types.js";
9
+ import { isBatchSnapshot, type AskAnswers, type AskQuestion, type BatchStreamSnapshot, type BatchStreamTask, type CenterTurn, type ConversationClosedCallback, type ConversationTurnCallback, type JobStatus, type Message, type Msg, type NodeEvent, type StreamItem, type StreamSnapshot, type StreamTask, type ToolData, type TurnUsage } from "./types.js";
10
10
  import { indexFromEnd, mergeSnapshot, toolDataOf } from "../ui/transcriptModel.js";
11
11
  import { formatAnswersText } from "../ui/format.js";
12
12
 
13
13
  export const TERMINAL_TURN = new Set(["succeeded", "failed", "cancelled", "merged"]);
14
14
  export const ACTIVE_TURN = new Set(["queued", "dispatching", "running"]);
15
15
  export const TERMINAL_JOB = new Set(["succeeded", "failed", "cancelled"]);
16
+ /** 一次性任务的子任务:结束了的、正在节点上跑的。 */
17
+ export const TERMINAL_TASK = TERMINAL_JOB;
18
+ const ACTIVE_TASK = new Set(["dispatching", "running", "verifying"]);
16
19
 
17
20
  // ── 对话键 ─────────────────────────────────────────────
18
21
  //
@@ -76,11 +79,19 @@ export interface ConvState {
76
79
  continuedBy: string | null;
77
80
  /** 本页提交过的提问回答(askId → 答案)。节点上那一轮已被中枢结束,不会回 ask_answered,只能本地记。 */
78
81
  localAnswers: Record<string, AskAnswers>;
82
+ /**
83
+ * 一次性任务(含续派,不是对话任务):只读查看其中一个子任务。没有轮次,task 是那个子任务,
84
+ * batchTask 是它的原样(状态、错误、原文),remoteTaskId 是它在节点上的对话(流里只认这个对话的转录)。
85
+ */
86
+ batch: boolean;
87
+ batchTask: BatchStreamTask | null;
88
+ remoteTaskId: string | null;
79
89
  }
80
90
 
81
91
  export const emptyConv = (key: string): ConvState => ({
82
92
  key, ...parseConvKey(key), job: null, task: null, turns: [], msgs: [], hasTranscript: false, nodeRunning: false, resolvedModel: null,
83
93
  liveUsage: { fresh: 0, cacheRead: 0 }, stream: "idle", streamError: null, endReason: null, continuedBy: null, localAnswers: {},
94
+ batch: false, batchTask: null, remoteTaskId: null,
84
95
  });
85
96
 
86
97
  export function upsertTurns(turns: CenterTurn[], incoming: CenterTurn[]): CenterTurn[] {
@@ -101,23 +112,70 @@ export function addTurnsIfAbsent(turns: CenterTurn[], incoming: CenterTurn[]): C
101
112
  return fresh.length ? upsertTurns(turns, fresh) : turns;
102
113
  }
103
114
 
104
- export function applySnapshot(conv: ConvState, snapshot: StreamSnapshot): ConvState {
115
+ export function applySnapshot(conv: ConvState, snapshot: StreamSnapshot | BatchStreamSnapshot): ConvState {
116
+ if (isBatchSnapshot(snapshot)) return applyBatchSnapshot(conv, snapshot);
105
117
  const { job } = snapshot;
106
118
  return {
107
119
  ...conv,
108
120
  job: { status: job.status, closed_reason: job.closed_reason, node_id: job.node_id, title: job.title },
109
121
  task: snapshot.task ?? conv.task,
110
- turns: upsertTurns([], snapshot.turns),
122
+ turns: upsertTurns([], Array.isArray(snapshot.turns) ? snapshot.turns : []),
111
123
  resolvedModel: conv.resolvedModel ?? job.conversation?.last_turn?.resolved_model ?? null,
112
124
  };
113
125
  }
114
126
 
127
+ // ── 一次性任务(含续派):只读查看其中一个子任务 ─────────
128
+ //
129
+ // 指定了子任务就看它;没指定看最近一个派发过的(续派开出新的子任务时跟过去)。
130
+ // 流里可能同时有几个节点对话的事件(带 remote_task_id),只认看的这个子任务所在的那个。
131
+
132
+ const batchStreamTask = (task: BatchStreamTask): StreamTask => ({
133
+ id: task.id, seq: task.seq, title: task.title, status: task.status, closed: TERMINAL_TASK.has(task.status),
134
+ remote_task_id: task.remote_task_id, continue_from_task_id: task.continue_from_task_id,
135
+ });
136
+
137
+ /** 没指定子任务时看哪个:最近一个派发过的,都没派发过就是最后一个。 */
138
+ export const latestBatchTask = (tasks: BatchStreamTask[]) =>
139
+ [...tasks].sort((a, b) => a.seq - b.seq).reverse().find((task) => task.remote_task_id) ?? tasks.reduce<BatchStreamTask | null>((last, task) => (!last || task.seq > last.seq ? task : last), null);
140
+
141
+ /** 换成看另一个子任务(或同一个子任务换了节点对话,例如重试):节点转录清掉,等新对话的转录。 */
142
+ function focusBatchTask(conv: ConvState, task: BatchStreamTask | null): ConvState {
143
+ const remote = task?.remote_task_id ?? null;
144
+ const sameRemote = remote === conv.remoteTaskId;
145
+ // 状态变化事件不一定带原文(没指定子任务时):沿用已有的。
146
+ const kept = conv.batchTask && task && conv.batchTask.id === task.id ? { prompt: conv.batchTask.prompt, opener_prompt: conv.batchTask.opener_prompt } : {};
147
+ const batchTask = task ? { ...kept, ...task } : null;
148
+ return {
149
+ ...conv, batch: true, batchTask, task: batchTask ? batchStreamTask(batchTask) : null, remoteTaskId: remote,
150
+ ...(sameRemote ? {} : { msgs: [], hasTranscript: false, nodeRunning: false }),
151
+ };
152
+ }
153
+
154
+ function applyBatchSnapshot(conv: ConvState, snapshot: BatchStreamSnapshot): ConvState {
155
+ const { job } = snapshot;
156
+ const tasks = Array.isArray(snapshot.tasks) ? snapshot.tasks : [];
157
+ // 指定了子任务就看它;没指定看流给的那个(最近一个派发过的),旧版中枢不给时自己挑。
158
+ const wanted = conv.taskId ? tasks.find((task) => task.id === conv.taskId) ?? null : snapshot.task ?? latestBatchTask(tasks);
159
+ const shown = wanted && snapshot.task?.id === wanted.id ? { ...wanted, ...snapshot.task } : wanted;
160
+ return focusBatchTask({ ...conv, job: { status: job.status, closed_reason: null, node_id: job.node_id, title: job.title }, turns: [] }, shown);
161
+ }
162
+
163
+ /** 一次性任务里的子任务状态变化。 */
164
+ function applyBatchTask(conv: ConvState, task: BatchStreamTask): ConvState {
165
+ if (conv.batchTask?.id === task.id) return focusBatchTask(conv, task);
166
+ // 没指定子任务:续派开出了更新的子任务(已经派发到节点),跟过去。
167
+ if (!conv.taskId && task.remote_task_id && (!conv.batchTask || task.seq > conv.batchTask.seq)) return focusBatchTask(conv, task);
168
+ return conv;
169
+ }
170
+
115
171
  // 缺字段时兜底:generation 缺省 1(按它分会话,缺了会把顺序排乱);时间缺就不显示。
116
172
  const toMsg = (m: Message): Msg => ({ id: m.id, role: m.role, content: m.content, generation: typeof m.generation === "number" ? m.generation : 1, ...(typeof m.created_at === "number" ? { ts: m.created_at } : {}) });
117
173
 
118
174
  export function applyItems(conv: ConvState, items: StreamItem[]): ConvState {
119
175
  let next = conv;
120
176
  for (const item of items) {
177
+ // 一次性任务:只认看的这个子任务所在的节点对话。
178
+ if (next.batch && (item.type === "transcript" || item.type === "node") && item.remote_task_id && item.remote_task_id !== next.remoteTaskId) continue;
121
179
  if (item.type === "transcript") {
122
180
  // 节点转录全量(连上 / 重连节点时各一次):整体替换,没变的消息保留对象引用。
123
181
  const messages = Array.isArray(item.messages) ? item.messages.map(toMsg) : [];
@@ -131,9 +189,9 @@ export function applyItems(conv: ConvState, items: StreamItem[]): ConvState {
131
189
  const settled = TERMINAL_TURN.has(item.turn.status) && !turns.some((turn) => ACTIVE_TURN.has(turn.status));
132
190
  next = { ...next, turns, resolvedModel: item.turn.resolved_model ?? next.resolvedModel, ...(settled ? { nodeRunning: false } : {}) };
133
191
  } else if (item.type === "job") {
134
- next = { ...next, job: { ...next.job, status: item.job.status, closed_reason: item.job.closed_reason, node_id: item.job.node_id } };
192
+ next = { ...next, job: { ...next.job, status: item.job.status, closed_reason: item.job.closed_reason ?? null, node_id: item.job.node_id } };
135
193
  } else if (item.type === "task") {
136
- next = { ...next, task: item.task };
194
+ next = next.batch ? applyBatchTask(next, item.task as BatchStreamTask) : { ...next, task: item.task as StreamTask };
137
195
  }
138
196
  }
139
197
  return next;
@@ -227,7 +285,7 @@ export const convEnded = (conv: Pick<ConvState, "job" | "task">) => !!conv.task?
227
285
  /** 正在执行的那一轮(派发中或执行中)。 */
228
286
  export const runningTurn = (conv: ConvState) => conv.turns.find((turn) => turn.status === "running" || turn.status === "dispatching") ?? null;
229
287
 
230
- export const isRunning = (conv: ConvState) => conv.nodeRunning || !!runningTurn(conv);
288
+ export const isRunning = (conv: ConvState) => conv.nodeRunning || !!runningTurn(conv) || (conv.batch && ACTIVE_TASK.has(conv.batchTask?.status ?? ""));
231
289
 
232
290
  /**
233
291
  * 界面上的转录:
@@ -236,6 +294,7 @@ export const isRunning = (conv: ConvState) => conv.nodeRunning || !!runningTurn(
236
294
  * 3. 还在中枢排队的消息,作为「排队中」气泡(可撤回),由界面钉在最下面。
237
295
  */
238
296
  export function viewMessages(conv: ConvState): Msg[] {
297
+ if (conv.batch) return batchViewMessages(conv);
239
298
  let out: Msg[] = [];
240
299
  // 有节点消息就以节点为准:通常先收到全量转录,但新对话可能先来增量事件(user / assistant / tool),也算。
241
300
  if (conv.msgs.length) {
@@ -296,6 +355,48 @@ export function viewMessages(conv: ConvState): Msg[] {
296
355
  return out;
297
356
  }
298
357
 
358
+ // 中枢派发一次性任务时加在正文前的标记行,和完成后的自动核对(见 center scheduler.ts)。
359
+ const CENTER_MARKER_LINES = /^(?:\[CENTER-(?:ATTEMPT|TERMINAL-REVISION):[^\]\n]*\][ \t]*(?:\r?\n|$))+/;
360
+ const CONFIRM_MARK = "[CENTER-CONFIRM]";
361
+ const BATCH_TASK_ERROR: Record<string, string> = { failed: "子任务失败", cancelled: "子任务已取消" };
362
+
363
+ /**
364
+ * 一次性任务(含续派)的转录,只读:
365
+ * 1. 第一条用户消息是 Operator 固定的启动语句,换成开出这个节点对话的子任务原文(流里给了才换);
366
+ * 2. 去掉中枢加在正文前的标记行([CENTER-ATTEMPT:…]、[CENTER-TERMINAL-REVISION:…]);
367
+ * 3. 中枢的完成核对([CENTER-CONFIRM] 追问 + Agent 回的 TASK_COMPLETE / TASK_INCOMPLETE)合成一行说明;
368
+ * 4. 还没派发、拿不到执行记录、失败 / 取消时补一行说明。
369
+ */
370
+ function batchViewMessages(conv: ConvState): Msg[] {
371
+ const out: Msg[] = [];
372
+ const task = conv.batchTask;
373
+ const opener = task?.opener_prompt ?? null;
374
+ const firstUserId = conv.msgs.find((m) => m.role === "user")?.id;
375
+ for (let i = 0; i < conv.msgs.length; i++) {
376
+ const m = conv.msgs[i];
377
+ if (m.role === "user" && m.content.includes(CONFIRM_MARK)) {
378
+ const reply = conv.msgs[i + 1]?.role === "assistant" ? conv.msgs[++i] : null;
379
+ const content = !reply ? "中枢正在核对任务是否完成…" : /^\s*TASK_COMPLETE\b/.test(reply.content) ? "✓ 中枢核对:任务已完成" : "⚠ 中枢核对:任务没有完成";
380
+ out.push({ id: `confirm-${m.id}`, role: "system", content, generation: m.generation, ...(m.ts !== undefined ? { ts: m.ts } : {}) });
381
+ continue;
382
+ }
383
+ if (m.role !== "user") { out.push(m); continue; }
384
+ const content = m.id === firstUserId && opener ? opener : m.content.replace(CENTER_MARKER_LINES, "");
385
+ out.push(content === m.content ? m : { ...m, content });
386
+ }
387
+ const generation = out.length ? out[out.length - 1].generation : 1;
388
+ const note = (id: string, content: string) => out.push({ id: `batch-${id}`, role: "system", content, generation });
389
+ if (!task) {
390
+ if (conv.job) note("none", "这个任务里还没有子任务");
391
+ } else if (!conv.msgs.length && !task.remote_task_id && !TERMINAL_TASK.has(task.status)) {
392
+ note("waiting", "子任务还没派发到执行节点,派发后这里会实时显示执行过程");
393
+ } else if (!conv.msgs.length && conv.stream === "ended") {
394
+ note("empty", "没有拿到这个子任务的执行记录(执行节点上的会话可能已经清理)");
395
+ }
396
+ if (task && BATCH_TASK_ERROR[task.status]) note(`err-${task.id}-${task.attempt}`, `⚠ ${BATCH_TASK_ERROR[task.status]}${task.last_error ? `:${describeTurnError(task.last_error)}` : ""}`);
397
+ return out;
398
+ }
399
+
299
400
  /** 把说明行按时间插进转录:放在时间不晚于它的最后一条消息之后(没有时间的消息沿用前一条的时间)。 */
300
401
  function insertByTime(msgs: Msg[], notices: Msg[]): Msg[] {
301
402
  if (!notices.length) return msgs;
package/src/core/store.ts CHANGED
@@ -149,7 +149,8 @@ export function createCliStore(options: CliStoreOptions = {}) {
149
149
  // 对话结束(整个对话任务结束,或只关掉了这一个对话):只对本页看着还开着的对话发(打开时就已结束的不算);
150
150
  // 而且要等所有轮次都收尾之后再发,保证 conversation_closed 排在这个对话最后一个 conversation_turn 之后(与 center 的 callback_seq 一致)。
151
151
  if (next.job && !convEnded(next)) seenOpen.add(key);
152
- if (next.job && convEnded(next) && seenOpen.has(key) && !closedJobs.has(key)) {
152
+ // 一次性任务(只读查看)没有对话的结果通知。
153
+ if (next.job && convEnded(next) && seenOpen.has(key) && !closedJobs.has(key) && !next.batch) {
153
154
  const settled = next.turns.every((turn) => TERMINAL_TURN.has(turn.status));
154
155
  if (settled || next.stream === "ended") {
155
156
  closedJobs.add(key);
package/src/core/types.ts CHANGED
@@ -223,12 +223,13 @@ export type NodeEvent =
223
223
 
224
224
  /** center 实时流 chunk.events 里的一项。 */
225
225
  export type StreamItem =
226
- | { type: "transcript"; messages: Message[] | null; pending: PendingMessage[] | null; running: boolean | null }
227
- | { type: "node"; event: NodeEvent }
226
+ /** remote_task_id:一次性任务的流里才有(大任务里可能同时有几个节点对话)。 */
227
+ | { type: "transcript"; remote_task_id?: string; messages: Message[] | null; pending: PendingMessage[] | null; running: boolean | null }
228
+ | { type: "node"; remote_task_id?: string; event: NodeEvent }
228
229
  | { type: "turn"; turn: CenterTurn }
229
- | { type: "job"; job: { id: string; status: JobStatus; closed_reason: string | null; node_id: string | null } }
230
- /** 正在看的这个对话的状态变化(关闭了:closed=true)。 */
231
- | { type: "task"; task: StreamTask };
230
+ | { type: "job"; job: { id: string; status: JobStatus; closed_reason?: string | null; node_id: string | null } }
231
+ /** 正在看的这个对话的状态变化(关闭了:closed=true);一次性任务的流里是子任务(BatchStreamTask)。 */
232
+ | { type: "task"; task: StreamTask | BatchStreamTask };
232
233
 
233
234
  /** 实时流看的那个对话(GET /api/jobs/:id/stream?task=)。 */
234
235
  export interface StreamTask { id: string; seq: number; title: string; status: string; closed: boolean; remote_task_id: string | null; continue_from_task_id: string | null }
@@ -240,6 +241,36 @@ export interface StreamSnapshot {
240
241
  turns: CenterTurn[];
241
242
  }
242
243
 
244
+ /**
245
+ * 一次性任务(含续派)的子任务,实时流里的样子。cli-chat 只读查看这类任务时用。
246
+ * prompt / opener_prompt:指定了 ?task= 时看的那个子任务才有(opener_prompt = 开出它所在节点对话的那个子任务的原文)。
247
+ */
248
+ export interface BatchStreamTask {
249
+ id: string;
250
+ seq: number;
251
+ task_key: string | null;
252
+ title: string;
253
+ status: string;
254
+ round: number;
255
+ attempt: number;
256
+ remote_task_id: string | null;
257
+ continue_from_task_id: string | null;
258
+ last_error: string | null;
259
+ prompt?: string;
260
+ opener_prompt?: string;
261
+ }
262
+
263
+ /** 一次性任务(含续派,mode 不是 conversation)的实时流首个事件:没有轮次,只有子任务。 */
264
+ export interface BatchStreamSnapshot {
265
+ job: { id: string; status: JobStatus; node_id: string | null; round: number; terminal_revision: number; mode: string; title: string };
266
+ tasks: BatchStreamTask[];
267
+ /** 指定了 ?task= 时:看的那个子任务。 */
268
+ task?: BatchStreamTask;
269
+ }
270
+
271
+ export const isBatchSnapshot = (snapshot: StreamSnapshot | BatchStreamSnapshot): snapshot is BatchStreamSnapshot =>
272
+ !Array.isArray((snapshot as StreamSnapshot).turns) && Array.isArray((snapshot as BatchStreamSnapshot).tasks);
273
+
243
274
  export interface StreamEnd {
244
275
  /** 对话任务的结束原因;只是这个对话被关闭时为 conversation_closed。 */
245
276
  reason: string;
@@ -53,6 +53,7 @@ export function CliMsgList(props: CliMsgListProps) {
53
53
  }
54
54
 
55
55
  const SEARCH_MIN = 6;
56
+ const BATCH_ACTIVE = new Set(["dispatching", "running", "verifying"]);
56
57
 
57
58
  interface CardView {
58
59
  id: string;
@@ -67,6 +68,8 @@ interface CardView {
67
68
  agent: string | null;
68
69
  turns: number;
69
70
  updatedAt: number;
71
+ /** 一次性任务(含续派,宿主把它的 job_id 放进了列表):只能查看,不能在里面新开对话。 */
72
+ batch: boolean;
70
73
  loading: boolean;
71
74
  error: string | null;
72
75
  }
@@ -75,7 +78,8 @@ export function cardView(id: string, entry: JobEntry | undefined): CardView {
75
78
  const job = entry?.job;
76
79
  const conv = job?.conversation;
77
80
  const updatedAt = toMs(conv?.last_activity_at) ?? 0;
78
- const base = { id, title: job?.title ?? id, desc: entry?.firstInput ?? "", agent: job?.tasks?.[0]?.agent ?? conv?.last_turn?.agent ?? null, turns: conv?.turns ?? 0, updatedAt, loading: !job && !entry?.error, error: entry?.error ?? null };
81
+ const batch = !!job && job.mode !== "conversation";
82
+ const base = { id, title: job?.title ?? id, desc: entry?.firstInput ?? "", agent: job?.tasks?.[0]?.agent ?? conv?.last_turn?.agent ?? null, turns: conv?.turns ?? 0, updatedAt, batch, loading: !job && !entry?.error, error: entry?.error ?? null };
79
83
  const status = job?.status ?? "queued";
80
84
  if (TERMINAL_JOB.has(status)) {
81
85
  const cancelled = status === "cancelled";
@@ -83,6 +87,11 @@ export function cardView(id: string, entry: JobEntry | undefined): CardView {
83
87
  const label = cancelled ? "已取消" : failed ? "已失败" : "已完成";
84
88
  return { ...base, cls: cancelled || failed ? "x" : "g", label, running: false, awaiting: false, group: cancelled ? "cancelled" : "done", activity: updatedAt ? `${label} · ${relTime(updatedAt)}` : label };
85
89
  }
90
+ // 一次性任务:有子任务在节点上跑就是运行中,没有轮次、不会停在提问上。
91
+ if (batch) {
92
+ const running = (job?.tasks ?? []).some((task) => BATCH_ACTIVE.has(task.status));
93
+ return { ...base, cls: "a", label: "进行中", running, awaiting: false, group: "active", activity: running ? "运行中 · 工作中" : status === "queued" ? "等待执行节点" : "进行中" };
94
+ }
86
95
  // 开了几个对话:任何一个在跑就算在跑,任何一个停在提问上就要你处理(关掉的对话不算)。
87
96
  const open = (conv?.conversations ?? []).filter((c) => !c.closed);
88
97
  const many = (conv?.conversations?.length ?? 0) > 1;
@@ -194,7 +203,7 @@ function Group({ label, cards, selected, onSelect, show, rows, firstTask, onNewI
194
203
  // 只有一个对话:整张卡片就是它;有几个对话:卡片是标题,选中落在下面的行上。
195
204
  selected={!convs.length && !newing && sel?.jobId === v.id}
196
205
  onSelect={() => onSelect(convs.length ? convs.find((c) => c.key === selectedKey)?.key ?? convs[0].key : v.id)}
197
- onNewInJob={onNewInJob && v.group !== "done" && v.group !== "cancelled" ? () => onNewInJob(v.id) : undefined}
206
+ onNewInJob={onNewInJob && !v.batch && v.group !== "done" && v.group !== "cancelled" ? () => onNewInJob(v.id) : undefined}
198
207
  />
199
208
  );
200
209
  if (!convs.length && !newing) return card;