@spzxg/cli-ui 0.3.1 → 0.6.0

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.
@@ -7,7 +7,7 @@
7
7
  * 界面以节点转录为主;转录还没有(没派发、节点连不上)时用轮次的 input / reply 兜底。
8
8
  */
9
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
- import { indexFromEnd, mergeSnapshot, toolDataOf } from "../ui/transcriptModel.js";
10
+ import { indexFromEnd, mergeSnapshot, prependOlder, 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"]);
@@ -68,6 +68,8 @@ export interface ConvState {
68
68
  /** 节点转录(Operator 消息)。 */
69
69
  msgs: Msg[];
70
70
  hasTranscript: boolean;
71
+ /** 节点转录里比 msgs 更早、还没取的条数(实时流只给最新一段;往上翻时 store.loadOlder 分页取)。 */
72
+ older: number;
71
73
  nodeRunning: boolean;
72
74
  resolvedModel: string | null;
73
75
  /** 上次刷新任务详情之后,实时流里累计的用量(usage 事件)。 */
@@ -89,7 +91,7 @@ export interface ConvState {
89
91
  }
90
92
 
91
93
  export const emptyConv = (key: string): ConvState => ({
92
- key, ...parseConvKey(key), job: null, task: null, turns: [], msgs: [], hasTranscript: false, nodeRunning: false, resolvedModel: null,
94
+ key, ...parseConvKey(key), job: null, task: null, turns: [], msgs: [], hasTranscript: false, older: 0, nodeRunning: false, resolvedModel: null,
93
95
  liveUsage: { fresh: 0, cacheRead: 0 }, stream: "idle", streamError: null, endReason: null, continuedBy: null, localAnswers: {},
94
96
  batch: false, batchTask: null, remoteTaskId: null,
95
97
  });
@@ -130,7 +132,8 @@ export function applySnapshot(conv: ConvState, snapshot: StreamSnapshot | BatchS
130
132
  // 流里可能同时有几个节点对话的事件(带 remote_task_id),只认看的这个子任务所在的那个。
131
133
 
132
134
  const batchStreamTask = (task: BatchStreamTask): StreamTask => ({
133
- id: task.id, seq: task.seq, title: task.title, status: task.status, closed: TERMINAL_TASK.has(task.status),
135
+ // 对话式子任务跑完一条不算结束:还能接着聊。
136
+ id: task.id, seq: task.seq, title: task.title, status: task.status, closed: !task.conversation && TERMINAL_TASK.has(task.status),
134
137
  remote_task_id: task.remote_task_id, continue_from_task_id: task.continue_from_task_id,
135
138
  });
136
139
 
@@ -138,38 +141,43 @@ const batchStreamTask = (task: BatchStreamTask): StreamTask => ({
138
141
  export const latestBatchTask = (tasks: BatchStreamTask[]) =>
139
142
  [...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
143
 
144
+ /** 对话式子任务(续派时 mode=conversation):可以接着聊,发消息 = 续派一条接着它的对话。 */
145
+ export const isChatTask = (conv: Pick<ConvState, "batch" | "batchTask"> | null | undefined) => !!conv?.batch && !!conv.batchTask?.conversation;
146
+
141
147
  /** 换成看另一个子任务(或同一个子任务换了节点对话,例如重试):节点转录清掉,等新对话的转录。 */
142
148
  function focusBatchTask(conv: ConvState, task: BatchStreamTask | null): ConvState {
143
- const remote = task?.remote_task_id ?? null;
149
+ // 对话式的对话里最新那条可能还没派发:节点对话用它们共用的那个。
150
+ const remote = task?.conversation_remote_task_id ?? task?.remote_task_id ?? null;
144
151
  const sameRemote = remote === conv.remoteTaskId;
145
152
  // 状态变化事件不一定带原文(没指定子任务时):沿用已有的。
146
153
  const kept = conv.batchTask && task && conv.batchTask.id === task.id ? { prompt: conv.batchTask.prompt, opener_prompt: conv.batchTask.opener_prompt } : {};
147
154
  const batchTask = task ? { ...kept, ...task } : null;
148
155
  return {
149
156
  ...conv, batch: true, batchTask, task: batchTask ? batchStreamTask(batchTask) : null, remoteTaskId: remote,
150
- ...(sameRemote ? {} : { msgs: [], hasTranscript: false, nodeRunning: false }),
157
+ ...(sameRemote ? {} : { msgs: [], hasTranscript: false, older: 0, nodeRunning: false }),
151
158
  };
152
159
  }
153
160
 
154
161
  function applyBatchSnapshot(conv: ConvState, snapshot: BatchStreamSnapshot): ConvState {
155
162
  const { job } = snapshot;
156
163
  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;
164
+ // 流给的那个优先:指定了子任务就是它(对话式的是它所在对话里最新的那条);没指定是最近一个派发过的。旧版中枢不给时自己挑。
165
+ const shown = snapshot.task ?? (conv.taskId ? tasks.find((task) => task.id === conv.taskId) ?? null : latestBatchTask(tasks));
160
166
  return focusBatchTask({ ...conv, job: { status: job.status, closed_reason: null, node_id: job.node_id, title: job.title }, turns: [] }, shown);
161
167
  }
162
168
 
163
169
  /** 一次性任务里的子任务状态变化。 */
164
170
  function applyBatchTask(conv: ConvState, task: BatchStreamTask): ConvState {
165
171
  if (conv.batchTask?.id === task.id) return focusBatchTask(conv, task);
172
+ // 对话式的对话:接着聊的新消息成了最新那条(流只发这个对话的)。
173
+ if (conv.taskId && task.conversation) return focusBatchTask(conv, task);
166
174
  // 没指定子任务:续派开出了更新的子任务(已经派发到节点),跟过去。
167
175
  if (!conv.taskId && task.remote_task_id && (!conv.batchTask || task.seq > conv.batchTask.seq)) return focusBatchTask(conv, task);
168
176
  return conv;
169
177
  }
170
178
 
171
179
  // 缺字段时兜底:generation 缺省 1(按它分会话,缺了会把顺序排乱);时间缺就不显示。
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 } : {}) });
180
+ export 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 } : {}) });
173
181
 
174
182
  export function applyItems(conv: ConvState, items: StreamItem[]): ConvState {
175
183
  let next = conv;
@@ -179,7 +187,8 @@ export function applyItems(conv: ConvState, items: StreamItem[]): ConvState {
179
187
  if (item.type === "transcript") {
180
188
  // 节点转录全量(连上 / 重连节点时各一次):整体替换,没变的消息保留对象引用。
181
189
  const messages = Array.isArray(item.messages) ? item.messages.map(toMsg) : [];
182
- next = { ...next, msgs: mergeSnapshot(next.msgs, messages), hasTranscript: true, nodeRunning: item.running === true };
190
+ const merged = mergeSnapshot(next.msgs, messages, typeof item.older === "number" ? item.older : 0);
191
+ next = { ...next, msgs: merged.msgs, older: merged.older, hasTranscript: true, nodeRunning: item.running === true };
183
192
  } else if (item.type === "node") {
184
193
  next = applyNodeEvent(next, item.event);
185
194
  } else if (item.type === "turn") {
@@ -279,6 +288,11 @@ export function applyNodeEvent(conv: ConvState, ev: NodeEvent): ConvState {
279
288
  }
280
289
  }
281
290
 
291
+ /** 往上翻取到的一页更早的节点消息(旧的在前)。older:再往前还剩几条。 */
292
+ export function applyOlderPage(conv: ConvState, page: Message[], older: number): ConvState {
293
+ return { ...conv, msgs: prependOlder(conv.msgs, page.map(toMsg)), older: Math.max(0, older) };
294
+ }
295
+
282
296
  /** 这个对话结束了:整个对话任务结束,或者只是这一个对话被关闭。 */
283
297
  export const convEnded = (conv: Pick<ConvState, "job" | "task">) => !!conv.task?.closed || (!!conv.job && TERMINAL_JOB.has(conv.job.status));
284
298
 
@@ -306,7 +320,8 @@ export function viewMessages(conv: ConvState): Msg[] {
306
320
  // 转录里的第一条用户消息、内容又不是开出会话那一轮的原文,就是它;措辞以后变了也认得出。
307
321
  // 之后的消息里再出现这句固定语句(例如会话被重启),按原文匹配。
308
322
  const openerText = opener ? opener.sent_input ?? opener.input : null;
309
- let replaced = false;
323
+ // 更早的消息还没取(older > 0)时,手里的第一条用户消息不是会话的开场:不按位置认。
324
+ let replaced = conv.older > 0;
310
325
  for (const m of conv.msgs) {
311
326
  const firstUser = !replaced && m.role === "user";
312
327
  if (firstUser) replaced = true;
@@ -359,6 +374,7 @@ export function viewMessages(conv: ConvState): Msg[] {
359
374
  const CENTER_MARKER_LINES = /^(?:\[CENTER-(?:ATTEMPT|TERMINAL-REVISION):[^\]\n]*\][ \t]*(?:\r?\n|$))+/;
360
375
  const CONFIRM_MARK = "[CENTER-CONFIRM]";
361
376
  const BATCH_TASK_ERROR: Record<string, string> = { failed: "子任务失败", cancelled: "子任务已取消" };
377
+ const CHAT_TASK_ERROR: Record<string, string> = { failed: "这一轮没有完成", cancelled: "这条消息已取消" };
362
378
 
363
379
  /**
364
380
  * 一次性任务(含续派)的转录,只读:
@@ -371,7 +387,8 @@ function batchViewMessages(conv: ConvState): Msg[] {
371
387
  const out: Msg[] = [];
372
388
  const task = conv.batchTask;
373
389
  const opener = task?.opener_prompt ?? null;
374
- const firstUserId = conv.msgs.find((m) => m.role === "user")?.id;
390
+ // 更早的消息还没取时,手里的第一条用户消息不是开场语句。
391
+ const firstUserId = conv.older > 0 ? undefined : conv.msgs.find((m) => m.role === "user")?.id;
375
392
  for (let i = 0; i < conv.msgs.length; i++) {
376
393
  const m = conv.msgs[i];
377
394
  if (m.role === "user" && m.content.includes(CONFIRM_MARK)) {
@@ -386,6 +403,12 @@ function batchViewMessages(conv: ConvState): Msg[] {
386
403
  }
387
404
  const generation = out.length ? out[out.length - 1].generation : 1;
388
405
  const note = (id: string, content: string) => out.push({ id: `batch-${id}`, role: "system", content, generation });
406
+ if (task?.conversation) {
407
+ // 对话式:像对话一样显示——还没派发的消息是「排队中」气泡,没有「子任务」字样。
408
+ if (CHAT_TASK_ERROR[task.status]) note(`err-${task.id}-${task.attempt}`, `⚠ ${CHAT_TASK_ERROR[task.status]}${task.last_error ? `:${describeTurnError(task.last_error)}` : ""}`);
409
+ for (const q of task.queued ?? []) out.push({ id: `batch-q-${q.id}`, role: "queued", content: q.prompt, generation, ...(q.created_at ? { ts: new Date(q.created_at).getTime() } : {}) });
410
+ return out;
411
+ }
389
412
  if (!task) {
390
413
  if (conv.job) note("none", "这个任务里还没有子任务");
391
414
  } else if (!conv.msgs.length && !task.remote_task_id && !TERMINAL_TASK.has(task.status)) {
@@ -1,16 +1,20 @@
1
- import { createContext, useContext, useEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
1
+ import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
2
2
  import { createCliStore, type CliEvent, type CliState, type CliStore, type CliStoreOptions } from "./store.js";
3
3
  import type { ConversationCallback, ConversationEndPayload, TurnEndPayload } from "./types.js";
4
4
 
5
5
  /**
6
- * 积木的拼接点。放在同一个 <CliCenterProvider> 里的组件共享一个仓库:
7
- * 列表点哪个对话,对话组件就显示哪个;同一个对话只连一条实时流。
6
+ * 积木的拼接点。放在同一个 <CliCenterProvider> 里的组件共享一个仓库,切换时一起变:
7
+ * 列表点哪个对话,对话和看板就显示哪个;对话里新建 / 接续出新对话,列表跟着选中、看板跟着切;
8
+ * 对话里点文件在看板里打开。同一个对话只连一条实时流。
8
9
  *
9
- * <CliCenterProvider baseUrl="/api/center">
10
+ * <CliCenterProvider baseUrl="/api/center" selectedId={key} onEvent={(e) => e.type === "select" && setKey(e.jobId)}>
10
11
  * <CliMsgList />
11
12
  * <CliChat />
13
+ * <CliBoard />
12
14
  * </CliCenterProvider>
13
15
  *
16
+ * 要一起切换,里面的组件就不要再传 jobId(传了的组件只看自己那个,不跟着切);从外面打开某个对话用 selectedId。
17
+ *
14
18
  * 组件单独使用时不需要它:组件发现外面没有 Provider,会用自己的 props 建一个。
15
19
  */
16
20
  const StoreContext = createContext<CliStore | null>(null);
@@ -18,6 +22,11 @@ const StoreContext = createContext<CliStore | null>(null);
18
22
  export interface CliCenterProviderProps extends CliStoreOptions, ResultHandlers {
19
23
  /** 外部建好的仓库(例如 Web Component 按 group 共享的);给了就不再自己建。 */
20
24
  store?: CliStore;
25
+ /**
26
+ * 里面这几个组件一起看哪个对话(对话键:"job_x" 或 "job_x#task_y";null = 新对话)。不给就由列表的点击决定。
27
+ * 给了之后改它,列表、对话、看板一起切过去;列表点击、对话里新建出的对话照样会切,并发 select / job_change 事件。
28
+ */
29
+ selectedId?: string | null;
21
30
  /** 监听仓库事件:选中、一轮结束、对话结束、job 变化、错误。 */
22
31
  onEvent?: (event: CliEvent) => void;
23
32
  children: ReactNode;
@@ -41,10 +50,22 @@ export function dispatchResult(event: CliEvent, handlers: ResultHandlers) {
41
50
  else if (event.type === "conversation_closed") { handlers.onConversationEnd?.(event.payload); handlers.onCallback?.(event.payload); }
42
51
  }
43
52
 
44
- export function CliCenterProvider({ store: external, onEvent, onTurnEnd, onConversationEnd, onCallback, children, ...options }: CliCenterProviderProps) {
53
+ export function CliCenterProvider({ store: external, selectedId, onEvent, onTurnEnd, onConversationEnd, onCallback, children, ...options }: CliCenterProviderProps) {
45
54
  // 连接参数只在建仓库时用一次:中途换 baseUrl / token 请换 key 重建。
46
55
  const optionsRef = useRef(options);
47
- const store = useMemo(() => external ?? createCliStore(optionsRef.current), [external]);
56
+ const applied = useRef<string | null | undefined>(undefined);
57
+ const store = useMemo(() => {
58
+ const created = external ?? createCliStore(optionsRef.current);
59
+ // 第一次渲染前就选好:里面的组件一上来就看这个对话,不会先打开别的。
60
+ if (selectedId !== undefined) { created.select(selectedId); applied.current = selectedId; }
61
+ return created;
62
+ }, [external]); // eslint-disable-line react-hooks/exhaustive-deps
63
+ // 之后宿主改了 selectedId:一起切过去(在绘制前,免得闪一下旧的)。
64
+ useLayoutEffect(() => {
65
+ if (selectedId === undefined || selectedId === applied.current) return;
66
+ applied.current = selectedId;
67
+ store.select(selectedId);
68
+ }, [store, selectedId]);
48
69
  const onEventRef = useRef(onEvent);
49
70
  onEventRef.current = onEvent;
50
71
  const resultRef = useRef<ResultHandlers>({});