@spzxg/cli-ui 0.1.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -14
- package/dist/{cli-ui-embed-BeBZKDZO.js → cli-ui-embed-C0F64Ln7.js} +13493 -12058
- package/dist/{cli-ui-index-CuwqVeUk.js → cli-ui-index--RVPCGIc.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +15 -9
- package/package.json +1 -1
- package/src/board/BrowserPane.tsx +418 -0
- package/src/board/ChangesPane.tsx +142 -0
- package/src/board/CliBoard.tsx +246 -0
- package/src/board/FileViewer.tsx +257 -0
- package/src/board/FilesPane.tsx +109 -0
- package/src/board/blocks.tsx +96 -0
- package/src/board/fileKind.ts +81 -0
- package/src/chat/CliChat.tsx +208 -65
- package/src/core/client.ts +224 -12
- package/src/core/config.ts +4 -2
- package/src/core/conversation.ts +186 -17
- package/src/core/provider.tsx +33 -19
- package/src/core/store.ts +169 -84
- package/src/core/types.ts +142 -4
- package/src/elements.tsx +51 -7
- package/src/embed.ts +15 -4
- package/src/index.ts +11 -5
- package/src/msglist/CliMsgList.tsx +129 -26
- package/src/styles.css +124 -0
- package/src/ui/Markdown.tsx +35 -6
- package/src/ui/fileLinks.ts +46 -0
- package/src/ui/transcriptModel.ts +19 -1
package/src/chat/CliChat.tsx
CHANGED
|
@@ -3,22 +3,34 @@
|
|
|
3
3
|
// 每条消息是中枢的一轮,排队 / 执行 / 结束都跟 center 保持一致。
|
|
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
|
-
import type
|
|
7
|
-
import {
|
|
8
|
-
import { emptyConv, isRunning, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation.js";
|
|
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";
|
|
9
8
|
import type { AskAnswers, AskQuestion, Msg } from "../core/types.js";
|
|
10
9
|
import { Icon } from "../ui/icons.js";
|
|
11
10
|
import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format.js";
|
|
12
11
|
import { AgentBadge, Avatar, Skel, StatusDot, type DotCls } from "../ui/shared.js";
|
|
13
|
-
import { MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript.js";
|
|
12
|
+
import { HANDOFF_NOTICE_PREFIX, MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript.js";
|
|
14
13
|
import { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel.js";
|
|
15
14
|
import { Composer, type ComposerOptions } from "./Composer.js";
|
|
16
15
|
import { fillTemplate, resolveChatConfig, type CliChatConfig } from "../core/config.js";
|
|
17
16
|
import { exportMarkdown } from "./export.js";
|
|
17
|
+
import { FileLinkContext, FileOpenContext } from "../ui/fileLinks.js";
|
|
18
|
+
import { FileViewer } from "../board/FileViewer.js";
|
|
19
|
+
import { BrowserPane } from "../board/BrowserPane.js";
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
/** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
|
|
22
|
+
export interface CliChatProps extends CliStoreOptions, ResultHandlers {
|
|
23
|
+
/**
|
|
24
|
+
* 显示哪个对话(center 的 job_id)。不给就跟共享仓库的选中走(和 cli-msglist 联动);都没有 = 新对话。
|
|
25
|
+
* 也可以直接给对话键 "job_id#task_id"(cli-msglist 的 onSelect、job_change 事件的 key 就是它)。
|
|
26
|
+
*/
|
|
21
27
|
jobId?: string | null;
|
|
28
|
+
/**
|
|
29
|
+
* 对话任务里的哪个对话(子任务 task_id),和 jobId 一起用。不给 = 对话任务的第一个对话(只有一个对话时就是它);
|
|
30
|
+
* "new" = 在这个对话任务里新开一个对话(同一节点、同一工作区,新的 Agent 会话),第一条消息发出后组件跟到开出来的那个。
|
|
31
|
+
* 结束对话时:给了 taskId 只关掉这一个对话,没给结束整个对话任务。
|
|
32
|
+
*/
|
|
33
|
+
taskId?: string | null;
|
|
22
34
|
/** 面包屑里的名称与色块(如业务 / 项目名)。 */
|
|
23
35
|
title?: string;
|
|
24
36
|
color?: string;
|
|
@@ -28,10 +40,24 @@ export interface CliChatProps extends CliStoreOptions {
|
|
|
28
40
|
allowClose?: boolean;
|
|
29
41
|
/** 界面配置:顶部栏、输入框、对话区各模块的显示与文案,见 core/config.ts。 */
|
|
30
42
|
config?: CliChatConfig;
|
|
43
|
+
/**
|
|
44
|
+
* Agent 消息里本地文件链接(report.md、/abs/path、src/a.ts:12)的打开地址。缺省没有:显示成不可点的文字。
|
|
45
|
+
* 有文件服务时(例如以后的看板 / Operator 后端)给出地址,链接就能打开(与 Operator 一致)。
|
|
46
|
+
* taskId:看的是对话任务里指定的那个对话时才有。
|
|
47
|
+
*/
|
|
48
|
+
fileUrl?: (path: string, jobId: string, kind: "link" | "image", taskId?: string | null) => string | null;
|
|
49
|
+
/**
|
|
50
|
+
* 点了本地文件链接 / 「查看浏览器」怎么打开:
|
|
51
|
+
* · "auto"(缺省)—— 同一个 Provider 里有 cli-board 在看这个对话就在看板里打开,否则在对话里弹窗预览;
|
|
52
|
+
* · "board" —— 只交给看板(没有看板时按 fileUrl 的地址打开);
|
|
53
|
+
* · "modal" —— 总是在对话里弹窗;
|
|
54
|
+
* · "link" —— 不拦截,按 fileUrl 的地址在新窗口打开(以前的行为)。
|
|
55
|
+
* 无论哪种,都会发 open 事件(onEvent),宿主可以据此展开右侧面板。
|
|
56
|
+
*/
|
|
57
|
+
fileOpen?: "auto" | "board" | "modal" | "link";
|
|
31
58
|
/** 一轮结束(≈ center 回调 conversation_turn)、对话结束、job 变化等。 */
|
|
32
59
|
onEvent?: (event: CliEvent) => void;
|
|
33
|
-
|
|
34
|
-
onCallback?: (payload: ConversationCallback) => void;
|
|
60
|
+
|
|
35
61
|
theme?: "light" | "dark";
|
|
36
62
|
className?: string;
|
|
37
63
|
style?: CSSProperties;
|
|
@@ -44,12 +70,25 @@ export function CliChat(props: CliChatProps) {
|
|
|
44
70
|
}
|
|
45
71
|
|
|
46
72
|
// 不能再发消息的结束原因(调用方关闭 / 取消)。其余原因(过期、换线、节点丢失)再发会自动开新对话。
|
|
47
|
-
const FINAL_REASONS = new Set(["closed", "cancelled"]);
|
|
73
|
+
const FINAL_REASONS = new Set(["closed", "cancelled", "conversation_closed"]);
|
|
48
74
|
const REASON_TEXT: Record<string, string> = {
|
|
49
|
-
closed: "对话已结束", cancelled: "对话已取消", expired: "对话空闲太久已过期",
|
|
75
|
+
closed: "对话已结束", cancelled: "对话已取消", conversation_closed: "这个对话已结束", expired: "对话空闲太久已过期",
|
|
50
76
|
node_reassigned: "执行机器换到了别的产品线", node_lost: "执行节点长时间连不上",
|
|
51
77
|
};
|
|
52
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
|
+
|
|
53
92
|
/** 「智能体在工作」那一行:跑了多久,很久没输出时提示可能卡住。与 Operator 的 RunningIndicator 一致。 */
|
|
54
93
|
const QUIET_AFTER_MS = 180_000;
|
|
55
94
|
function RunningIndicator({ agent, agentName, startedAt, lastActivityRef }: { agent: string | null; agentName: string; startedAt: number | null; lastActivityRef: React.MutableRefObject<number> }) {
|
|
@@ -76,42 +115,49 @@ function RunningIndicator({ agent, agentName, startedAt, lastActivityRef }: { ag
|
|
|
76
115
|
);
|
|
77
116
|
}
|
|
78
117
|
|
|
79
|
-
function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", readOnly: readOnlyProp, allowClose = true, config, theme = "light", className, style, onEvent, onCallback, inProvider, newConversation, createConversation, businessIdFor }: CliChatProps & { inProvider: boolean }) {
|
|
80
|
-
useComponentEvents(onEvent, onCallback, inProvider);
|
|
118
|
+
function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", color = "#b8613f", readOnly: readOnlyProp, allowClose = true, config, theme = "light", className, style, onEvent, onTurnEnd, onConversationEnd, onCallback, inProvider, fileUrl, fileOpen = "auto", newConversation, createConversation, businessIdFor }: CliChatProps & { inProvider: boolean }) {
|
|
119
|
+
useComponentEvents(onEvent, { onTurnEnd, onConversationEnd, onCallback }, inProvider);
|
|
81
120
|
// 配置按内容比较:宿主每次渲染传新对象也不会让消息行全部重渲染。
|
|
82
121
|
const configKey = JSON.stringify(config ?? {});
|
|
83
122
|
const cfg = useMemo(() => resolveChatConfig(config), [configKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
84
|
-
const readOnly = !!readOnlyProp || !cfg.composer.show;
|
|
85
123
|
const store = useCliStore();
|
|
86
124
|
const selected = useCliState((s) => s.selected);
|
|
87
125
|
// 显示哪个对话:
|
|
88
126
|
// · 不传 jobId → 跟仓库的选中走(和同一 Provider 里的 cli-msglist 联动);
|
|
89
127
|
// · 传了 jobId(含 null = 新对话)→ 组件自己接管:从传入的 id 开始,之后自己新建出的对话、
|
|
90
|
-
// center 接续出的新对话都由组件自己跟过去(同时发 job_change 告诉宿主);宿主改了 jobId 就以宿主为准。
|
|
91
|
-
//
|
|
128
|
+
// center 接续出的新对话都由组件自己跟过去(同时发 job_change 告诉宿主);宿主改了 jobId / taskId 就以宿主为准。
|
|
129
|
+
// 内部用对话键(key):只有 job_id = 对话任务的第一个对话;"job#task" = 里面指定的那个对话(见 core/conversation.ts)。
|
|
130
|
+
// 新对话的草稿按组件实例存(对话的草稿按对话键存,看同一个对话的组件共用一份是对的)。
|
|
92
131
|
const instanceId = useId();
|
|
93
132
|
const controlled = jobIdProp !== undefined;
|
|
94
|
-
|
|
133
|
+
// jobId 也可以直接给对话键(例如 cli-msglist 的 onSelect 给的 "job#task"),taskId 再给就以 taskId 为准。
|
|
134
|
+
const keyProp = jobIdProp === undefined ? undefined : jobIdProp ? convKey(parseConvKey(jobIdProp).jobId, taskIdProp ?? parseConvKey(jobIdProp).taskId) : null;
|
|
135
|
+
const [followed, setShown] = useState<{ prop: string | null | undefined; id: string | null }>(() => ({ prop: keyProp, id: keyProp ?? null }));
|
|
95
136
|
let viewId = followed.id;
|
|
96
|
-
if (followed.prop !==
|
|
97
|
-
viewId =
|
|
98
|
-
setShown({ prop:
|
|
137
|
+
if (followed.prop !== keyProp) {
|
|
138
|
+
viewId = keyProp ?? null;
|
|
139
|
+
setShown({ prop: keyProp, id: viewId });
|
|
99
140
|
}
|
|
100
|
-
const
|
|
141
|
+
const key = controlled ? viewId : selected;
|
|
142
|
+
const ref = key ? parseConvKey(key) : null;
|
|
143
|
+
const jobId = ref?.jobId ?? null;
|
|
144
|
+
// 「在这个对话任务里新开一个对话」:还没有对话,第一条消息开出来。
|
|
145
|
+
const isNew = isNewInJob(key);
|
|
146
|
+
const taskId = ref?.taskId && !isNew ? ref.taskId : null;
|
|
101
147
|
const go = useCallback((id: string | null) => {
|
|
102
148
|
if (controlled) setShown((v) => (v.id === id ? v : { ...v, id }));
|
|
103
149
|
else store.select(id);
|
|
104
150
|
}, [controlled, store]);
|
|
105
|
-
const conv = useCliState((s) => (
|
|
151
|
+
const conv = useCliState((s) => (key && !isNew ? s.convs[key] : undefined)) ?? (key && !isNew ? emptyConv(key) : null);
|
|
106
152
|
const entry = useCliState((s) => (jobId ? s.jobs[jobId] : undefined));
|
|
107
153
|
|
|
108
154
|
// 看这个对话:开实时流(同一对话多处看只连一次)。
|
|
109
|
-
useEffect(() => (
|
|
155
|
+
useEffect(() => (key ? store.open(key) : undefined), [store, key]);
|
|
110
156
|
// 正在看的对话被接续成了新 job(别处发消息触发的也算):跟过去。
|
|
111
|
-
const
|
|
112
|
-
|
|
157
|
+
const keyRef = useRef(key);
|
|
158
|
+
keyRef.current = key;
|
|
113
159
|
useEffect(() => store.on((event) => {
|
|
114
|
-
if (event.type === "job_change" && event.
|
|
160
|
+
if (event.type === "job_change" && event.previousKey && event.previousKey === keyRef.current) go(event.key);
|
|
115
161
|
}), [store, go]);
|
|
116
162
|
|
|
117
163
|
const allMessages = useMemo(() => (conv ? viewMessages(conv) : []), [conv]);
|
|
@@ -123,39 +169,62 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
|
|
|
123
169
|
})), [allMessages, cfg.transcript.tools]);
|
|
124
170
|
const running = conv ? isRunning(conv) : false;
|
|
125
171
|
const job = entry?.job;
|
|
126
|
-
|
|
172
|
+
// 一次性任务(含续派,不是对话任务):只读查看其中一个子任务——没有轮次,不能发消息、结束、回答。
|
|
173
|
+
const batch = !!conv?.batch || (!!job && job.mode !== "conversation");
|
|
174
|
+
const readOnly = !!readOnlyProp || !cfg.composer.show || batch;
|
|
175
|
+
// 这个对话对应的子任务:指定了就是它,否则对话任务的第一个(「新开」时借用第一个的智能体显示);
|
|
176
|
+
// 一次性任务没指定时是实时流挑的那个(最近一个派发过的)。
|
|
177
|
+
const shownTaskId = taskId ?? (batch ? conv?.task?.id : undefined);
|
|
178
|
+
const task = (shownTaskId ? job?.tasks?.find((t) => t.id === shownTaskId) : undefined) ?? (shownTaskId || batch ? undefined : job?.tasks?.[0]);
|
|
179
|
+
const info = taskId ? job?.conversation?.conversations?.find((c) => c.task_id === taskId) : undefined;
|
|
127
180
|
const agent = task?.agent ?? conv?.turns[conv.turns.length - 1]?.agent ?? null;
|
|
128
181
|
const agentText = labelOf(agent);
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
182
|
+
const jobStatus = conv?.job?.status ?? job?.status ?? null;
|
|
183
|
+
const jobEnded = !!jobStatus && TERMINAL_JOB.has(jobStatus);
|
|
184
|
+
// 只关掉了这一个对话(对话任务还开着):按「这个对话已结束」显示,不能再发消息。
|
|
185
|
+
const convClosed = !jobEnded && !!(conv?.task?.closed ?? info?.closed);
|
|
186
|
+
const status = convClosed ? "succeeded" : jobStatus;
|
|
187
|
+
const closedReason = convClosed ? "conversation_closed" : conv?.job?.closed_reason ?? job?.conversation?.closed_reason ?? null;
|
|
188
|
+
const ended = jobEnded || convClosed;
|
|
132
189
|
const finalEnd = ended && FINAL_REASONS.has(closedReason ?? "closed");
|
|
133
190
|
const continuedFrom = job?.conversation?.continued_from_job_id ?? null;
|
|
134
|
-
|
|
135
|
-
const
|
|
191
|
+
// 接续自旧对话任务里的哪个对话(指定了对话时跟回那一个)。
|
|
192
|
+
const continuedFromKey = continuedFrom ? convKey(continuedFrom, taskId ? task?.continue_from_task_id ?? info?.continue_from_task_id ?? null : null) : null;
|
|
193
|
+
const continuedBy = conv?.continuedBy ?? (taskId ? null : job?.conversation?.continued_by_job_id ?? null);
|
|
194
|
+
const jobTitle = job?.title ?? conv?.job?.title;
|
|
195
|
+
// 对话任务里有几个对话时,标题是「对话任务 / 这个对话」。
|
|
196
|
+
const manyConvs = (batch ? job?.tasks?.length ?? 0 : job?.conversation?.conversations?.length ?? 0) > 1;
|
|
197
|
+
const convName = conv?.task?.title ?? info?.title ?? task?.title;
|
|
198
|
+
const convTitle = isNew ? `${jobTitle ? `${jobTitle} / ` : ""}新对话`
|
|
199
|
+
: (taskId || batch) && manyConvs && convName && convName !== jobTitle ? `${jobTitle ?? "对话"} / ${convName}`
|
|
200
|
+
: jobTitle ?? (key ? "对话" : "新对话");
|
|
136
201
|
const awaitingAnswer = useMemo(() => hasOpenAsk(messages), [messages]);
|
|
137
202
|
const lastTurn = conv?.turns[conv.turns.length - 1];
|
|
138
203
|
const awaiting = !ended && !running && !!lastTurn?.asked && lastTurn.status === "succeeded";
|
|
139
|
-
|
|
204
|
+
// 一次性任务的子任务还没派发(没有节点对话)、或者流已经结束还没有转录:不再转圈,转录里有说明。
|
|
205
|
+
const loading = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error" && conv.stream !== "ended" && !(conv.batch && !conv.remoteTaskId);
|
|
206
|
+
const batchStatus = batch ? conv?.batchTask?.status ?? task?.status ?? null : null;
|
|
140
207
|
|
|
141
|
-
// 用量:任务详情里的累计 +
|
|
142
|
-
const
|
|
143
|
-
const
|
|
208
|
+
// 用量:任务详情里的累计 + 实时流里上次刷新后的增量(「新开」的对话还没有用量,不借第一个对话的)。
|
|
209
|
+
const usageTask = isNew ? undefined : task;
|
|
210
|
+
const fresh = (usageTask ? Math.max(0, usageTask.total_tokens - usageTask.cache_read_tokens) : 0) + (conv?.liveUsage.fresh ?? 0);
|
|
211
|
+
const cacheRead = (usageTask?.cache_read_tokens ?? 0) + (conv?.liveUsage.cacheRead ?? 0);
|
|
144
212
|
const resolvedModel = conv?.resolvedModel ?? task?.resolved_model ?? null;
|
|
145
213
|
|
|
146
214
|
const { dotCls, statusText } = useMemo((): { dotCls: DotCls; statusText: string } => {
|
|
147
|
-
if (!
|
|
215
|
+
if (!key || isNew) return { dotCls: "r", statusText: "未开始" };
|
|
216
|
+
if (batch) return batchStatusView(batchStatus, jobStatus);
|
|
148
217
|
if (ended) return { dotCls: status === "succeeded" ? "g" : "x", statusText: REASON_TEXT[closedReason ?? ""] ?? (status === "succeeded" ? "已结束" : status === "cancelled" ? "已取消" : "已失败") };
|
|
149
218
|
if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
|
|
150
219
|
if (running) return { dotCls: "a", statusText: "运行中" };
|
|
151
220
|
if (status === "queued") return { dotCls: "a", statusText: "等待执行节点" };
|
|
152
221
|
return { dotCls: "a", statusText: "进行中" };
|
|
153
|
-
}, [
|
|
222
|
+
}, [key, isNew, batch, batchStatus, jobStatus, ended, status, closedReason, awaiting, running]);
|
|
154
223
|
|
|
155
224
|
// ── 处理器(引用稳定,MessageView 的 memo 才有效) ──
|
|
156
225
|
// 输入框之外的操作(回答提问、撤回排队、结束对话、停止)失败时在顶部提示,不静默吞掉。
|
|
157
226
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
158
|
-
useEffect(() => setActionError(null), [
|
|
227
|
+
useEffect(() => setActionError(null), [key]);
|
|
159
228
|
const report = useCallback(async <T,>(work: Promise<T>, what: string) => {
|
|
160
229
|
try {
|
|
161
230
|
const result = await work;
|
|
@@ -166,46 +235,81 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
|
|
|
166
235
|
throw error;
|
|
167
236
|
}
|
|
168
237
|
}, []);
|
|
169
|
-
const latest = useRef({
|
|
170
|
-
latest.current = {
|
|
238
|
+
const latest = useRef({ key, messages, conv });
|
|
239
|
+
latest.current = { key, messages, conv };
|
|
171
240
|
// 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
|
|
172
241
|
const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
|
|
173
242
|
sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled };
|
|
174
243
|
const send = useCallback(async (text: string) => {
|
|
175
|
-
const id = latest.current.
|
|
244
|
+
const id = latest.current.key;
|
|
176
245
|
const target = await store.send(id ?? null, text, sendOpts.current);
|
|
177
246
|
if (target !== id) go(target);
|
|
178
247
|
}, [store, go]);
|
|
248
|
+
// AI 把浏览器交给用户(实时新来的「🖐 …」行):同一 Provider 里有看板在看这个对话,就把看板切到浏览器(收起的会请宿主展开)。
|
|
249
|
+
// 打开对话时已有的交接行不算(先记下已有的再开始比较);没有看板时不弹窗打扰,行里有「查看浏览器」按钮。
|
|
250
|
+
const handoffSeen = useRef<{ key: string | null; ids: Set<string> | null }>({ key: null, ids: null });
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
if (!key || !conv || fileOpen === "link" || fileOpen === "modal") return;
|
|
253
|
+
if (handoffSeen.current.key !== key) handoffSeen.current = { key, ids: null };
|
|
254
|
+
if (!conv.hasTranscript) return;
|
|
255
|
+
const handoffs = conv.msgs.filter((m) => m.role === "system" && m.content.startsWith(HANDOFF_NOTICE_PREFIX));
|
|
256
|
+
const seen = handoffSeen.current;
|
|
257
|
+
if (!seen.ids) { seen.ids = new Set(handoffs.map((m) => m.id)); return; }
|
|
258
|
+
const fresh = handoffs.filter((m) => !seen.ids!.has(m.id));
|
|
259
|
+
for (const m of fresh) seen.ids.add(m.id);
|
|
260
|
+
if (fresh.length) store.openInBoard(key, { kind: "browser" });
|
|
261
|
+
}, [key, conv, fileOpen, store]);
|
|
262
|
+
|
|
263
|
+
// 对话里弹窗打开的文件 / 浏览器(没有看板接的时候)。换对话就关掉。
|
|
264
|
+
const [popup, setPopup] = useState<{ kind: "file"; path: string } | { kind: "browser" } | null>(null);
|
|
265
|
+
useEffect(() => setPopup(null), [key]);
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
if (!popup) return;
|
|
268
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setPopup(null); };
|
|
269
|
+
window.addEventListener("keydown", onKey);
|
|
270
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
271
|
+
}, [popup]);
|
|
179
272
|
const handlers = useMemo<MessageHandlers>(() => ({
|
|
180
273
|
// 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
|
|
181
274
|
onAnswer: readOnly ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
|
|
182
|
-
const id = latest.current.
|
|
275
|
+
const id = latest.current.key;
|
|
183
276
|
if (!id) return;
|
|
184
277
|
const sent = askId
|
|
185
278
|
? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id) go(target); })
|
|
186
279
|
: send(formatAnswersText(questions, answers));
|
|
187
280
|
await report(sent, "回答没有发出去");
|
|
188
281
|
},
|
|
189
|
-
onCancelQueued: readOnly ? undefined : (m: Msg) => { const id = latest.current.
|
|
282
|
+
onCancelQueued: readOnly ? undefined : (m: Msg) => { const id = latest.current.key; if (id && m.turnId) void report(store.cancelTurn(id, m.turnId), "撤回失败").catch(() => undefined); },
|
|
190
283
|
// 后端能直接处理的浏览器动作(Operator 后端)才给;center 不提供,卡片按 Operator「没有浏览器能力」时的样子显示。
|
|
191
284
|
onInput: readOnly || !store.client.submitInput ? undefined : async (inputId: string, values: string[]) => {
|
|
192
|
-
const id = latest.current.
|
|
285
|
+
const id = latest.current.key;
|
|
193
286
|
if (!id) return "对话还没开始";
|
|
194
287
|
try { return await store.client.submitInput!(id, inputId, values); } catch (error) { return error instanceof Error ? error.message : String(error); }
|
|
195
288
|
},
|
|
196
|
-
|
|
197
|
-
|
|
289
|
+
// 交还浏览器:后端自己的 handback(Operator 后端),没有就走看板接口(center 转发到节点)。
|
|
290
|
+
onHandback: readOnly || !(store.client.handback || store.client.board) ? undefined : async () => {
|
|
291
|
+
const id = latest.current.key;
|
|
198
292
|
if (!id) return "对话还没开始";
|
|
199
|
-
try {
|
|
293
|
+
try {
|
|
294
|
+
if (store.client.handback) return await store.client.handback(id);
|
|
295
|
+
await store.client.board!.browserAction(id, "handback");
|
|
296
|
+
return null;
|
|
297
|
+
} catch (error) { return error instanceof Error ? error.message : String(error); }
|
|
298
|
+
},
|
|
299
|
+
onShowBrowser: !store.client.board || fileOpen === "link" ? undefined : () => {
|
|
300
|
+
const id = latest.current.key;
|
|
301
|
+
if (!id) return;
|
|
302
|
+
if (fileOpen !== "modal" && store.openInBoard(id, { kind: "browser" })) return;
|
|
303
|
+
if (fileOpen !== "board") setPopup({ kind: "browser" });
|
|
200
304
|
},
|
|
201
305
|
onRetry: readOnly ? undefined : (msgId: string) => {
|
|
202
306
|
const list = latest.current.messages;
|
|
203
307
|
const at = list.findIndex((m) => m.id === msgId);
|
|
204
308
|
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; }
|
|
205
309
|
},
|
|
206
|
-
}), [readOnly, send, store, go, report]);
|
|
310
|
+
}), [readOnly, send, store, go, report, fileOpen]);
|
|
207
311
|
const stop = useCallback(async () => {
|
|
208
|
-
const {
|
|
312
|
+
const { key: id, conv: c } = latest.current;
|
|
209
313
|
const turn = c ? runningTurn(c) : null;
|
|
210
314
|
if (id && turn) await report(store.cancelTurn(id, turn.turn_id), "停止失败");
|
|
211
315
|
}, [store, report]);
|
|
@@ -365,7 +469,7 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
|
|
|
365
469
|
setAtBottom(true);
|
|
366
470
|
setWin({ start: null, end: null });
|
|
367
471
|
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
368
|
-
}, [
|
|
472
|
+
}, [key]);
|
|
369
473
|
|
|
370
474
|
const contextReset = !!lastTurn?.context_reset && conv?.turns.some((turn) => turn.context_reset);
|
|
371
475
|
const h = cfg.header;
|
|
@@ -377,7 +481,29 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
|
|
|
377
481
|
const composerDisabled = !!readOnly || finalEnd || conv?.stream === "error";
|
|
378
482
|
const disabledHint = readOnly ? "只读" : finalEnd ? "对话已结束,不能再发消息" : conv?.streamError ?? undefined;
|
|
379
483
|
|
|
484
|
+
const fileUrlRef = useRef(fileUrl);
|
|
485
|
+
fileUrlRef.current = fileUrl;
|
|
486
|
+
const board = store.client.board;
|
|
487
|
+
// 文件地址:宿主给的 fileUrl 优先;没给且后端有看板接口、地址能直接打开时,用 center 的文件接口。
|
|
488
|
+
const resolveFile = useMemo(() => {
|
|
489
|
+
if (fileUrl) return (path: string, kind: "link" | "image") => (jobId ? fileUrlRef.current?.(path, jobId, kind, taskId) ?? null : null);
|
|
490
|
+
if (board?.directLinks && key) return (path: string) => board.fileUrl(key, path);
|
|
491
|
+
return null;
|
|
492
|
+
}, [!!fileUrl, key, jobId, taskId, board]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
493
|
+
// 点了本地文件:看板 → 弹窗 → 地址(见 fileOpen)。
|
|
494
|
+
const openFile = useMemo(() => {
|
|
495
|
+
if (!key || fileOpen === "link") return null;
|
|
496
|
+
return (path: string) => {
|
|
497
|
+
if (fileOpen !== "modal" && store.openInBoard(key, { kind: "file", path })) return true;
|
|
498
|
+
if (fileOpen === "board" || !board) return false;
|
|
499
|
+
setPopup({ kind: "file", path });
|
|
500
|
+
return true;
|
|
501
|
+
};
|
|
502
|
+
}, [key, fileOpen, store, board]);
|
|
503
|
+
|
|
380
504
|
return (
|
|
505
|
+
<FileLinkContext.Provider value={resolveFile}>
|
|
506
|
+
<FileOpenContext.Provider value={openFile}>
|
|
381
507
|
<div className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
|
|
382
508
|
<div className="session">
|
|
383
509
|
{h.show && (h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
|
|
@@ -387,8 +513,8 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
|
|
|
387
513
|
<span className="pic" style={{ width: 16, height: 16, borderRadius: 5, background: color, display: "grid", placeItems: "center", color: "#fff", fontSize: 9, fontWeight: 700 }}>{title[0]}</span>
|
|
388
514
|
{title} <span className="sep">/</span> 对话
|
|
389
515
|
</>}
|
|
390
|
-
{h.export &&
|
|
391
|
-
<button className="crumb-export" onClick={() => exportMarkdown(convTitle, jobId
|
|
516
|
+
{h.export && key && allMessages.length > 0 && (
|
|
517
|
+
<button className="crumb-export" onClick={() => exportMarkdown(convTitle, taskId ? `${jobId}-${taskId}` : jobId!, allMessages, conv?.turns ?? [])} title="导出这个对话的完整过程(Markdown)">{Icon.download()} 导出执行过程</button>
|
|
392
518
|
)}
|
|
393
519
|
</div>}
|
|
394
520
|
{h.title && <div className="sh-title"><span className="sht-text" title={convTitle}>{convTitle}</span></div>}
|
|
@@ -401,7 +527,7 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
|
|
|
401
527
|
{cacheRead > 0 && <> <span className="usage-dot">·</span> <span className="usage-cached">{fmtTokens(cacheRead)} 缓存</span></>}
|
|
402
528
|
</span>
|
|
403
529
|
)}
|
|
404
|
-
{h.model &&
|
|
530
|
+
{h.model && key && !isNew && (
|
|
405
531
|
<span className="status-ctl" style={{ cursor: "default" }} title="模型由任务中心按产品线配置分配">
|
|
406
532
|
{Icon.spark()}<span className="cv">默认</span>
|
|
407
533
|
{resolvedModel && <span className="model-badge" title={`上次运行于 ${resolvedModel}`}>{modelLabel(resolvedModel)}</span>}
|
|
@@ -411,18 +537,18 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
|
|
|
411
537
|
<StatusDot cls={dotCls} running={running} awaiting={awaiting} title={statusText} />
|
|
412
538
|
<span className="cv">{statusText}</span>
|
|
413
539
|
</span>}
|
|
414
|
-
{h.close &&
|
|
415
|
-
<button className="btn btn-line btn-sm" onClick={() => setClosing("confirm")} disabled={closing === "busy"} title="结束这个对话:之后不再接收消息,正在跑的一轮会被中止">{Icon.stop()} 结束对话</button>
|
|
540
|
+
{h.close && key && !isNew && allowClose && !readOnly && !ended && (
|
|
541
|
+
<button className="btn btn-line btn-sm" onClick={() => setClosing("confirm")} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
|
|
416
542
|
)}
|
|
417
543
|
</div>
|
|
418
544
|
</div>}
|
|
419
545
|
|
|
420
|
-
{closing !== "idle" &&
|
|
546
|
+
{closing !== "idle" && key && (
|
|
421
547
|
<div className="clear-confirm">
|
|
422
|
-
<span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}。</span>
|
|
548
|
+
<span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}{taskId && manyConvs ? ";同一任务里的其他对话不受影响" : ""}。</span>
|
|
423
549
|
<span className="spacer" />
|
|
424
550
|
<button className="btn btn-ghost btn-sm" onClick={() => setClosing("idle")} disabled={closing === "busy"}>取消</button>
|
|
425
|
-
<button className="btn btn-accent btn-sm" disabled={closing === "busy"} onClick={() => { setClosing("busy"); void report(store.close(
|
|
551
|
+
<button className="btn btn-accent btn-sm" disabled={closing === "busy"} onClick={() => { setClosing("busy"); void report(store.close(key), "结束对话失败").catch(() => undefined).finally(() => setClosing("idle")); }}>{closing === "busy" ? "正在结束…" : "确认结束"}</button>
|
|
426
552
|
</div>
|
|
427
553
|
)}
|
|
428
554
|
{actionError && <div className="sync-banner conflict"><span className="sync-msg">⚠ {actionError}</span><span className="sync-spacer" /><button className="btn btn-ghost btn-sm" onClick={() => setActionError(null)}>知道了</button></div>}
|
|
@@ -432,17 +558,18 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
|
|
|
432
558
|
<div className="transcript-wrap">
|
|
433
559
|
<div className="transcript" ref={scrollRef} onScroll={onScroll}>
|
|
434
560
|
<div className="tw">
|
|
435
|
-
{!
|
|
561
|
+
{(!key || isNew) && cfg.empty.show && (
|
|
436
562
|
<div className="hero cli-hero">
|
|
437
563
|
<div className="h-ic">{Icon.bolt()}</div>
|
|
438
564
|
<div className="h-status"><StatusDot cls="r" /> 新对话 · 尚未开始</div>
|
|
439
565
|
<h2>{cfg.empty.title}</h2>
|
|
566
|
+
{isNew && <p className="h-desc">在「{jobTitle ?? "这个任务"}」里新开一个对话:和任务里的其他对话共用同一个工作区(能看到彼此产出的文件),对话内容各自独立。</p>}
|
|
440
567
|
{cfg.empty.description && <p className="h-desc">{cfg.empty.description}</p>}
|
|
441
568
|
</div>
|
|
442
569
|
)}
|
|
443
570
|
{continuedFrom && (
|
|
444
571
|
<div className="msg system info"><div className="msg-body">ℹ 这个对话接续自上一个已结束的对话({continuedFrom})。工作区文件沿用,之前的对话内容没有带进来。
|
|
445
|
-
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => go(
|
|
572
|
+
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => go(continuedFromKey)}>查看上一个对话</button>
|
|
446
573
|
</div></div>
|
|
447
574
|
)}
|
|
448
575
|
{contextReset && <div className="msg system info"><div className="msg-body">ℹ 执行节点上的会话重建过:从那一轮起是新的 Agent 会话,之前的对话上下文没有带过去(工作区文件还在)。</div></div>}
|
|
@@ -484,7 +611,7 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
|
|
|
484
611
|
{cfg.transcript.runningIndicator && running && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
|
|
485
612
|
{!hasBelow && queued.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho={false} handlers={handlers} view={view} />)}
|
|
486
613
|
{ended && (
|
|
487
|
-
<div className="session-label"><span className="ln" />{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}<span className="ln" /></div>
|
|
614
|
+
<div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
|
|
488
615
|
)}
|
|
489
616
|
{continuedBy && (
|
|
490
617
|
<div className="load-older"><button className="btn btn-line btn-sm" onClick={() => go(continuedBy)}>转到接续的新对话</button></div>
|
|
@@ -499,18 +626,34 @@ function ChatInner({ jobId: jobIdProp, title = "对话", color = "#b8613f", read
|
|
|
499
626
|
</div>
|
|
500
627
|
{!readOnly && (
|
|
501
628
|
<Composer
|
|
502
|
-
key={
|
|
503
|
-
draft={
|
|
504
|
-
placeholder={fillTemplate(
|
|
629
|
+
key={key ?? "new"}
|
|
630
|
+
draft={key ?? `new:${instanceId}`}
|
|
631
|
+
placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
|
|
505
632
|
disabled={composerDisabled}
|
|
506
633
|
disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
|
|
507
634
|
running={running}
|
|
508
635
|
onSend={send}
|
|
509
|
-
onStop={cfg.composer.stop &&
|
|
636
|
+
onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
|
|
510
637
|
options={composerOptions}
|
|
511
638
|
/>
|
|
512
639
|
)}
|
|
513
640
|
</div>
|
|
641
|
+
{popup && key && board && (
|
|
642
|
+
<div className="scrim" onClick={(e) => { if (e.target === e.currentTarget) setPopup(null); }}>
|
|
643
|
+
<div className={`modal cli-popup ${popup.kind}`} role="dialog" aria-modal="true" aria-label={popup.kind === "file" ? popup.path : "任务浏览器"}>
|
|
644
|
+
{popup.kind === "file" ? (
|
|
645
|
+
<FileViewer backend={board} jobId={key} path={popup.path} onClose={() => setPopup(null)} onOpenPath={(p) => setPopup({ kind: "file", path: p })} />
|
|
646
|
+
) : (
|
|
647
|
+
<>
|
|
648
|
+
<div className="cli-fv-h"><span className="cli-fv-name">任务浏览器</span><span className="cli-fv-sp" /><button className="cli-fv-icon" onClick={() => setPopup(null)} title="关闭">{Icon.x()}</button></div>
|
|
649
|
+
<BrowserPane backend={board} jobId={key} readOnly={readOnly} />
|
|
650
|
+
</>
|
|
651
|
+
)}
|
|
652
|
+
</div>
|
|
653
|
+
</div>
|
|
654
|
+
)}
|
|
514
655
|
</div>
|
|
656
|
+
</FileOpenContext.Provider>
|
|
657
|
+
</FileLinkContext.Provider>
|
|
515
658
|
);
|
|
516
659
|
}
|