@spzxg/cli-ui 0.3.0 → 0.4.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.
- package/README.md +26 -8
- package/dist/{cli-ui-embed-hutDQhkT.js → cli-ui-embed-D7xtcvGG.js} +8991 -8865
- package/dist/{cli-ui-index-C8TOk7M3.js → cli-ui-index-O0krpZBg.js} +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +1 -1
- package/src/chat/CliChat.tsx +40 -16
- package/src/core/client.ts +16 -3
- package/src/core/config.ts +28 -11
- package/src/core/conversation.ts +120 -6
- package/src/core/provider.tsx +27 -6
- package/src/core/store.ts +19 -2
- package/src/core/types.ts +43 -5
- package/src/elements.tsx +11 -2
- package/src/msglist/CliMsgList.tsx +25 -6
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-
|
|
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-D7xtcvGG.js";
|
|
2
2
|
export {
|
|
3
3
|
a as CliBoard,
|
|
4
4
|
i as CliBrowserView,
|
package/package.json
CHANGED
package/src/chat/CliChat.tsx
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
5
5
|
import type { CliEvent, CliStore, CliStoreOptions } from "../core/store.js";
|
|
6
6
|
import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider.js";
|
|
7
|
-
import { convKey, emptyConv, isNewInJob, isRunning, parseConvKey, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation.js";
|
|
7
|
+
import { convKey, emptyConv, isChatTask, isNewInJob, isRunning, parseConvKey, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation.js";
|
|
8
8
|
import type { AskAnswers, AskQuestion, Msg } from "../core/types.js";
|
|
9
9
|
import { Icon } from "../ui/icons.js";
|
|
10
10
|
import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format.js";
|
|
@@ -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,13 +169,21 @@ 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
|
-
|
|
172
|
+
// 一次性任务(含续派,不是对话任务):只读查看其中一个子任务——没有轮次,不能发消息、结束、回答。
|
|
173
|
+
// 例外是对话式子任务(续派时 mode=conversation):当对话用,随时能接着发(= 续派一条接着它),不显示子任务状态。
|
|
174
|
+
const batch = !!conv?.batch || (!!job && job.mode !== "conversation");
|
|
175
|
+
const chat = !!taskId && isChatTask(conv);
|
|
176
|
+
const readOnly = !!readOnlyProp || !cfg.composer.show || (batch && !chat);
|
|
177
|
+
// 这个对话对应的子任务:指定了就是它,否则对话任务的第一个(「新开」时借用第一个的智能体显示);
|
|
178
|
+
// 一次性任务没指定时是实时流挑的那个(最近一个派发过的)。
|
|
179
|
+
const shownTaskId = taskId ?? (batch ? conv?.task?.id : undefined);
|
|
180
|
+
const task = (shownTaskId ? job?.tasks?.find((t) => t.id === shownTaskId) : undefined) ?? (shownTaskId || batch ? undefined : job?.tasks?.[0]);
|
|
162
181
|
const info = taskId ? job?.conversation?.conversations?.find((c) => c.task_id === taskId) : undefined;
|
|
163
182
|
const agent = task?.agent ?? conv?.turns[conv.turns.length - 1]?.agent ?? null;
|
|
164
183
|
const agentText = labelOf(agent);
|
|
165
184
|
const jobStatus = conv?.job?.status ?? job?.status ?? null;
|
|
166
|
-
|
|
185
|
+
// 对话式子任务没有「结束」:大任务结束了,再发一条会重新打开它。
|
|
186
|
+
const jobEnded = !chat && !!jobStatus && TERMINAL_JOB.has(jobStatus);
|
|
167
187
|
// 只关掉了这一个对话(对话任务还开着):按「这个对话已结束」显示,不能再发消息。
|
|
168
188
|
const convClosed = !jobEnded && !!(conv?.task?.closed ?? info?.closed);
|
|
169
189
|
const status = convClosed ? "succeeded" : jobStatus;
|
|
@@ -176,15 +196,18 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
176
196
|
const continuedBy = conv?.continuedBy ?? (taskId ? null : job?.conversation?.continued_by_job_id ?? null);
|
|
177
197
|
const jobTitle = job?.title ?? conv?.job?.title;
|
|
178
198
|
// 对话任务里有几个对话时,标题是「对话任务 / 这个对话」。
|
|
179
|
-
const manyConvs = (job?.conversation?.conversations?.length ?? 0) > 1;
|
|
180
|
-
|
|
199
|
+
const manyConvs = (batch ? job?.tasks?.length ?? 0 : job?.conversation?.conversations?.length ?? 0) > 1;
|
|
200
|
+
// 对话式子任务:流里的是这个对话最新的那条消息,名称取打开的那个子任务。
|
|
201
|
+
const convName = chat ? task?.title ?? conv?.task?.title : conv?.task?.title ?? info?.title ?? task?.title;
|
|
181
202
|
const convTitle = isNew ? `${jobTitle ? `${jobTitle} / ` : ""}新对话`
|
|
182
|
-
: taskId && manyConvs && convName && convName !== jobTitle ? `${jobTitle ?? "对话"} / ${convName}`
|
|
203
|
+
: (taskId || batch) && manyConvs && convName && convName !== jobTitle ? `${jobTitle ?? "对话"} / ${convName}`
|
|
183
204
|
: jobTitle ?? (key ? "对话" : "新对话");
|
|
184
205
|
const awaitingAnswer = useMemo(() => hasOpenAsk(messages), [messages]);
|
|
185
206
|
const lastTurn = conv?.turns[conv.turns.length - 1];
|
|
186
207
|
const awaiting = !ended && !running && !!lastTurn?.asked && lastTurn.status === "succeeded";
|
|
187
|
-
|
|
208
|
+
// 一次性任务的子任务还没派发(没有节点对话)、或者流已经结束还没有转录:不再转圈,转录里有说明。
|
|
209
|
+
const loading = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error" && conv.stream !== "ended" && !(conv.batch && !conv.remoteTaskId);
|
|
210
|
+
const batchStatus = batch ? conv?.batchTask?.status ?? task?.status ?? null : null;
|
|
188
211
|
|
|
189
212
|
// 用量:任务详情里的累计 + 实时流里上次刷新后的增量(「新开」的对话还没有用量,不借第一个对话的)。
|
|
190
213
|
const usageTask = isNew ? undefined : task;
|
|
@@ -194,12 +217,13 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
194
217
|
|
|
195
218
|
const { dotCls, statusText } = useMemo((): { dotCls: DotCls; statusText: string } => {
|
|
196
219
|
if (!key || isNew) return { dotCls: "r", statusText: "未开始" };
|
|
220
|
+
if (batch && !chat) return batchStatusView(batchStatus, jobStatus);
|
|
197
221
|
if (ended) return { dotCls: status === "succeeded" ? "g" : "x", statusText: REASON_TEXT[closedReason ?? ""] ?? (status === "succeeded" ? "已结束" : status === "cancelled" ? "已取消" : "已失败") };
|
|
198
222
|
if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
|
|
199
223
|
if (running) return { dotCls: "a", statusText: "运行中" };
|
|
200
224
|
if (status === "queued") return { dotCls: "a", statusText: "等待执行节点" };
|
|
201
225
|
return { dotCls: "a", statusText: "进行中" };
|
|
202
|
-
}, [key, isNew, ended, status, closedReason, awaiting, running]);
|
|
226
|
+
}, [key, isNew, batch, chat, batchStatus, jobStatus, ended, status, closedReason, awaiting, running]);
|
|
203
227
|
|
|
204
228
|
// ── 处理器(引用稳定,MessageView 的 memo 才有效) ──
|
|
205
229
|
// 输入框之外的操作(回答提问、撤回排队、结束对话、停止)失败时在顶部提示,不静默吞掉。
|
|
@@ -259,7 +283,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
259
283
|
: send(formatAnswersText(questions, answers));
|
|
260
284
|
await report(sent, "回答没有发出去");
|
|
261
285
|
},
|
|
262
|
-
onCancelQueued: readOnly ? undefined : (m: Msg) => { const id = latest.current.key; if (id && m.turnId) void report(store.cancelTurn(id, m.turnId), "撤回失败").catch(() => undefined); },
|
|
286
|
+
onCancelQueued: readOnly || batch ? undefined : (m: Msg) => { const id = latest.current.key; if (id && m.turnId) void report(store.cancelTurn(id, m.turnId), "撤回失败").catch(() => undefined); },
|
|
263
287
|
// 后端能直接处理的浏览器动作(Operator 后端)才给;center 不提供,卡片按 Operator「没有浏览器能力」时的样子显示。
|
|
264
288
|
onInput: readOnly || !store.client.submitInput ? undefined : async (inputId: string, values: string[]) => {
|
|
265
289
|
const id = latest.current.key;
|
|
@@ -287,7 +311,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
287
311
|
const at = list.findIndex((m) => m.id === msgId);
|
|
288
312
|
for (let j = (at === -1 ? list.length : at) - 1; j >= 0; j--) if (list[j].role === "user") { void report(send(list[j].content), "重试没有发出去").catch(() => undefined); return; }
|
|
289
313
|
},
|
|
290
|
-
}), [readOnly, send, store, go, report, fileOpen]);
|
|
314
|
+
}), [readOnly, batch, send, store, go, report, fileOpen]);
|
|
291
315
|
const stop = useCallback(async () => {
|
|
292
316
|
const { key: id, conv: c } = latest.current;
|
|
293
317
|
const turn = c ? runningTurn(c) : null;
|
|
@@ -517,7 +541,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
517
541
|
<StatusDot cls={dotCls} running={running} awaiting={awaiting} title={statusText} />
|
|
518
542
|
<span className="cv">{statusText}</span>
|
|
519
543
|
</span>}
|
|
520
|
-
{h.close && key && !isNew && allowClose && !readOnly && !ended && (
|
|
544
|
+
{h.close && key && !isNew && allowClose && !readOnly && !ended && !batch && (
|
|
521
545
|
<button className="btn btn-line btn-sm" onClick={() => setClosing("confirm")} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
|
|
522
546
|
)}
|
|
523
547
|
</div>
|
|
@@ -541,7 +565,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
541
565
|
{(!key || isNew) && cfg.empty.show && (
|
|
542
566
|
<div className="hero cli-hero">
|
|
543
567
|
<div className="h-ic">{Icon.bolt()}</div>
|
|
544
|
-
<div className="h-status"><StatusDot cls="r" /> 新对话 · 尚未开始</div>
|
|
568
|
+
{cfg.transcript.status && <div className="h-status"><StatusDot cls="r" /> 新对话 · 尚未开始</div>}
|
|
545
569
|
<h2>{cfg.empty.title}</h2>
|
|
546
570
|
{isNew && <p className="h-desc">在「{jobTitle ?? "这个任务"}」里新开一个对话:和任务里的其他对话共用同一个工作区(能看到彼此产出的文件),对话内容各自独立。</p>}
|
|
547
571
|
{cfg.empty.description && <p className="h-desc">{cfg.empty.description}</p>}
|
|
@@ -590,8 +614,8 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
590
614
|
)}
|
|
591
615
|
{cfg.transcript.runningIndicator && running && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
|
|
592
616
|
{!hasBelow && queued.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho={false} handlers={handlers} view={view} />)}
|
|
593
|
-
{ended && (
|
|
594
|
-
<div className="session-label"><span className="ln" />{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}<span className="ln" /></div>
|
|
617
|
+
{ended && cfg.transcript.status && (
|
|
618
|
+
<div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
|
|
595
619
|
)}
|
|
596
620
|
{continuedBy && (
|
|
597
621
|
<div className="load-older"><button className="btn btn-line btn-sm" onClick={() => go(continuedBy)}>转到接续的新对话</button></div>
|
package/src/core/client.ts
CHANGED
|
@@ -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
|
/**
|
|
@@ -80,6 +80,11 @@ export interface ConversationBackend {
|
|
|
80
80
|
createConversation(firstMessage: string, opts?: NewConversationOptions & { businessId?: string }): Promise<{ id: string; job_id: string; status: string; task_ids?: string[]; turn: CenterTurn }>;
|
|
81
81
|
/** taskId:发给对话任务里的哪个对话,不给 = 第一个。 */
|
|
82
82
|
sendMessage(jobId: string, text: string, opts?: { businessId?: string; timeoutSeconds?: number; taskId?: string }): Promise<SendResult>;
|
|
83
|
+
/**
|
|
84
|
+
* 一次性任务里的对话式子任务接着聊:续派一条 mode=conversation、continue_from 这个子任务的子任务(接在同一个 Agent 会话里)。
|
|
85
|
+
* 返回新子任务的 id。
|
|
86
|
+
*/
|
|
87
|
+
continueTask?(jobId: string, taskId: string, text: string, opts?: { title?: string; businessId?: string }): Promise<{ task_id: string }>;
|
|
83
88
|
/** 在对话任务里新开一个对话(同一节点、同一工作区),text 是它的第一条消息。 */
|
|
84
89
|
openConversation?(jobId: string, text: string, opts?: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number }): Promise<SendResult>;
|
|
85
90
|
cancelTurn(jobId: string, turnId: string): Promise<CenterTurn>;
|
|
@@ -197,6 +202,13 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
197
202
|
...(opts.businessId ? { business_id: opts.businessId } : {}),
|
|
198
203
|
...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
|
|
199
204
|
}),
|
|
205
|
+
continueTask: async (jobId: string, taskId: string, text: string, opts: { title?: string; businessId?: string } = {}) => {
|
|
206
|
+
const result = await post<{ task_ids: string[] }>(`/api/jobs`, {
|
|
207
|
+
job_id: jobId, mode: "conversation",
|
|
208
|
+
tasks: [{ title: opts.title || firstLine(text, 60), prompt: text, continue_from: taskId, ...(opts.businessId ? { business_id: opts.businessId } : {}) }],
|
|
209
|
+
});
|
|
210
|
+
return { task_id: result.task_ids[0] };
|
|
211
|
+
},
|
|
200
212
|
/** 同一个 business_id 重发只开一个对话。 */
|
|
201
213
|
openConversation: (jobId: string, text: string, opts: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number } = {}) =>
|
|
202
214
|
post<SendResult>(`/api/jobs/${id(jobId)}/conversations`, {
|
|
@@ -246,7 +258,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
246
258
|
const parser = createSseParser(({ event, data }) => {
|
|
247
259
|
let payload: unknown;
|
|
248
260
|
try { payload = JSON.parse(data); } catch { return; }
|
|
249
|
-
if (event === "snapshot") handlers.onSnapshot?.(payload as StreamSnapshot);
|
|
261
|
+
if (event === "snapshot") handlers.onSnapshot?.(payload as StreamSnapshot | BatchStreamSnapshot);
|
|
250
262
|
else if (event === "chunk") handlers.onChunk?.((payload as { events?: StreamItem[] }).events ?? []);
|
|
251
263
|
else if (event === "end") handlers.onEnd?.(payload as StreamEnd);
|
|
252
264
|
});
|
|
@@ -278,7 +290,8 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
278
290
|
|
|
279
291
|
export interface StreamHandlers {
|
|
280
292
|
onOpen?: () => void;
|
|
281
|
-
|
|
293
|
+
/** 对话任务是 StreamSnapshot;一次性任务(含续派)是 BatchStreamSnapshot(见 isBatchSnapshot)。 */
|
|
294
|
+
onSnapshot?: (snapshot: StreamSnapshot | BatchStreamSnapshot) => void;
|
|
282
295
|
onChunk?: (events: StreamItem[]) => void;
|
|
283
296
|
onEnd?: (end: StreamEnd) => void;
|
|
284
297
|
/** 流断了(error 为 null = 服务端正常关闭)。onEnd 之后也会触发一次。 */
|
package/src/core/config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 组件的界面配置:哪些模块显示、文案怎么写。全部可选,不给就是缺省(默认文案;cli-chat 的顶部栏缺省隐藏,其余全部显示)。
|
|
3
3
|
*
|
|
4
|
-
* <CliChat config={{ header: {
|
|
4
|
+
* <CliChat config={{ header: { title: true, status: true }, composer: { placeholder: "问点什么…", fileUpload: false } }} />
|
|
5
5
|
* <CliMsgList config={{ header: { refresh: false }, card: { turns: false } }} />
|
|
6
6
|
*
|
|
7
7
|
* 开关类的模块写 false 就整块隐藏,写对象就逐项开关;文案里可以用 {title}(对话标题)、{agent}(智能体名)。
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// ── cli-chat ─────────────────────────────────────────
|
|
11
11
|
|
|
12
12
|
export interface CliChatConfig {
|
|
13
|
-
/**
|
|
13
|
+
/** 顶部栏。缺省隐藏;true = 每一项都显示;对象 = 只显示写了 true 的项。 */
|
|
14
14
|
header?: boolean | {
|
|
15
15
|
/** 面包屑:色块 + 名称 / 对话。 */
|
|
16
16
|
crumb?: boolean;
|
|
@@ -66,6 +66,8 @@ export interface CliChatConfig {
|
|
|
66
66
|
tools?: boolean;
|
|
67
67
|
/** 「智能体 · 运行中 0:12」那一行。 */
|
|
68
68
|
runningIndicator?: boolean;
|
|
69
|
+
/** 状态说明行:新对话的「尚未开始」、结束时的「对话已结束」「子任务已完成」等。缺省不显示。 */
|
|
70
|
+
status?: boolean;
|
|
69
71
|
};
|
|
70
72
|
/** 消息头上的称呼。 */
|
|
71
73
|
labels?: { user?: string; agent?: string };
|
|
@@ -79,13 +81,14 @@ export interface ResolvedChatConfig {
|
|
|
79
81
|
show: boolean; placeholder: string; placeholderNew: string; placeholderRunning: string; placeholderDisabled: string | null;
|
|
80
82
|
hints: boolean; charCount: boolean; fileUpload: boolean; fileButtonText: string; stop: boolean; queue: boolean;
|
|
81
83
|
};
|
|
82
|
-
transcript: { nav: boolean; sessionLabel: boolean; timestamps: boolean; initialBadge: boolean; tools: boolean; runningIndicator: boolean };
|
|
84
|
+
transcript: { nav: boolean; sessionLabel: boolean; timestamps: boolean; initialBadge: boolean; tools: boolean; runningIndicator: boolean; status: boolean };
|
|
83
85
|
labels: { user: string; agent: string };
|
|
84
86
|
empty: { show: boolean; title: string; description: string };
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
export const DEFAULT_CHAT_CONFIG: ResolvedChatConfig = {
|
|
88
|
-
|
|
90
|
+
// 顶部栏缺省整栏隐藏(模型、状态、用量这些对使用者是干扰),需要的项在 config.header 里打开。
|
|
91
|
+
header: { show: true, crumb: false, export: false, title: false, agent: false, usage: false, model: false, status: false, close: false },
|
|
89
92
|
composer: {
|
|
90
93
|
show: true,
|
|
91
94
|
placeholder: "在“{title}”中回复 {agent}…(可拖入 TXT / Markdown / Word 文件)",
|
|
@@ -94,14 +97,14 @@ export const DEFAULT_CHAT_CONFIG: ResolvedChatConfig = {
|
|
|
94
97
|
placeholderDisabled: null,
|
|
95
98
|
hints: true, charCount: true, fileUpload: true, fileButtonText: "文件", stop: true, queue: true,
|
|
96
99
|
},
|
|
97
|
-
transcript: { nav: true, sessionLabel: true, timestamps: true, initialBadge: true, tools: true, runningIndicator: true },
|
|
100
|
+
transcript: { nav: true, sessionLabel: true, timestamps: true, initialBadge: true, tools: true, runningIndicator: true, status: false },
|
|
98
101
|
labels: { user: "你", agent: "智能体" },
|
|
99
102
|
empty: { show: true, title: "开始一个新对话", description: "在下面输入第一条消息。可以附上 TXT、Markdown 或 Word 文件,内容会转成文字一起发送。" },
|
|
100
103
|
};
|
|
101
104
|
|
|
102
105
|
export function resolveChatConfig(config: CliChatConfig = {}): ResolvedChatConfig {
|
|
103
106
|
const d = DEFAULT_CHAT_CONFIG;
|
|
104
|
-
const header = toggle(config.header, d.header);
|
|
107
|
+
const header = config.header === true ? allOn(d.header) : toggle(config.header, d.header);
|
|
105
108
|
const composer = toggle(config.composer, d.composer);
|
|
106
109
|
const empty = toggle(config.empty, d.empty);
|
|
107
110
|
// 没有文件上传时,缺省文案里的「可拖入文件」提示也去掉。
|
|
@@ -138,22 +141,26 @@ export interface CliMsgListConfig {
|
|
|
138
141
|
/** 搜索框:true 总是显示,false 不显示,"auto"(缺省)对话数 ≥ 6 时显示。 */
|
|
139
142
|
search?: boolean | "auto";
|
|
140
143
|
searchPlaceholder?: string;
|
|
144
|
+
/**
|
|
145
|
+
* 按状态分组(需要你处理 / 进行中 / 已结束 / 已取消),配合 groups 改组名。缺省不分组:一个列表,最近活动的在前。
|
|
146
|
+
*/
|
|
147
|
+
grouped?: boolean;
|
|
141
148
|
/** 卡片上显示哪些内容。 */
|
|
142
149
|
card?: {
|
|
143
|
-
/**
|
|
150
|
+
/** 状态点、状态文字,以及底部的「运行中 / 等你处理 / 已完成」等字样。缺省不显示(底部只有时间)。 */
|
|
144
151
|
status?: boolean;
|
|
145
152
|
/** 智能体标签。 */
|
|
146
153
|
agent?: boolean;
|
|
147
154
|
/** 第一条消息摘要。 */
|
|
148
155
|
description?: boolean;
|
|
149
|
-
/**
|
|
156
|
+
/** 底部活动(3 分钟前;打开 status 时带上运行中、排队等状态)。 */
|
|
150
157
|
activity?: boolean;
|
|
151
158
|
/** 「N 轮对话」。 */
|
|
152
159
|
turns?: boolean;
|
|
153
160
|
/** 「+」按钮:在这个任务(对话任务)里新开一个对话,共用同一个工作区。 */
|
|
154
161
|
newInJob?: boolean;
|
|
155
162
|
};
|
|
156
|
-
/**
|
|
163
|
+
/** 分组标题(grouped 时);写 false 隐藏这一组。 */
|
|
157
164
|
groups?: { needs?: string | false; active?: string | false; done?: string | false; cancelled?: string | false };
|
|
158
165
|
/** 没有对话时的提示。 */
|
|
159
166
|
emptyTitle?: string;
|
|
@@ -164,6 +171,7 @@ export interface ResolvedMsgListConfig {
|
|
|
164
171
|
header: { show: boolean; title: boolean; newButton: boolean; newButtonText: string; refresh: boolean; context: boolean };
|
|
165
172
|
search: boolean | "auto";
|
|
166
173
|
searchPlaceholder: string;
|
|
174
|
+
grouped: boolean;
|
|
167
175
|
card: { status: boolean; agent: boolean; description: boolean; activity: boolean; turns: boolean; newInJob: boolean };
|
|
168
176
|
groups: { needs: string | false; active: string | false; done: string | false; cancelled: string | false };
|
|
169
177
|
emptyTitle: string;
|
|
@@ -174,7 +182,9 @@ export const DEFAULT_MSGLIST_CONFIG: ResolvedMsgListConfig = {
|
|
|
174
182
|
header: { show: true, title: true, newButton: true, newButtonText: "对话", refresh: true, context: true },
|
|
175
183
|
search: "auto",
|
|
176
184
|
searchPlaceholder: "搜索对话…",
|
|
177
|
-
|
|
185
|
+
grouped: false,
|
|
186
|
+
// 状态缺省不显示(未开始、进行中、已完成……对使用者是干扰),需要时打开 card.status / grouped。
|
|
187
|
+
card: { status: false, agent: true, description: true, activity: true, turns: true, newInJob: true },
|
|
178
188
|
groups: { needs: "需要你处理", active: "进行中", done: "已结束", cancelled: "已取消" },
|
|
179
189
|
emptyTitle: "还没有对话",
|
|
180
190
|
emptyDescription: "点「对话」开始一个新的对话。",
|
|
@@ -186,6 +196,7 @@ export function resolveMsgListConfig(config: CliMsgListConfig = {}): ResolvedMsg
|
|
|
186
196
|
header: toggle(config.header, d.header),
|
|
187
197
|
search: config.search ?? d.search,
|
|
188
198
|
searchPlaceholder: config.searchPlaceholder ?? d.searchPlaceholder,
|
|
199
|
+
grouped: config.grouped ?? d.grouped,
|
|
189
200
|
card: { ...d.card, ...defined(config.card) },
|
|
190
201
|
groups: { ...d.groups, ...defined(config.groups) },
|
|
191
202
|
emptyTitle: config.emptyTitle ?? d.emptyTitle,
|
|
@@ -211,6 +222,12 @@ function defined<T extends object>(value: T | undefined): Partial<T> {
|
|
|
211
222
|
/**
|
|
212
223
|
* 开关型模块:false → 全关(show=false 且布尔项全 false,文案保留);true / 不给 → 缺省;对象 → 合并缺省。
|
|
213
224
|
*/
|
|
225
|
+
function allOn<T extends { show: boolean }>(defaults: T): T {
|
|
226
|
+
const on = { ...defaults } as Record<string, unknown>;
|
|
227
|
+
for (const key of Object.keys(on)) if (typeof on[key] === "boolean") on[key] = true;
|
|
228
|
+
return on as T;
|
|
229
|
+
}
|
|
230
|
+
|
|
214
231
|
function toggle<T extends { show: boolean }>(value: boolean | object | undefined, defaults: T): T {
|
|
215
232
|
if (value === false) {
|
|
216
233
|
const off = { ...defaults, show: false } as Record<string, unknown>;
|
package/src/core/conversation.ts
CHANGED
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
* · 节点转录(transcript + node 事件):Agent 实际的对话过程,规则与 Operator 的 useTaskStream 一致。
|
|
7
7
|
* 界面以节点转录为主;转录还没有(没派发、节点连不上)时用轮次的 input / reply 兜底。
|
|
8
8
|
*/
|
|
9
|
-
import type
|
|
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,76 @@ 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
|
+
// 对话式子任务跑完一条不算结束:还能接着聊。
|
|
134
|
+
id: task.id, seq: task.seq, title: task.title, status: task.status, closed: !task.conversation && TERMINAL_TASK.has(task.status),
|
|
135
|
+
remote_task_id: task.remote_task_id, continue_from_task_id: task.continue_from_task_id,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/** 没指定子任务时看哪个:最近一个派发过的,都没派发过就是最后一个。 */
|
|
139
|
+
export const latestBatchTask = (tasks: BatchStreamTask[]) =>
|
|
140
|
+
[...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);
|
|
141
|
+
|
|
142
|
+
/** 对话式子任务(续派时 mode=conversation):可以接着聊,发消息 = 续派一条接着它的对话。 */
|
|
143
|
+
export const isChatTask = (conv: Pick<ConvState, "batch" | "batchTask"> | null | undefined) => !!conv?.batch && !!conv.batchTask?.conversation;
|
|
144
|
+
|
|
145
|
+
/** 换成看另一个子任务(或同一个子任务换了节点对话,例如重试):节点转录清掉,等新对话的转录。 */
|
|
146
|
+
function focusBatchTask(conv: ConvState, task: BatchStreamTask | null): ConvState {
|
|
147
|
+
// 对话式的对话里最新那条可能还没派发:节点对话用它们共用的那个。
|
|
148
|
+
const remote = task?.conversation_remote_task_id ?? task?.remote_task_id ?? null;
|
|
149
|
+
const sameRemote = remote === conv.remoteTaskId;
|
|
150
|
+
// 状态变化事件不一定带原文(没指定子任务时):沿用已有的。
|
|
151
|
+
const kept = conv.batchTask && task && conv.batchTask.id === task.id ? { prompt: conv.batchTask.prompt, opener_prompt: conv.batchTask.opener_prompt } : {};
|
|
152
|
+
const batchTask = task ? { ...kept, ...task } : null;
|
|
153
|
+
return {
|
|
154
|
+
...conv, batch: true, batchTask, task: batchTask ? batchStreamTask(batchTask) : null, remoteTaskId: remote,
|
|
155
|
+
...(sameRemote ? {} : { msgs: [], hasTranscript: false, nodeRunning: false }),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function applyBatchSnapshot(conv: ConvState, snapshot: BatchStreamSnapshot): ConvState {
|
|
160
|
+
const { job } = snapshot;
|
|
161
|
+
const tasks = Array.isArray(snapshot.tasks) ? snapshot.tasks : [];
|
|
162
|
+
// 流给的那个优先:指定了子任务就是它(对话式的是它所在对话里最新的那条);没指定是最近一个派发过的。旧版中枢不给时自己挑。
|
|
163
|
+
const shown = snapshot.task ?? (conv.taskId ? tasks.find((task) => task.id === conv.taskId) ?? null : latestBatchTask(tasks));
|
|
164
|
+
return focusBatchTask({ ...conv, job: { status: job.status, closed_reason: null, node_id: job.node_id, title: job.title }, turns: [] }, shown);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 一次性任务里的子任务状态变化。 */
|
|
168
|
+
function applyBatchTask(conv: ConvState, task: BatchStreamTask): ConvState {
|
|
169
|
+
if (conv.batchTask?.id === task.id) return focusBatchTask(conv, task);
|
|
170
|
+
// 对话式的对话:接着聊的新消息成了最新那条(流只发这个对话的)。
|
|
171
|
+
if (conv.taskId && task.conversation) return focusBatchTask(conv, task);
|
|
172
|
+
// 没指定子任务:续派开出了更新的子任务(已经派发到节点),跟过去。
|
|
173
|
+
if (!conv.taskId && task.remote_task_id && (!conv.batchTask || task.seq > conv.batchTask.seq)) return focusBatchTask(conv, task);
|
|
174
|
+
return conv;
|
|
175
|
+
}
|
|
176
|
+
|
|
115
177
|
// 缺字段时兜底:generation 缺省 1(按它分会话,缺了会把顺序排乱);时间缺就不显示。
|
|
116
178
|
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
179
|
|
|
118
180
|
export function applyItems(conv: ConvState, items: StreamItem[]): ConvState {
|
|
119
181
|
let next = conv;
|
|
120
182
|
for (const item of items) {
|
|
183
|
+
// 一次性任务:只认看的这个子任务所在的节点对话。
|
|
184
|
+
if (next.batch && (item.type === "transcript" || item.type === "node") && item.remote_task_id && item.remote_task_id !== next.remoteTaskId) continue;
|
|
121
185
|
if (item.type === "transcript") {
|
|
122
186
|
// 节点转录全量(连上 / 重连节点时各一次):整体替换,没变的消息保留对象引用。
|
|
123
187
|
const messages = Array.isArray(item.messages) ? item.messages.map(toMsg) : [];
|
|
@@ -131,9 +195,9 @@ export function applyItems(conv: ConvState, items: StreamItem[]): ConvState {
|
|
|
131
195
|
const settled = TERMINAL_TURN.has(item.turn.status) && !turns.some((turn) => ACTIVE_TURN.has(turn.status));
|
|
132
196
|
next = { ...next, turns, resolvedModel: item.turn.resolved_model ?? next.resolvedModel, ...(settled ? { nodeRunning: false } : {}) };
|
|
133
197
|
} 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 } };
|
|
198
|
+
next = { ...next, job: { ...next.job, status: item.job.status, closed_reason: item.job.closed_reason ?? null, node_id: item.job.node_id } };
|
|
135
199
|
} else if (item.type === "task") {
|
|
136
|
-
next = { ...next, task: item.task };
|
|
200
|
+
next = next.batch ? applyBatchTask(next, item.task as BatchStreamTask) : { ...next, task: item.task as StreamTask };
|
|
137
201
|
}
|
|
138
202
|
}
|
|
139
203
|
return next;
|
|
@@ -227,7 +291,7 @@ export const convEnded = (conv: Pick<ConvState, "job" | "task">) => !!conv.task?
|
|
|
227
291
|
/** 正在执行的那一轮(派发中或执行中)。 */
|
|
228
292
|
export const runningTurn = (conv: ConvState) => conv.turns.find((turn) => turn.status === "running" || turn.status === "dispatching") ?? null;
|
|
229
293
|
|
|
230
|
-
export const isRunning = (conv: ConvState) => conv.nodeRunning || !!runningTurn(conv);
|
|
294
|
+
export const isRunning = (conv: ConvState) => conv.nodeRunning || !!runningTurn(conv) || (conv.batch && ACTIVE_TASK.has(conv.batchTask?.status ?? ""));
|
|
231
295
|
|
|
232
296
|
/**
|
|
233
297
|
* 界面上的转录:
|
|
@@ -236,6 +300,7 @@ export const isRunning = (conv: ConvState) => conv.nodeRunning || !!runningTurn(
|
|
|
236
300
|
* 3. 还在中枢排队的消息,作为「排队中」气泡(可撤回),由界面钉在最下面。
|
|
237
301
|
*/
|
|
238
302
|
export function viewMessages(conv: ConvState): Msg[] {
|
|
303
|
+
if (conv.batch) return batchViewMessages(conv);
|
|
239
304
|
let out: Msg[] = [];
|
|
240
305
|
// 有节点消息就以节点为准:通常先收到全量转录,但新对话可能先来增量事件(user / assistant / tool),也算。
|
|
241
306
|
if (conv.msgs.length) {
|
|
@@ -296,6 +361,55 @@ export function viewMessages(conv: ConvState): Msg[] {
|
|
|
296
361
|
return out;
|
|
297
362
|
}
|
|
298
363
|
|
|
364
|
+
// 中枢派发一次性任务时加在正文前的标记行,和完成后的自动核对(见 center scheduler.ts)。
|
|
365
|
+
const CENTER_MARKER_LINES = /^(?:\[CENTER-(?:ATTEMPT|TERMINAL-REVISION):[^\]\n]*\][ \t]*(?:\r?\n|$))+/;
|
|
366
|
+
const CONFIRM_MARK = "[CENTER-CONFIRM]";
|
|
367
|
+
const BATCH_TASK_ERROR: Record<string, string> = { failed: "子任务失败", cancelled: "子任务已取消" };
|
|
368
|
+
const CHAT_TASK_ERROR: Record<string, string> = { failed: "这一轮没有完成", cancelled: "这条消息已取消" };
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 一次性任务(含续派)的转录,只读:
|
|
372
|
+
* 1. 第一条用户消息是 Operator 固定的启动语句,换成开出这个节点对话的子任务原文(流里给了才换);
|
|
373
|
+
* 2. 去掉中枢加在正文前的标记行([CENTER-ATTEMPT:…]、[CENTER-TERMINAL-REVISION:…]);
|
|
374
|
+
* 3. 中枢的完成核对([CENTER-CONFIRM] 追问 + Agent 回的 TASK_COMPLETE / TASK_INCOMPLETE)合成一行说明;
|
|
375
|
+
* 4. 还没派发、拿不到执行记录、失败 / 取消时补一行说明。
|
|
376
|
+
*/
|
|
377
|
+
function batchViewMessages(conv: ConvState): Msg[] {
|
|
378
|
+
const out: Msg[] = [];
|
|
379
|
+
const task = conv.batchTask;
|
|
380
|
+
const opener = task?.opener_prompt ?? null;
|
|
381
|
+
const firstUserId = conv.msgs.find((m) => m.role === "user")?.id;
|
|
382
|
+
for (let i = 0; i < conv.msgs.length; i++) {
|
|
383
|
+
const m = conv.msgs[i];
|
|
384
|
+
if (m.role === "user" && m.content.includes(CONFIRM_MARK)) {
|
|
385
|
+
const reply = conv.msgs[i + 1]?.role === "assistant" ? conv.msgs[++i] : null;
|
|
386
|
+
const content = !reply ? "中枢正在核对任务是否完成…" : /^\s*TASK_COMPLETE\b/.test(reply.content) ? "✓ 中枢核对:任务已完成" : "⚠ 中枢核对:任务没有完成";
|
|
387
|
+
out.push({ id: `confirm-${m.id}`, role: "system", content, generation: m.generation, ...(m.ts !== undefined ? { ts: m.ts } : {}) });
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (m.role !== "user") { out.push(m); continue; }
|
|
391
|
+
const content = m.id === firstUserId && opener ? opener : m.content.replace(CENTER_MARKER_LINES, "");
|
|
392
|
+
out.push(content === m.content ? m : { ...m, content });
|
|
393
|
+
}
|
|
394
|
+
const generation = out.length ? out[out.length - 1].generation : 1;
|
|
395
|
+
const note = (id: string, content: string) => out.push({ id: `batch-${id}`, role: "system", content, generation });
|
|
396
|
+
if (task?.conversation) {
|
|
397
|
+
// 对话式:像对话一样显示——还没派发的消息是「排队中」气泡,没有「子任务」字样。
|
|
398
|
+
if (CHAT_TASK_ERROR[task.status]) note(`err-${task.id}-${task.attempt}`, `⚠ ${CHAT_TASK_ERROR[task.status]}${task.last_error ? `:${describeTurnError(task.last_error)}` : ""}`);
|
|
399
|
+
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() } : {}) });
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
if (!task) {
|
|
403
|
+
if (conv.job) note("none", "这个任务里还没有子任务");
|
|
404
|
+
} else if (!conv.msgs.length && !task.remote_task_id && !TERMINAL_TASK.has(task.status)) {
|
|
405
|
+
note("waiting", "子任务还没派发到执行节点,派发后这里会实时显示执行过程");
|
|
406
|
+
} else if (!conv.msgs.length && conv.stream === "ended") {
|
|
407
|
+
note("empty", "没有拿到这个子任务的执行记录(执行节点上的会话可能已经清理)");
|
|
408
|
+
}
|
|
409
|
+
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)}` : ""}`);
|
|
410
|
+
return out;
|
|
411
|
+
}
|
|
412
|
+
|
|
299
413
|
/** 把说明行按时间插进转录:放在时间不晚于它的最后一条消息之后(没有时间的消息沿用前一条的时间)。 */
|
|
300
414
|
function insertByTime(msgs: Msg[], notices: Msg[]): Msg[] {
|
|
301
415
|
if (!notices.length) return msgs;
|
package/src/core/provider.tsx
CHANGED
|
@@ -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
|
|
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>({});
|