@spzxg/cli-ui 0.3.1 → 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 +23 -8
- package/dist/{cli-ui-embed-C0F64Ln7.js → cli-ui-embed-D7xtcvGG.js} +8375 -8330
- package/dist/{cli-ui-index--RVPCGIc.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 +15 -11
- package/src/core/client.ts +12 -0
- package/src/core/config.ts +28 -11
- package/src/core/conversation.ts +18 -5
- package/src/core/provider.tsx +27 -6
- package/src/core/store.ts +17 -1
- package/src/core/types.ts +7 -0
- package/src/elements.tsx +11 -2
- package/src/msglist/CliMsgList.tsx +15 -5
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";
|
|
@@ -170,8 +170,10 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
170
170
|
const running = conv ? isRunning(conv) : false;
|
|
171
171
|
const job = entry?.job;
|
|
172
172
|
// 一次性任务(含续派,不是对话任务):只读查看其中一个子任务——没有轮次,不能发消息、结束、回答。
|
|
173
|
+
// 例外是对话式子任务(续派时 mode=conversation):当对话用,随时能接着发(= 续派一条接着它),不显示子任务状态。
|
|
173
174
|
const batch = !!conv?.batch || (!!job && job.mode !== "conversation");
|
|
174
|
-
const
|
|
175
|
+
const chat = !!taskId && isChatTask(conv);
|
|
176
|
+
const readOnly = !!readOnlyProp || !cfg.composer.show || (batch && !chat);
|
|
175
177
|
// 这个对话对应的子任务:指定了就是它,否则对话任务的第一个(「新开」时借用第一个的智能体显示);
|
|
176
178
|
// 一次性任务没指定时是实时流挑的那个(最近一个派发过的)。
|
|
177
179
|
const shownTaskId = taskId ?? (batch ? conv?.task?.id : undefined);
|
|
@@ -180,7 +182,8 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
180
182
|
const agent = task?.agent ?? conv?.turns[conv.turns.length - 1]?.agent ?? null;
|
|
181
183
|
const agentText = labelOf(agent);
|
|
182
184
|
const jobStatus = conv?.job?.status ?? job?.status ?? null;
|
|
183
|
-
|
|
185
|
+
// 对话式子任务没有「结束」:大任务结束了,再发一条会重新打开它。
|
|
186
|
+
const jobEnded = !chat && !!jobStatus && TERMINAL_JOB.has(jobStatus);
|
|
184
187
|
// 只关掉了这一个对话(对话任务还开着):按「这个对话已结束」显示,不能再发消息。
|
|
185
188
|
const convClosed = !jobEnded && !!(conv?.task?.closed ?? info?.closed);
|
|
186
189
|
const status = convClosed ? "succeeded" : jobStatus;
|
|
@@ -194,7 +197,8 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
194
197
|
const jobTitle = job?.title ?? conv?.job?.title;
|
|
195
198
|
// 对话任务里有几个对话时,标题是「对话任务 / 这个对话」。
|
|
196
199
|
const manyConvs = (batch ? job?.tasks?.length ?? 0 : job?.conversation?.conversations?.length ?? 0) > 1;
|
|
197
|
-
|
|
200
|
+
// 对话式子任务:流里的是这个对话最新的那条消息,名称取打开的那个子任务。
|
|
201
|
+
const convName = chat ? task?.title ?? conv?.task?.title : conv?.task?.title ?? info?.title ?? task?.title;
|
|
198
202
|
const convTitle = isNew ? `${jobTitle ? `${jobTitle} / ` : ""}新对话`
|
|
199
203
|
: (taskId || batch) && manyConvs && convName && convName !== jobTitle ? `${jobTitle ?? "对话"} / ${convName}`
|
|
200
204
|
: jobTitle ?? (key ? "对话" : "新对话");
|
|
@@ -213,13 +217,13 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
213
217
|
|
|
214
218
|
const { dotCls, statusText } = useMemo((): { dotCls: DotCls; statusText: string } => {
|
|
215
219
|
if (!key || isNew) return { dotCls: "r", statusText: "未开始" };
|
|
216
|
-
if (batch) return batchStatusView(batchStatus, jobStatus);
|
|
220
|
+
if (batch && !chat) return batchStatusView(batchStatus, jobStatus);
|
|
217
221
|
if (ended) return { dotCls: status === "succeeded" ? "g" : "x", statusText: REASON_TEXT[closedReason ?? ""] ?? (status === "succeeded" ? "已结束" : status === "cancelled" ? "已取消" : "已失败") };
|
|
218
222
|
if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
|
|
219
223
|
if (running) return { dotCls: "a", statusText: "运行中" };
|
|
220
224
|
if (status === "queued") return { dotCls: "a", statusText: "等待执行节点" };
|
|
221
225
|
return { dotCls: "a", statusText: "进行中" };
|
|
222
|
-
}, [key, isNew, batch, batchStatus, jobStatus, ended, status, closedReason, awaiting, running]);
|
|
226
|
+
}, [key, isNew, batch, chat, batchStatus, jobStatus, ended, status, closedReason, awaiting, running]);
|
|
223
227
|
|
|
224
228
|
// ── 处理器(引用稳定,MessageView 的 memo 才有效) ──
|
|
225
229
|
// 输入框之外的操作(回答提问、撤回排队、结束对话、停止)失败时在顶部提示,不静默吞掉。
|
|
@@ -279,7 +283,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
279
283
|
: send(formatAnswersText(questions, answers));
|
|
280
284
|
await report(sent, "回答没有发出去");
|
|
281
285
|
},
|
|
282
|
-
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); },
|
|
283
287
|
// 后端能直接处理的浏览器动作(Operator 后端)才给;center 不提供,卡片按 Operator「没有浏览器能力」时的样子显示。
|
|
284
288
|
onInput: readOnly || !store.client.submitInput ? undefined : async (inputId: string, values: string[]) => {
|
|
285
289
|
const id = latest.current.key;
|
|
@@ -307,7 +311,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
307
311
|
const at = list.findIndex((m) => m.id === msgId);
|
|
308
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; }
|
|
309
313
|
},
|
|
310
|
-
}), [readOnly, send, store, go, report, fileOpen]);
|
|
314
|
+
}), [readOnly, batch, send, store, go, report, fileOpen]);
|
|
311
315
|
const stop = useCallback(async () => {
|
|
312
316
|
const { key: id, conv: c } = latest.current;
|
|
313
317
|
const turn = c ? runningTurn(c) : null;
|
|
@@ -537,7 +541,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
537
541
|
<StatusDot cls={dotCls} running={running} awaiting={awaiting} title={statusText} />
|
|
538
542
|
<span className="cv">{statusText}</span>
|
|
539
543
|
</span>}
|
|
540
|
-
{h.close && key && !isNew && allowClose && !readOnly && !ended && (
|
|
544
|
+
{h.close && key && !isNew && allowClose && !readOnly && !ended && !batch && (
|
|
541
545
|
<button className="btn btn-line btn-sm" onClick={() => setClosing("confirm")} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
|
|
542
546
|
)}
|
|
543
547
|
</div>
|
|
@@ -561,7 +565,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
561
565
|
{(!key || isNew) && cfg.empty.show && (
|
|
562
566
|
<div className="hero cli-hero">
|
|
563
567
|
<div className="h-ic">{Icon.bolt()}</div>
|
|
564
|
-
<div className="h-status"><StatusDot cls="r" /> 新对话 · 尚未开始</div>
|
|
568
|
+
{cfg.transcript.status && <div className="h-status"><StatusDot cls="r" /> 新对话 · 尚未开始</div>}
|
|
565
569
|
<h2>{cfg.empty.title}</h2>
|
|
566
570
|
{isNew && <p className="h-desc">在「{jobTitle ?? "这个任务"}」里新开一个对话:和任务里的其他对话共用同一个工作区(能看到彼此产出的文件),对话内容各自独立。</p>}
|
|
567
571
|
{cfg.empty.description && <p className="h-desc">{cfg.empty.description}</p>}
|
|
@@ -610,7 +614,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
610
614
|
)}
|
|
611
615
|
{cfg.transcript.runningIndicator && running && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
|
|
612
616
|
{!hasBelow && queued.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho={false} handlers={handlers} view={view} />)}
|
|
613
|
-
{ended && (
|
|
617
|
+
{ended && cfg.transcript.status && (
|
|
614
618
|
<div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
|
|
615
619
|
)}
|
|
616
620
|
{continuedBy && (
|
package/src/core/client.ts
CHANGED
|
@@ -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`, {
|
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
|
@@ -130,7 +130,8 @@ export function applySnapshot(conv: ConvState, snapshot: StreamSnapshot | BatchS
|
|
|
130
130
|
// 流里可能同时有几个节点对话的事件(带 remote_task_id),只认看的这个子任务所在的那个。
|
|
131
131
|
|
|
132
132
|
const batchStreamTask = (task: BatchStreamTask): StreamTask => ({
|
|
133
|
-
|
|
133
|
+
// 对话式子任务跑完一条不算结束:还能接着聊。
|
|
134
|
+
id: task.id, seq: task.seq, title: task.title, status: task.status, closed: !task.conversation && TERMINAL_TASK.has(task.status),
|
|
134
135
|
remote_task_id: task.remote_task_id, continue_from_task_id: task.continue_from_task_id,
|
|
135
136
|
});
|
|
136
137
|
|
|
@@ -138,9 +139,13 @@ const batchStreamTask = (task: BatchStreamTask): StreamTask => ({
|
|
|
138
139
|
export const latestBatchTask = (tasks: BatchStreamTask[]) =>
|
|
139
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);
|
|
140
141
|
|
|
142
|
+
/** 对话式子任务(续派时 mode=conversation):可以接着聊,发消息 = 续派一条接着它的对话。 */
|
|
143
|
+
export const isChatTask = (conv: Pick<ConvState, "batch" | "batchTask"> | null | undefined) => !!conv?.batch && !!conv.batchTask?.conversation;
|
|
144
|
+
|
|
141
145
|
/** 换成看另一个子任务(或同一个子任务换了节点对话,例如重试):节点转录清掉,等新对话的转录。 */
|
|
142
146
|
function focusBatchTask(conv: ConvState, task: BatchStreamTask | null): ConvState {
|
|
143
|
-
|
|
147
|
+
// 对话式的对话里最新那条可能还没派发:节点对话用它们共用的那个。
|
|
148
|
+
const remote = task?.conversation_remote_task_id ?? task?.remote_task_id ?? null;
|
|
144
149
|
const sameRemote = remote === conv.remoteTaskId;
|
|
145
150
|
// 状态变化事件不一定带原文(没指定子任务时):沿用已有的。
|
|
146
151
|
const kept = conv.batchTask && task && conv.batchTask.id === task.id ? { prompt: conv.batchTask.prompt, opener_prompt: conv.batchTask.opener_prompt } : {};
|
|
@@ -154,15 +159,16 @@ function focusBatchTask(conv: ConvState, task: BatchStreamTask | null): ConvStat
|
|
|
154
159
|
function applyBatchSnapshot(conv: ConvState, snapshot: BatchStreamSnapshot): ConvState {
|
|
155
160
|
const { job } = snapshot;
|
|
156
161
|
const tasks = Array.isArray(snapshot.tasks) ? snapshot.tasks : [];
|
|
157
|
-
//
|
|
158
|
-
const
|
|
159
|
-
const shown = wanted && snapshot.task?.id === wanted.id ? { ...wanted, ...snapshot.task } : wanted;
|
|
162
|
+
// 流给的那个优先:指定了子任务就是它(对话式的是它所在对话里最新的那条);没指定是最近一个派发过的。旧版中枢不给时自己挑。
|
|
163
|
+
const shown = snapshot.task ?? (conv.taskId ? tasks.find((task) => task.id === conv.taskId) ?? null : latestBatchTask(tasks));
|
|
160
164
|
return focusBatchTask({ ...conv, job: { status: job.status, closed_reason: null, node_id: job.node_id, title: job.title }, turns: [] }, shown);
|
|
161
165
|
}
|
|
162
166
|
|
|
163
167
|
/** 一次性任务里的子任务状态变化。 */
|
|
164
168
|
function applyBatchTask(conv: ConvState, task: BatchStreamTask): ConvState {
|
|
165
169
|
if (conv.batchTask?.id === task.id) return focusBatchTask(conv, task);
|
|
170
|
+
// 对话式的对话:接着聊的新消息成了最新那条(流只发这个对话的)。
|
|
171
|
+
if (conv.taskId && task.conversation) return focusBatchTask(conv, task);
|
|
166
172
|
// 没指定子任务:续派开出了更新的子任务(已经派发到节点),跟过去。
|
|
167
173
|
if (!conv.taskId && task.remote_task_id && (!conv.batchTask || task.seq > conv.batchTask.seq)) return focusBatchTask(conv, task);
|
|
168
174
|
return conv;
|
|
@@ -359,6 +365,7 @@ export function viewMessages(conv: ConvState): Msg[] {
|
|
|
359
365
|
const CENTER_MARKER_LINES = /^(?:\[CENTER-(?:ATTEMPT|TERMINAL-REVISION):[^\]\n]*\][ \t]*(?:\r?\n|$))+/;
|
|
360
366
|
const CONFIRM_MARK = "[CENTER-CONFIRM]";
|
|
361
367
|
const BATCH_TASK_ERROR: Record<string, string> = { failed: "子任务失败", cancelled: "子任务已取消" };
|
|
368
|
+
const CHAT_TASK_ERROR: Record<string, string> = { failed: "这一轮没有完成", cancelled: "这条消息已取消" };
|
|
362
369
|
|
|
363
370
|
/**
|
|
364
371
|
* 一次性任务(含续派)的转录,只读:
|
|
@@ -386,6 +393,12 @@ function batchViewMessages(conv: ConvState): Msg[] {
|
|
|
386
393
|
}
|
|
387
394
|
const generation = out.length ? out[out.length - 1].generation : 1;
|
|
388
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
|
+
}
|
|
389
402
|
if (!task) {
|
|
390
403
|
if (conv.job) note("none", "这个任务里还没有子任务");
|
|
391
404
|
} else if (!conv.msgs.length && !task.remote_task_id && !TERMINAL_TASK.has(task.status)) {
|
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>({});
|
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
|
|
|
@@ -430,6 +430,22 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
430
430
|
void refreshJob(newId, true);
|
|
431
431
|
return newId;
|
|
432
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
|
+
}
|
|
433
449
|
const result = await retryOnce(() => client.sendMessage(ref.jobId, text, { businessId, ...(ref.taskId ? { taskId: ref.taskId } : {}) }));
|
|
434
450
|
const jobTarget = result.job_id || ref.jobId;
|
|
435
451
|
// 指定了对话的:接续后跟到新对话任务里对应的那个对话;只有 job_id 的照旧只换 job。
|
package/src/core/types.ts
CHANGED
|
@@ -258,6 +258,13 @@ export interface BatchStreamTask {
|
|
|
258
258
|
last_error: string | null;
|
|
259
259
|
prompt?: string;
|
|
260
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 }>;
|
|
261
268
|
}
|
|
262
269
|
|
|
263
270
|
/** 一次性任务(含续派,mode 不是 conversation)的实时流首个事件:没有轮次,只有子任务。 */
|
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,
|
|
@@ -64,7 +64,10 @@ interface CardView {
|
|
|
64
64
|
running: boolean;
|
|
65
65
|
awaiting: boolean;
|
|
66
66
|
group: "needs" | "active" | "done" | "cancelled";
|
|
67
|
+
/** 带状态的活动(打开 card.status 时显示)。 */
|
|
67
68
|
activity: string;
|
|
69
|
+
/** 只有时间(缺省显示这个)。 */
|
|
70
|
+
when: string;
|
|
68
71
|
agent: string | null;
|
|
69
72
|
turns: number;
|
|
70
73
|
updatedAt: number;
|
|
@@ -79,7 +82,7 @@ export function cardView(id: string, entry: JobEntry | undefined): CardView {
|
|
|
79
82
|
const conv = job?.conversation;
|
|
80
83
|
const updatedAt = toMs(conv?.last_activity_at) ?? 0;
|
|
81
84
|
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 };
|
|
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 };
|
|
83
86
|
const status = job?.status ?? "queued";
|
|
84
87
|
if (TERMINAL_JOB.has(status)) {
|
|
85
88
|
const cancelled = status === "cancelled";
|
|
@@ -134,7 +137,8 @@ export function convRows(jobId: string, entry: JobEntry | undefined): ConvRowVie
|
|
|
134
137
|
|
|
135
138
|
function MsgCard({ v, selected, onSelect, show, onNewInJob }: { v: CardView; selected: boolean; onSelect: () => void; show: ResolvedMsgListConfig["card"]; onNewInJob?: () => void }) {
|
|
136
139
|
if (v.loading) return <CardSkeleton i={v.id.length} />;
|
|
137
|
-
const
|
|
140
|
+
const activity = show.status ? v.activity : v.when;
|
|
141
|
+
const foot = (show.activity && !!activity) || (show.turns && v.turns > 0);
|
|
138
142
|
const card = (
|
|
139
143
|
<button className={`task ${selected ? "sel" : ""} ${v.awaiting && show.status ? "awaiting" : ""}`} onClick={onSelect} title={v.error ?? undefined}>
|
|
140
144
|
<div className="task-top">
|
|
@@ -146,7 +150,7 @@ function MsgCard({ v, selected, onSelect, show, onNewInJob }: { v: CardView; sel
|
|
|
146
150
|
{show.description && v.desc && <div className="tdesc">{v.desc}</div>}
|
|
147
151
|
{v.error && <div className="blocked-chip">{Icon.x()} {v.error}</div>}
|
|
148
152
|
{foot && <div className="task-foot">
|
|
149
|
-
{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>}
|
|
150
154
|
<span className="spacer" />
|
|
151
155
|
{show.turns && v.turns > 0 && <span className="activity">{v.turns} 条消息</span>}
|
|
152
156
|
</div>}
|
|
@@ -167,7 +171,7 @@ function ConvRow({ v, selected, onSelect, show }: { v: ConvRowView; selected: bo
|
|
|
167
171
|
<button className={`cli-conv-row ${selected ? "sel" : ""} ${v.awaiting && show.status ? "awaiting" : ""}`} onClick={onSelect}>
|
|
168
172
|
{show.status && <StatusDot cls={v.cls} running={v.running} awaiting={v.awaiting} title={v.label} />}
|
|
169
173
|
<span className="cli-conv-title">{v.title}</span>
|
|
170
|
-
{show.activity && <span className="cli-conv-act">{v.activity}</span>}
|
|
174
|
+
{show.activity && show.status && <span className="cli-conv-act">{v.activity}</span>}
|
|
171
175
|
{show.turns && v.turns > 0 && <span className="cli-conv-act">{v.turns} 条</span>}
|
|
172
176
|
</button>
|
|
173
177
|
);
|
|
@@ -187,9 +191,11 @@ type GroupProps = {
|
|
|
187
191
|
label: string | false; cards: CardView[]; selected: string | null; onSelect: (id: string) => void; show: ResolvedMsgListConfig["card"];
|
|
188
192
|
rows: Record<string, ConvRowView[]>; firstTask: Record<string, string | undefined>; onNewInJob?: (jobId: string) => void;
|
|
189
193
|
accent?: boolean; collapsible?: boolean; collapsed?: boolean; onToggle?: () => void;
|
|
194
|
+
/** 不分组:只有卡片,没有组标题。 */
|
|
195
|
+
plain?: boolean;
|
|
190
196
|
};
|
|
191
197
|
|
|
192
|
-
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) {
|
|
193
199
|
if (!cards.length || label === false) return null;
|
|
194
200
|
const sel = selected ? parseConvKey(selected) : null;
|
|
195
201
|
const list = cards.map((v) => {
|
|
@@ -217,6 +223,7 @@ function Group({ label, cards, selected, onSelect, show, rows, firstTask, onNewI
|
|
|
217
223
|
</div>
|
|
218
224
|
);
|
|
219
225
|
});
|
|
226
|
+
if (plain) return <>{list}</>;
|
|
220
227
|
if (collapsible) {
|
|
221
228
|
return (
|
|
222
229
|
<>
|
|
@@ -337,10 +344,13 @@ function MsgListInner({ jobIds, discover = true, selectedId, onSelect, onNew, ti
|
|
|
337
344
|
<>
|
|
338
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>}
|
|
339
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 && <>
|
|
340
349
|
<Group label={cfg.groups.needs} cards={groups.needs} {...groupProps} accent />
|
|
341
350
|
<Group label={cfg.groups.active} cards={groups.active} {...groupProps} />
|
|
342
351
|
<Group label={cfg.groups.done} cards={groups.done} {...groupProps} collapsible collapsed={doneCollapsed && !q} onToggle={() => setDoneCollapsed((c) => !c)} />
|
|
343
352
|
<Group label={cfg.groups.cancelled} cards={groups.cancelled} {...groupProps} collapsible collapsed={cancelledCollapsed && !q} onToggle={() => setCancelledCollapsed((c) => !c)} />
|
|
353
|
+
</>}
|
|
344
354
|
</>
|
|
345
355
|
)}
|
|
346
356
|
</div>
|