@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/src/core/store.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* 单独用某个组件时它会自己建一个仓库;放进同一个 <CliCenterProvider>(或同一个 group 的 Web Component)就共享一个。
|
|
12
12
|
*/
|
|
13
13
|
import { CenterError, createCenterClient, firstLine, type CenterClient, type CenterClientOptions } from "./client.js";
|
|
14
|
-
import { addTurnsIfAbsent, applyItems, applySnapshot, closedCallback, convEnded, convKey, emptyConv, NEW_TASK, parseConvKey, TERMINAL_JOB, TERMINAL_TURN, turnCallback, upsertTurns, type ConvState } from "./conversation.js";
|
|
14
|
+
import { addTurnsIfAbsent, applyItems, applySnapshot, closedCallback, convEnded, convKey, emptyConv, isChatTask, NEW_TASK, parseConvKey, TERMINAL_JOB, TERMINAL_TURN, turnCallback, upsertTurns, type ConvState } from "./conversation.js";
|
|
15
15
|
import { formatAnswersText } from "../ui/format.js";
|
|
16
16
|
import type { AskAnswers, AskQuestion, CenterJob, CenterTurn, ConversationClosedCallback, ConversationTurnCallback, NewConversationOptions } from "./types.js";
|
|
17
17
|
|
|
@@ -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
|
-
|
|
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);
|
|
@@ -429,6 +430,22 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
429
430
|
void refreshJob(newId, true);
|
|
430
431
|
return newId;
|
|
431
432
|
}
|
|
433
|
+
const shown = state.convs[key];
|
|
434
|
+
if (ref.taskId && isChatTask(shown)) {
|
|
435
|
+
// 一次性任务里的对话式子任务:续派一条接着它的对话(中枢接到这个对话最新的那条后面),对话键不变。
|
|
436
|
+
// 续派没有按 business_id 去重,不重发。
|
|
437
|
+
if (!client.continueTask) throw new CenterError("当前后端不支持接着对话式子任务聊", "UNSUPPORTED", 0);
|
|
438
|
+
const { task_id } = await client.continueTask(ref.jobId, ref.taskId, text, { businessId });
|
|
439
|
+
// 先显示成排队中,不用等实时流的下一次轮询。
|
|
440
|
+
patchConv(key, (conv) => conv.batchTask && !conv.batchTask.queued?.some((q) => q.id === task_id)
|
|
441
|
+
? { ...conv, batchTask: { ...conv.batchTask, queued: [...(conv.batchTask.queued ?? []), { id: task_id, prompt: text, created_at: new Date().toISOString() }] } }
|
|
442
|
+
: conv);
|
|
443
|
+
// 大任务结束时旧版中枢会关流:重新连上,接着看这一条。
|
|
444
|
+
const entry = streams.get(key);
|
|
445
|
+
if (entry && entry.refs > 0 && !entry.stop && !entry.timer) { entry.attempt = 0; connect(key); }
|
|
446
|
+
void refreshJob(ref.jobId, true);
|
|
447
|
+
return key;
|
|
448
|
+
}
|
|
432
449
|
const result = await retryOnce(() => client.sendMessage(ref.jobId, text, { businessId, ...(ref.taskId ? { taskId: ref.taskId } : {}) }));
|
|
433
450
|
const jobTarget = result.job_id || ref.jobId;
|
|
434
451
|
// 指定了对话的:接续后跟到新对话任务里对应的那个对话;只有 job_id 的照旧只换 job。
|
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
|
-
|
|
227
|
-
| { type: "
|
|
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
|
|
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,43 @@ 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
|
+
* 对话式子任务(续派时 mode=conversation):指定 ?task= 看它时,它和续派接着它聊的子任务算一个对话——
|
|
263
|
+
* 这条是其中最新的;conversation_remote_task_id 是它们共用的节点对话,queued 是还没派发的消息。旧版中枢没有。
|
|
264
|
+
*/
|
|
265
|
+
conversation?: boolean;
|
|
266
|
+
conversation_remote_task_id?: string | null;
|
|
267
|
+
queued?: Array<{ id: string; prompt: string; created_at?: string }>;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** 一次性任务(含续派,mode 不是 conversation)的实时流首个事件:没有轮次,只有子任务。 */
|
|
271
|
+
export interface BatchStreamSnapshot {
|
|
272
|
+
job: { id: string; status: JobStatus; node_id: string | null; round: number; terminal_revision: number; mode: string; title: string };
|
|
273
|
+
tasks: BatchStreamTask[];
|
|
274
|
+
/** 指定了 ?task= 时:看的那个子任务。 */
|
|
275
|
+
task?: BatchStreamTask;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const isBatchSnapshot = (snapshot: StreamSnapshot | BatchStreamSnapshot): snapshot is BatchStreamSnapshot =>
|
|
279
|
+
!Array.isArray((snapshot as StreamSnapshot).turns) && Array.isArray((snapshot as BatchStreamSnapshot).tasks);
|
|
280
|
+
|
|
243
281
|
export interface StreamEnd {
|
|
244
282
|
/** 对话任务的结束原因;只是这个对话被关闭时为 conversation_closed。 */
|
|
245
283
|
reason: string;
|
package/src/elements.tsx
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* <cli-file-preview job-id="job_x" path="outputs/报告.md"></cli-file-preview> (单独的文件预览 + 下载)
|
|
12
12
|
* <cli-browser job-id="job_x"></cli-browser> (单独的浏览器实时画面)
|
|
13
13
|
*
|
|
14
|
-
* 同一个 group
|
|
14
|
+
* 同一个 group 的元素共享一个仓库,切换时一起变:列表点哪个,对话和看板就显示哪个;不写 group 各自独立。
|
|
15
|
+
* 要一起切换,group 里的元素就不要写 job-id(写了的只看自己那个);从外面打开某个对话,给任意一个元素写
|
|
16
|
+
* selected-id="job_x" 或 "job_x#task_y"(改它就一起切过去),选中变化在 cli-event 的 select 事件里。
|
|
15
17
|
* 属性:base-url、token、line、namespace、group、job-id、task-id(对话任务里的哪个对话,"new" = 新开一个;对话 / 看板)、
|
|
16
18
|
* title、color、theme、context、read-only、
|
|
17
19
|
* tabs(看板页签,逗号分隔:files,changes,browser)、default-tab、file-open(对话:auto / board / modal / link)、
|
|
@@ -45,7 +47,7 @@ function storeFor(el: HTMLElement): CliStore | undefined {
|
|
|
45
47
|
return store;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
const ATTRS = ["base-url", "token", "line", "namespace", "group", "job-id", "task-id", "title", "color", "theme", "context", "read-only", "config", "new-conversation", "tabs", "default-tab", "file-open", "path"];
|
|
50
|
+
const ATTRS = ["base-url", "token", "line", "namespace", "group", "selected-id", "job-id", "task-id", "title", "color", "theme", "context", "read-only", "config", "new-conversation", "tabs", "default-tab", "file-open", "path"];
|
|
49
51
|
|
|
50
52
|
function jsonAttr(el: HTMLElement, name: string): object | undefined {
|
|
51
53
|
const raw = el.getAttribute(name);
|
|
@@ -92,8 +94,15 @@ abstract class CliElement extends HTMLElement {
|
|
|
92
94
|
}
|
|
93
95
|
attributeChangedCallback() { if (this.root) this.render(); }
|
|
94
96
|
|
|
97
|
+
private appliedSelection: string | null = null;
|
|
95
98
|
protected common() {
|
|
96
99
|
const store = storeFor(this) ?? (this.ownStore ??= createCliStore(storeOptions(this)));
|
|
100
|
+
// selected-id:这一组一起看哪个对话。属性变了才切(列表点击换了选中,不会被属性拉回去)。
|
|
101
|
+
const selection = this.getAttribute("selected-id");
|
|
102
|
+
if (selection !== null && selection !== this.appliedSelection) {
|
|
103
|
+
this.appliedSelection = selection;
|
|
104
|
+
store.select(selection || null);
|
|
105
|
+
}
|
|
97
106
|
return {
|
|
98
107
|
store,
|
|
99
108
|
title: this.getAttribute("title") ?? undefined,
|
|
@@ -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;
|
|
@@ -63,10 +64,15 @@ interface CardView {
|
|
|
63
64
|
running: boolean;
|
|
64
65
|
awaiting: boolean;
|
|
65
66
|
group: "needs" | "active" | "done" | "cancelled";
|
|
67
|
+
/** 带状态的活动(打开 card.status 时显示)。 */
|
|
66
68
|
activity: string;
|
|
69
|
+
/** 只有时间(缺省显示这个)。 */
|
|
70
|
+
when: string;
|
|
67
71
|
agent: string | null;
|
|
68
72
|
turns: number;
|
|
69
73
|
updatedAt: number;
|
|
74
|
+
/** 一次性任务(含续派,宿主把它的 job_id 放进了列表):只能查看,不能在里面新开对话。 */
|
|
75
|
+
batch: boolean;
|
|
70
76
|
loading: boolean;
|
|
71
77
|
error: string | null;
|
|
72
78
|
}
|
|
@@ -75,7 +81,8 @@ export function cardView(id: string, entry: JobEntry | undefined): CardView {
|
|
|
75
81
|
const job = entry?.job;
|
|
76
82
|
const conv = job?.conversation;
|
|
77
83
|
const updatedAt = toMs(conv?.last_activity_at) ?? 0;
|
|
78
|
-
const
|
|
84
|
+
const batch = !!job && job.mode !== "conversation";
|
|
85
|
+
const base = { id, title: job?.title ?? id, desc: entry?.firstInput ?? "", when: updatedAt ? relTime(updatedAt) : "", 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
86
|
const status = job?.status ?? "queued";
|
|
80
87
|
if (TERMINAL_JOB.has(status)) {
|
|
81
88
|
const cancelled = status === "cancelled";
|
|
@@ -83,6 +90,11 @@ export function cardView(id: string, entry: JobEntry | undefined): CardView {
|
|
|
83
90
|
const label = cancelled ? "已取消" : failed ? "已失败" : "已完成";
|
|
84
91
|
return { ...base, cls: cancelled || failed ? "x" : "g", label, running: false, awaiting: false, group: cancelled ? "cancelled" : "done", activity: updatedAt ? `${label} · ${relTime(updatedAt)}` : label };
|
|
85
92
|
}
|
|
93
|
+
// 一次性任务:有子任务在节点上跑就是运行中,没有轮次、不会停在提问上。
|
|
94
|
+
if (batch) {
|
|
95
|
+
const running = (job?.tasks ?? []).some((task) => BATCH_ACTIVE.has(task.status));
|
|
96
|
+
return { ...base, cls: "a", label: "进行中", running, awaiting: false, group: "active", activity: running ? "运行中 · 工作中" : status === "queued" ? "等待执行节点" : "进行中" };
|
|
97
|
+
}
|
|
86
98
|
// 开了几个对话:任何一个在跑就算在跑,任何一个停在提问上就要你处理(关掉的对话不算)。
|
|
87
99
|
const open = (conv?.conversations ?? []).filter((c) => !c.closed);
|
|
88
100
|
const many = (conv?.conversations?.length ?? 0) > 1;
|
|
@@ -125,7 +137,8 @@ export function convRows(jobId: string, entry: JobEntry | undefined): ConvRowVie
|
|
|
125
137
|
|
|
126
138
|
function MsgCard({ v, selected, onSelect, show, onNewInJob }: { v: CardView; selected: boolean; onSelect: () => void; show: ResolvedMsgListConfig["card"]; onNewInJob?: () => void }) {
|
|
127
139
|
if (v.loading) return <CardSkeleton i={v.id.length} />;
|
|
128
|
-
const
|
|
140
|
+
const activity = show.status ? v.activity : v.when;
|
|
141
|
+
const foot = (show.activity && !!activity) || (show.turns && v.turns > 0);
|
|
129
142
|
const card = (
|
|
130
143
|
<button className={`task ${selected ? "sel" : ""} ${v.awaiting && show.status ? "awaiting" : ""}`} onClick={onSelect} title={v.error ?? undefined}>
|
|
131
144
|
<div className="task-top">
|
|
@@ -137,7 +150,7 @@ function MsgCard({ v, selected, onSelect, show, onNewInJob }: { v: CardView; sel
|
|
|
137
150
|
{show.description && v.desc && <div className="tdesc">{v.desc}</div>}
|
|
138
151
|
{v.error && <div className="blocked-chip">{Icon.x()} {v.error}</div>}
|
|
139
152
|
{foot && <div className="task-foot">
|
|
140
|
-
{show.activity && <span className="activity">{v.awaiting ? <span style={{ color: "var(--blue)" }}>●</span> : v.running ? <span style={{ color: "var(--amber)" }}>●</span> : null}{
|
|
153
|
+
{show.activity && activity && <span className="activity">{show.status && (v.awaiting ? <span style={{ color: "var(--blue)" }}>●</span> : v.running ? <span style={{ color: "var(--amber)" }}>●</span> : null)}{activity}</span>}
|
|
141
154
|
<span className="spacer" />
|
|
142
155
|
{show.turns && v.turns > 0 && <span className="activity">{v.turns} 条消息</span>}
|
|
143
156
|
</div>}
|
|
@@ -158,7 +171,7 @@ function ConvRow({ v, selected, onSelect, show }: { v: ConvRowView; selected: bo
|
|
|
158
171
|
<button className={`cli-conv-row ${selected ? "sel" : ""} ${v.awaiting && show.status ? "awaiting" : ""}`} onClick={onSelect}>
|
|
159
172
|
{show.status && <StatusDot cls={v.cls} running={v.running} awaiting={v.awaiting} title={v.label} />}
|
|
160
173
|
<span className="cli-conv-title">{v.title}</span>
|
|
161
|
-
{show.activity && <span className="cli-conv-act">{v.activity}</span>}
|
|
174
|
+
{show.activity && show.status && <span className="cli-conv-act">{v.activity}</span>}
|
|
162
175
|
{show.turns && v.turns > 0 && <span className="cli-conv-act">{v.turns} 条</span>}
|
|
163
176
|
</button>
|
|
164
177
|
);
|
|
@@ -178,9 +191,11 @@ type GroupProps = {
|
|
|
178
191
|
label: string | false; cards: CardView[]; selected: string | null; onSelect: (id: string) => void; show: ResolvedMsgListConfig["card"];
|
|
179
192
|
rows: Record<string, ConvRowView[]>; firstTask: Record<string, string | undefined>; onNewInJob?: (jobId: string) => void;
|
|
180
193
|
accent?: boolean; collapsible?: boolean; collapsed?: boolean; onToggle?: () => void;
|
|
194
|
+
/** 不分组:只有卡片,没有组标题。 */
|
|
195
|
+
plain?: boolean;
|
|
181
196
|
};
|
|
182
197
|
|
|
183
|
-
function Group({ label, cards, selected, onSelect, show, rows, firstTask, onNewInJob, accent, collapsible, collapsed, onToggle }: GroupProps) {
|
|
198
|
+
function Group({ label, cards, selected, onSelect, show, rows, firstTask, onNewInJob, accent, collapsible, collapsed, onToggle, plain }: GroupProps) {
|
|
184
199
|
if (!cards.length || label === false) return null;
|
|
185
200
|
const sel = selected ? parseConvKey(selected) : null;
|
|
186
201
|
const list = cards.map((v) => {
|
|
@@ -194,7 +209,7 @@ function Group({ label, cards, selected, onSelect, show, rows, firstTask, onNewI
|
|
|
194
209
|
// 只有一个对话:整张卡片就是它;有几个对话:卡片是标题,选中落在下面的行上。
|
|
195
210
|
selected={!convs.length && !newing && sel?.jobId === v.id}
|
|
196
211
|
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}
|
|
212
|
+
onNewInJob={onNewInJob && !v.batch && v.group !== "done" && v.group !== "cancelled" ? () => onNewInJob(v.id) : undefined}
|
|
198
213
|
/>
|
|
199
214
|
);
|
|
200
215
|
if (!convs.length && !newing) return card;
|
|
@@ -208,6 +223,7 @@ function Group({ label, cards, selected, onSelect, show, rows, firstTask, onNewI
|
|
|
208
223
|
</div>
|
|
209
224
|
);
|
|
210
225
|
});
|
|
226
|
+
if (plain) return <>{list}</>;
|
|
211
227
|
if (collapsible) {
|
|
212
228
|
return (
|
|
213
229
|
<>
|
|
@@ -328,10 +344,13 @@ function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, ti
|
|
|
328
344
|
<>
|
|
329
345
|
{cards.length === 0 && <div className="empty" style={{ padding: "30px 16px" }}><div className="e-t">{cfg.emptyTitle}</div>{cfg.emptyDescription && <div className="e-s">{cfg.emptyDescription}</div>}</div>}
|
|
330
346
|
{q && shown.length === 0 && <div className="search-empty">没有匹配“{query.trim()}”的对话。</div>}
|
|
347
|
+
{!cfg.grouped && <Group label="" cards={[...shown].sort(byRecent)} {...groupProps} plain />}
|
|
348
|
+
{cfg.grouped && <>
|
|
331
349
|
<Group label={cfg.groups.needs} cards={groups.needs} {...groupProps} accent />
|
|
332
350
|
<Group label={cfg.groups.active} cards={groups.active} {...groupProps} />
|
|
333
351
|
<Group label={cfg.groups.done} cards={groups.done} {...groupProps} collapsible collapsed={doneCollapsed && !q} onToggle={() => setDoneCollapsed((c) => !c)} />
|
|
334
352
|
<Group label={cfg.groups.cancelled} cards={groups.cancelled} {...groupProps} collapsible collapsed={cancelledCollapsed && !q} onToggle={() => setCancelledCollapsed((c) => !c)} />
|
|
353
|
+
</>}
|
|
335
354
|
</>
|
|
336
355
|
)}
|
|
337
356
|
</div>
|