@spzxg/cli-ui 0.3.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -18
- package/dist/cli-ui-embed-CXdmrwo1.js +40896 -0
- package/dist/cli-ui-index-BZppwn-v.js +13528 -0
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +2 -1
- package/src/board/BrowserPane.tsx +396 -96
- package/src/board/ChangesPane.tsx +13 -4
- package/src/board/CliBoard.tsx +219 -37
- package/src/board/FileViewer.tsx +244 -63
- package/src/board/FilesPane.tsx +51 -4
- package/src/board/blocks.tsx +3 -2
- package/src/board/fileOps.ts +55 -0
- package/src/board/folderDownload.ts +161 -0
- package/src/board/workspace.ts +129 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +126 -29
- package/src/core/blocks.ts +21 -2
- package/src/core/client.ts +270 -41
- package/src/core/config.ts +28 -11
- package/src/core/conversation.ts +35 -12
- package/src/core/provider.tsx +27 -6
- package/src/core/store.ts +354 -69
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +28 -6
- package/src/elements.tsx +113 -9
- package/src/index.ts +7 -2
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +214 -0
- package/src/msglist/CliMsgList.tsx +64 -26
- package/src/styles.css +101 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/TextBlock.tsx +1 -1
- package/src/ui/transcriptModel.ts +66 -9
- package/dist/cli-ui-embed-C0F64Ln7.js +0 -37922
- package/dist/cli-ui-index--RVPCGIc.js +0 -13516
package/src/chat/CliChat.tsx
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
// 数据来自 center 的对话任务(mode=conversation):实时流里的节点转录按 Operator 的规则渲染,
|
|
3
3
|
// 每条消息是中枢的一轮,排队 / 执行 / 结束都跟 center 保持一致。
|
|
4
4
|
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
5
|
+
import { CliErrorBoundary } from "../ui/ErrorBoundary.js";
|
|
5
6
|
import type { CliEvent, CliStore, CliStoreOptions } from "../core/store.js";
|
|
6
7
|
import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider.js";
|
|
7
|
-
import {
|
|
8
|
+
import { boardContextBlock, serializeBlock } from "../core/blocks.js";
|
|
9
|
+
import { convKey, emptyConv, isChatTask, isNewInJob, isRunning, parseConvKey, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation.js";
|
|
8
10
|
import type { AskAnswers, AskQuestion, Msg } from "../core/types.js";
|
|
9
11
|
import { Icon } from "../ui/icons.js";
|
|
10
12
|
import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format.js";
|
|
@@ -66,7 +68,7 @@ export interface CliChatProps extends CliStoreOptions, ResultHandlers {
|
|
|
66
68
|
|
|
67
69
|
export function CliChat(props: CliChatProps) {
|
|
68
70
|
const ctx = useCliStoreContext();
|
|
69
|
-
return withOwnProvider(props, <ChatInner {...props} inProvider={!!ctx} />, !!ctx)
|
|
71
|
+
return <CliErrorBoundary component="cli-chat" theme={props.theme}>{withOwnProvider(props, <ChatInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
// 不能再发消息的结束原因(调用方关闭 / 取消)。其余原因(过期、换线、节点丢失)再发会自动开新对话。
|
|
@@ -76,6 +78,9 @@ const REASON_TEXT: Record<string, string> = {
|
|
|
76
78
|
node_reassigned: "执行机器换到了别的产品线", node_lost: "执行节点长时间连不上",
|
|
77
79
|
};
|
|
78
80
|
|
|
81
|
+
/** 子任务结束了的状态:可以接着它聊。 */
|
|
82
|
+
const SETTLED_TASK = new Set(["succeeded", "failed", "cancelled"]);
|
|
83
|
+
|
|
79
84
|
// 一次性任务(含续派)的子任务状态(center TaskStatus)。
|
|
80
85
|
const BATCH_STATUS: Record<string, { dotCls: DotCls; statusText: string }> = {
|
|
81
86
|
succeeded: { dotCls: "g", statusText: "子任务已完成" }, failed: { dotCls: "x", statusText: "子任务失败" }, cancelled: { dotCls: "x", statusText: "子任务已取消" },
|
|
@@ -170,8 +175,16 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
170
175
|
const running = conv ? isRunning(conv) : false;
|
|
171
176
|
const job = entry?.job;
|
|
172
177
|
// 一次性任务(含续派,不是对话任务):只读查看其中一个子任务——没有轮次,不能发消息、结束、回答。
|
|
178
|
+
// 例外是对话式子任务(续派时 mode=conversation):当对话用,随时能接着发(= 续派一条接着它),不显示子任务状态。
|
|
173
179
|
const batch = !!conv?.batch || (!!job && job.mode !== "conversation");
|
|
174
|
-
const
|
|
180
|
+
const chat = !!taskId && isChatTask(conv);
|
|
181
|
+
// 普通子任务结束后(完成 / 失败 / 取消)也能接着聊:发出去 = 续派一条接着它的对话式子任务(同一个 Agent 会话、同一个工作区)。
|
|
182
|
+
// 还在跑的时候输入框照样显示、但不能发,提示结束后再接着聊(需要登录、验证码时在看板的浏览器里接管)。
|
|
183
|
+
const batchTaskStatus = batch ? conv?.batchTask?.status ?? job?.tasks?.find((t) => t.id === (taskId ?? conv?.task?.id))?.status ?? null : null;
|
|
184
|
+
const batchSettled = batch && !chat && !!batchTaskStatus && SETTLED_TASK.has(batchTaskStatus) && !!store.client.continueTask;
|
|
185
|
+
const readOnly = !!readOnlyProp || !cfg.composer.show || (batch && !chat && !batchSettled);
|
|
186
|
+
// 一次性任务的子任务没结束、不能发时,输入框也露出来(禁用 + 说明),别让人以为没有输入框。
|
|
187
|
+
const showComposer = !readOnly || (batch && !readOnlyProp && cfg.composer.show);
|
|
175
188
|
// 这个对话对应的子任务:指定了就是它,否则对话任务的第一个(「新开」时借用第一个的智能体显示);
|
|
176
189
|
// 一次性任务没指定时是实时流挑的那个(最近一个派发过的)。
|
|
177
190
|
const shownTaskId = taskId ?? (batch ? conv?.task?.id : undefined);
|
|
@@ -180,9 +193,11 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
180
193
|
const agent = task?.agent ?? conv?.turns[conv.turns.length - 1]?.agent ?? null;
|
|
181
194
|
const agentText = labelOf(agent);
|
|
182
195
|
const jobStatus = conv?.job?.status ?? job?.status ?? null;
|
|
183
|
-
|
|
196
|
+
// 对话式子任务没有「结束」:大任务结束了,再发一条会重新打开它。
|
|
197
|
+
// 普通子任务结束后能接着聊的(batchSettled)同理:大任务结束不挡输入框。
|
|
198
|
+
const jobEnded = !chat && !batchSettled && !!jobStatus && TERMINAL_JOB.has(jobStatus);
|
|
184
199
|
// 只关掉了这一个对话(对话任务还开着):按「这个对话已结束」显示,不能再发消息。
|
|
185
|
-
const convClosed = !jobEnded && !!(conv?.task?.closed ?? info?.closed);
|
|
200
|
+
const convClosed = !jobEnded && !batchSettled && !!(conv?.task?.closed ?? info?.closed);
|
|
186
201
|
const status = convClosed ? "succeeded" : jobStatus;
|
|
187
202
|
const closedReason = convClosed ? "conversation_closed" : conv?.job?.closed_reason ?? job?.conversation?.closed_reason ?? null;
|
|
188
203
|
const ended = jobEnded || convClosed;
|
|
@@ -194,7 +209,8 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
194
209
|
const jobTitle = job?.title ?? conv?.job?.title;
|
|
195
210
|
// 对话任务里有几个对话时,标题是「对话任务 / 这个对话」。
|
|
196
211
|
const manyConvs = (batch ? job?.tasks?.length ?? 0 : job?.conversation?.conversations?.length ?? 0) > 1;
|
|
197
|
-
|
|
212
|
+
// 对话式子任务:流里的是这个对话最新的那条消息,名称取打开的那个子任务。
|
|
213
|
+
const convName = chat ? task?.title ?? conv?.task?.title : conv?.task?.title ?? info?.title ?? task?.title;
|
|
198
214
|
const convTitle = isNew ? `${jobTitle ? `${jobTitle} / ` : ""}新对话`
|
|
199
215
|
: (taskId || batch) && manyConvs && convName && convName !== jobTitle ? `${jobTitle ?? "对话"} / ${convName}`
|
|
200
216
|
: jobTitle ?? (key ? "对话" : "新对话");
|
|
@@ -213,13 +229,13 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
213
229
|
|
|
214
230
|
const { dotCls, statusText } = useMemo((): { dotCls: DotCls; statusText: string } => {
|
|
215
231
|
if (!key || isNew) return { dotCls: "r", statusText: "未开始" };
|
|
216
|
-
if (batch) return batchStatusView(batchStatus, jobStatus);
|
|
232
|
+
if (batch && !chat) return batchStatusView(batchStatus, jobStatus);
|
|
217
233
|
if (ended) return { dotCls: status === "succeeded" ? "g" : "x", statusText: REASON_TEXT[closedReason ?? ""] ?? (status === "succeeded" ? "已结束" : status === "cancelled" ? "已取消" : "已失败") };
|
|
218
234
|
if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
|
|
219
235
|
if (running) return { dotCls: "a", statusText: "运行中" };
|
|
220
236
|
if (status === "queued") return { dotCls: "a", statusText: "等待执行节点" };
|
|
221
237
|
return { dotCls: "a", statusText: "进行中" };
|
|
222
|
-
}, [key, isNew, batch, batchStatus, jobStatus, ended, status, closedReason, awaiting, running]);
|
|
238
|
+
}, [key, isNew, batch, chat, batchStatus, jobStatus, ended, status, closedReason, awaiting, running]);
|
|
223
239
|
|
|
224
240
|
// ── 处理器(引用稳定,MessageView 的 memo 才有效) ──
|
|
225
241
|
// 输入框之外的操作(回答提问、撤回排队、结束对话、停止)失败时在顶部提示,不静默吞掉。
|
|
@@ -235,16 +251,38 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
235
251
|
throw error;
|
|
236
252
|
}
|
|
237
253
|
}, []);
|
|
238
|
-
|
|
239
|
-
|
|
254
|
+
// 操作这次运行(回答提问、填浏览器输入卡片、交还浏览器)落在哪个子任务上:一次性任务没指定子任务时是正在看的那个
|
|
255
|
+
// (几个子任务同时跑时,卡片属于它,不是中枢缺省的那个)。和能不能发消息无关。
|
|
256
|
+
const runKey = batch && !taskId && conv?.task?.id && jobId ? convKey(jobId, conv.task.id) : key;
|
|
257
|
+
const latest = useRef({ key, messages, conv, runKey });
|
|
258
|
+
latest.current = { key, messages, conv, runKey };
|
|
240
259
|
// 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
|
|
241
260
|
const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
|
|
242
261
|
sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled };
|
|
243
262
|
const send = useCallback(async (text: string) => {
|
|
244
|
-
|
|
245
|
-
const
|
|
263
|
+
// 一次性任务:发给正在看的那个子任务(接着它聊)。
|
|
264
|
+
const id = latest.current.conv?.batch ? latest.current.runKey : latest.current.key;
|
|
265
|
+
// 看板里引用到这个对话的东西:合成一个「看板上下文」块跟着这条消息发出去;发失败就放回去。
|
|
266
|
+
// 看板上正在看的文件缺省也带上一次(已经明确引用了的不重复)。
|
|
267
|
+
const refKey = latest.current.key;
|
|
268
|
+
const refs = refKey ? store.takeRefs(refKey) : [];
|
|
269
|
+
const viewing = refKey ? store.takeViewing(refKey) : null;
|
|
270
|
+
const lines = [...(viewing && !refs.some((r) => r.id === viewing.id) ? [viewing.text] : []), ...refs.map((r) => r.text)];
|
|
271
|
+
const full = lines.length ? `${text}\n\n${serializeBlock(boardContextBlock(lines))}` : text;
|
|
272
|
+
let target: string;
|
|
273
|
+
try {
|
|
274
|
+
target = await store.send(id ?? null, full, sendOpts.current);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (refKey) { store.restoreRefs(refKey, refs); store.restoreViewing(refKey, viewing); }
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
246
279
|
if (target !== id) go(target);
|
|
247
280
|
}, [store, go]);
|
|
281
|
+
const refs = useCliState((s) => (key ? s.refs[key] : undefined));
|
|
282
|
+
const viewingState = useCliState((s) => (key ? s.viewing[key] : undefined));
|
|
283
|
+
const viewing = viewingState && !viewingState.off && !refs?.some((r) => r.id === viewingState.ref.id) ? viewingState.ref : null;
|
|
284
|
+
// 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
|
|
285
|
+
const contextItems = useCliState((s) => s.context[key ?? ""]);
|
|
248
286
|
// AI 把浏览器交给用户(实时新来的「🖐 …」行):同一 Provider 里有看板在看这个对话,就把看板切到浏览器(收起的会请宿主展开)。
|
|
249
287
|
// 打开对话时已有的交接行不算(先记下已有的再开始比较);没有看板时不弹窗打扰,行里有「查看浏览器」按钮。
|
|
250
288
|
const handoffSeen = useRef<{ key: string | null; ids: Set<string> | null }>({ key: null, ids: null });
|
|
@@ -271,24 +309,26 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
271
309
|
}, [popup]);
|
|
272
310
|
const handlers = useMemo<MessageHandlers>(() => ({
|
|
273
311
|
// 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
|
|
274
|
-
onAnswer: readOnly ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
|
|
312
|
+
onAnswer: readOnlyProp || (readOnly && !(batch && store.client.answerAsk)) ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
|
|
275
313
|
const id = latest.current.key;
|
|
276
314
|
if (!id) return;
|
|
315
|
+
// 一次性任务:提问时回合开着等,答案直接交给节点;不能改发消息。
|
|
316
|
+
if (batch && !chat) { await report(store.answerAsk(latest.current.runKey ?? id, askId, questions, answers, { fallback: false }).then(() => undefined), "回答没有送到"); return; }
|
|
277
317
|
const sent = askId
|
|
278
318
|
? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id) go(target); })
|
|
279
319
|
: send(formatAnswersText(questions, answers));
|
|
280
320
|
await report(sent, "回答没有发出去");
|
|
281
321
|
},
|
|
282
|
-
onCancelQueued: readOnly ? undefined : (m: Msg) => { const id = latest.current.key; if (id && m.turnId) void report(store.cancelTurn(id, m.turnId), "撤回失败").catch(() => undefined); },
|
|
322
|
+
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
323
|
// 后端能直接处理的浏览器动作(Operator 后端)才给;center 不提供,卡片按 Operator「没有浏览器能力」时的样子显示。
|
|
284
|
-
onInput:
|
|
285
|
-
const id = latest.current.
|
|
324
|
+
onInput: readOnlyProp || !store.client.submitInput ? undefined : async (inputId: string, values: string[]) => {
|
|
325
|
+
const id = latest.current.runKey;
|
|
286
326
|
if (!id) return "对话还没开始";
|
|
287
327
|
try { return await store.client.submitInput!(id, inputId, values); } catch (error) { return error instanceof Error ? error.message : String(error); }
|
|
288
328
|
},
|
|
289
329
|
// 交还浏览器:后端自己的 handback(Operator 后端),没有就走看板接口(center 转发到节点)。
|
|
290
|
-
onHandback:
|
|
291
|
-
const id = latest.current.
|
|
330
|
+
onHandback: readOnlyProp || !(store.client.handback || store.client.board) ? undefined : async () => {
|
|
331
|
+
const id = latest.current.runKey;
|
|
292
332
|
if (!id) return "对话还没开始";
|
|
293
333
|
try {
|
|
294
334
|
if (store.client.handback) return await store.client.handback(id);
|
|
@@ -307,7 +347,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
307
347
|
const at = list.findIndex((m) => m.id === msgId);
|
|
308
348
|
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
349
|
},
|
|
310
|
-
}), [readOnly, send, store, go, report, fileOpen]);
|
|
350
|
+
}), [readOnly, readOnlyProp, batch, chat, send, store, go, report, fileOpen]);
|
|
311
351
|
const stop = useCallback(async () => {
|
|
312
352
|
const { key: id, conv: c } = latest.current;
|
|
313
353
|
const turn = c ? runningTurn(c) : null;
|
|
@@ -334,7 +374,14 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
334
374
|
const [atBottom, setAtBottom] = useState(true);
|
|
335
375
|
const committed = useMemo(() => messages.filter((m) => m.role !== "queued"), [messages]);
|
|
336
376
|
const queued = useMemo(() => messages.filter((m) => m.role === "queued"), [messages]);
|
|
337
|
-
|
|
377
|
+
// 节点转录比手里更早、还没取的条数(实时流只给最新一段,往上翻时分页取,和 Operator 一样)。
|
|
378
|
+
const older = conv?.older ?? 0;
|
|
379
|
+
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
380
|
+
const olderRef = useRef(older); olderRef.current = older;
|
|
381
|
+
const keyRef2 = useRef(key); keyRef2.current = key;
|
|
382
|
+
const items = useMemo(() => buildTranscriptItems(committed, older > 0), [committed, older]);
|
|
383
|
+
// 展开了的「续等」折叠行(按折叠行的 key)。
|
|
384
|
+
const [openFolds, setOpenFolds] = useState<Set<string>>(() => new Set());
|
|
338
385
|
const [win, setWin] = useState<{ start: string | null; end: string | null }>({ start: null, end: null });
|
|
339
386
|
const { startIdx, endIdx } = useMemo(() => {
|
|
340
387
|
const find = (key: string | null) => (key ? items.findIndex((it) => it.key === key) : -1);
|
|
@@ -369,10 +416,17 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
369
416
|
}, []);
|
|
370
417
|
const revealEarlier = useCallback(() => {
|
|
371
418
|
const { startIdx: s, endIdx: e } = rangeRef.current;
|
|
372
|
-
if (s <= 0)
|
|
419
|
+
if (s <= 0) {
|
|
420
|
+
// 手里的都显示了,节点上还有更早的:取一页接在前面,再往上展开(窗口按原来的那一行定位,不跳)。
|
|
421
|
+
const k = keyRef2.current;
|
|
422
|
+
if (!k || olderRef.current <= 0) return;
|
|
423
|
+
setLoadingOlder(true);
|
|
424
|
+
void store.loadOlder(k).then((ok) => { if (ok) requestAnimationFrame(() => revealRef.current.earlier()); }).finally(() => setLoadingOlder(false));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
373
427
|
const ns = Math.max(0, s - RENDER_STEP);
|
|
374
428
|
setRange(ns, Math.min(e, ns + RENDER_MAX - 1), measure());
|
|
375
|
-
}, [measure, setRange]);
|
|
429
|
+
}, [measure, setRange, store]);
|
|
376
430
|
const revealLater = useCallback(() => {
|
|
377
431
|
const its = itemsRef.current;
|
|
378
432
|
const { startIdx: s, endIdx: e } = rangeRef.current;
|
|
@@ -400,7 +454,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
400
454
|
|
|
401
455
|
const topSentinelRef = useRef<HTMLDivElement>(null);
|
|
402
456
|
const bottomSentinelRef = useRef<HTMLDivElement>(null);
|
|
403
|
-
const hasAbove = hiddenAbove > 0;
|
|
457
|
+
const hasAbove = hiddenAbove > 0 || older > 0;
|
|
404
458
|
const hasBelow = hiddenBelow > 0;
|
|
405
459
|
useEffect(() => {
|
|
406
460
|
const root = scrollRef.current;
|
|
@@ -479,7 +533,13 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
479
533
|
fileUpload: cfg.composer.fileUpload, fileButtonText: cfg.composer.fileButtonText, queue: cfg.composer.queue,
|
|
480
534
|
}), [cfg]);
|
|
481
535
|
const composerDisabled = !!readOnly || finalEnd || conv?.stream === "error";
|
|
482
|
-
const disabledHint = readOnly
|
|
536
|
+
const disabledHint = readOnly
|
|
537
|
+
? batch && !readOnlyProp && cfg.composer.show
|
|
538
|
+
? (batchTaskStatus && SETTLED_TASK.has(batchTaskStatus) ? "只读(当前中枢不支持接着子任务聊)"
|
|
539
|
+
: batchTaskStatus === "blocked" || batchTaskStatus === "queued" ? "子任务还没开始,开始并结束后可以接着它聊"
|
|
540
|
+
: "子任务运行中,结束后可以接着它继续聊;需要登录等操作请在右侧浏览器里接管")
|
|
541
|
+
: "只读"
|
|
542
|
+
: finalEnd ? "对话已结束,不能再发消息" : conv?.streamError ?? undefined;
|
|
483
543
|
|
|
484
544
|
const fileUrlRef = useRef(fileUrl);
|
|
485
545
|
fileUrlRef.current = fileUrl;
|
|
@@ -537,7 +597,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
537
597
|
<StatusDot cls={dotCls} running={running} awaiting={awaiting} title={statusText} />
|
|
538
598
|
<span className="cv">{statusText}</span>
|
|
539
599
|
</span>}
|
|
540
|
-
{h.close && key && !isNew && allowClose && !readOnly && !ended && (
|
|
600
|
+
{h.close && key && !isNew && allowClose && !readOnly && !ended && !batch && (
|
|
541
601
|
<button className="btn btn-line btn-sm" onClick={() => setClosing("confirm")} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
|
|
542
602
|
)}
|
|
543
603
|
</div>
|
|
@@ -561,7 +621,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
561
621
|
{(!key || isNew) && cfg.empty.show && (
|
|
562
622
|
<div className="hero cli-hero">
|
|
563
623
|
<div className="h-ic">{Icon.bolt()}</div>
|
|
564
|
-
<div className="h-status"><StatusDot cls="r" /> 新对话 · 尚未开始</div>
|
|
624
|
+
{cfg.transcript.status && <div className="h-status"><StatusDot cls="r" /> 新对话 · 尚未开始</div>}
|
|
565
625
|
<h2>{cfg.empty.title}</h2>
|
|
566
626
|
{isNew && <p className="h-desc">在「{jobTitle ?? "这个任务"}」里新开一个对话:和任务里的其他对话共用同一个工作区(能看到彼此产出的文件),对话内容各自独立。</p>}
|
|
567
627
|
{cfg.empty.description && <p className="h-desc">{cfg.empty.description}</p>}
|
|
@@ -588,7 +648,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
588
648
|
)}
|
|
589
649
|
{hasAbove && (
|
|
590
650
|
<div ref={topSentinelRef} className="load-older">
|
|
591
|
-
<button className="btn btn-ghost btn-sm" onClick={revealEarlier}
|
|
651
|
+
<button className="btn btn-ghost btn-sm" onClick={revealEarlier} disabled={loadingOlder}>{loadingOlder ? "正在加载更早的消息…" : `加载更早的消息(还有 ${hiddenAbove + older} 条)`}</button>
|
|
592
652
|
</div>
|
|
593
653
|
)}
|
|
594
654
|
{shown.map((it) =>
|
|
@@ -596,6 +656,15 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
596
656
|
cfg.transcript.sessionLabel ? <div key={it.key} data-tkey={it.key} className="session-label"><span className="ln" />第 {it.n} 次会话{it.current ? " · 当前" : ""}<span className="ln" /></div> : null
|
|
597
657
|
) : it.kind === "break" ? (
|
|
598
658
|
<div key={it.key} data-tkey={it.key}><SessionBreak summary={it.summary} /></div>
|
|
659
|
+
) : it.kind === "fold" ? (
|
|
660
|
+
<div key={it.key} data-tkey={it.key} data-msg="">
|
|
661
|
+
<button className="cli-wait-fold" onClick={() => setOpenFolds((o) => { const n = new Set(o); if (n.has(it.key)) n.delete(it.key); else n.add(it.key); return n; })} aria-expanded={openFolds.has(it.key)}>
|
|
662
|
+
<span className="cli-wait-ic">⏳</span>
|
|
663
|
+
<span>等你在浏览器里操作 · AI 已续等 {it.waits} 次</span>
|
|
664
|
+
<span className="cli-wait-more">{openFolds.has(it.key) ? "收起" : "展开"}</span>
|
|
665
|
+
</button>
|
|
666
|
+
{openFolds.has(it.key) && it.msgs.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho running={running} agent={agent} agentLabel={agentText} handlers={handlers} view={view} />)}
|
|
667
|
+
</div>
|
|
599
668
|
) : (
|
|
600
669
|
<div key={it.key} data-tkey={it.key} data-msg="">
|
|
601
670
|
<MessageView m={it.m} initial={it.initial} hideWho={it.hideWho} running={running} agent={agent} agentLabel={agentText} handlers={handlers} view={view} />
|
|
@@ -610,7 +679,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
610
679
|
)}
|
|
611
680
|
{cfg.transcript.runningIndicator && running && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
|
|
612
681
|
{!hasBelow && queued.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho={false} handlers={handlers} view={view} />)}
|
|
613
|
-
{ended && (
|
|
682
|
+
{ended && cfg.transcript.status && (
|
|
614
683
|
<div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
|
|
615
684
|
)}
|
|
616
685
|
{continuedBy && (
|
|
@@ -624,7 +693,35 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
624
693
|
{!atBottom && <button className="msg-nav-btn" onClick={() => scrollToBottom()} title="跳到最新" aria-label="跳到最新">{Icon.toBottom()}</button>}
|
|
625
694
|
</div>}
|
|
626
695
|
</div>
|
|
627
|
-
{
|
|
696
|
+
{showComposer && !!contextItems?.length && (
|
|
697
|
+
<div className="cli-refs cli-ctxs" aria-label="补充背景,下一条消息会带上">
|
|
698
|
+
<span className="cli-refs-t">{Icon.flag()} 补充背景</span>
|
|
699
|
+
{contextItems.map((c) => (
|
|
700
|
+
<span key={c.id} className="cli-ref ctx" title={`${c.text.slice(0, 500)}${c.text.length > 500 ? "…" : ""}\n(下一条消息会带上)`}>
|
|
701
|
+
<span className="cli-ref-l">{c.title || c.text.replace(/\s+/g, " ").slice(0, 20)}</span>
|
|
702
|
+
</span>
|
|
703
|
+
))}
|
|
704
|
+
</div>
|
|
705
|
+
)}
|
|
706
|
+
{showComposer && (!!refs?.length || !!viewing) && (
|
|
707
|
+
<div className="cli-refs" aria-label="引用自看板,下一条消息会带上">
|
|
708
|
+
<span className="cli-refs-t">{Icon.board()} 引用自看板</span>
|
|
709
|
+
{viewing && (
|
|
710
|
+
<span className="cli-ref auto" title={`${viewing.text}\n(看板上正在看,下一条消息缺省带上;点 × 这次不带)`}>
|
|
711
|
+
<span className="cli-ref-k">正在看</span>
|
|
712
|
+
<span className="cli-ref-l">{viewing.label}</span>
|
|
713
|
+
<button className="cli-ref-x" onClick={() => key && store.dismissViewing(key)} title="这次不带上" aria-label={`不带上 ${viewing.label}`}>{Icon.x()}</button>
|
|
714
|
+
</span>
|
|
715
|
+
)}
|
|
716
|
+
{(refs ?? []).map((r) => (
|
|
717
|
+
<span key={r.id} className={`cli-ref ${r.kind === "selection" ? "sel" : ""}`} title={r.text}>
|
|
718
|
+
<span className="cli-ref-l">{r.label}</span>
|
|
719
|
+
<button className="cli-ref-x" onClick={() => key && store.removeRef(key, r.id)} title="不带上" aria-label={`不带上 ${r.label}`}>{Icon.x()}</button>
|
|
720
|
+
</span>
|
|
721
|
+
))}
|
|
722
|
+
</div>
|
|
723
|
+
)}
|
|
724
|
+
{showComposer && (
|
|
628
725
|
<Composer
|
|
629
726
|
key={key ?? "new"}
|
|
630
727
|
draft={key ?? `new:${instanceId}`}
|
package/src/core/blocks.ts
CHANGED
|
@@ -16,7 +16,26 @@ export interface TextBlock {
|
|
|
16
16
|
/** 显示名:文件名,粘贴的文字为「粘贴的文本」。 */
|
|
17
17
|
name: string;
|
|
18
18
|
text: string;
|
|
19
|
-
kind: "file" | "paste";
|
|
19
|
+
kind: "file" | "paste" | "context" | "background";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 看板引用合成的块的名字(转录里按它认出来,显示成「看板上下文」卡片)。 */
|
|
23
|
+
export const BOARD_CONTEXT_NAME = "看板上下文";
|
|
24
|
+
|
|
25
|
+
/** 看板引用 → 发给智能体的块。 */
|
|
26
|
+
export function boardContextBlock(lines: string[]): TextBlock {
|
|
27
|
+
return { name: BOARD_CONTEXT_NAME, kind: "context", text: ["用户在右侧看板里引用了下面这些,供这条消息参考:", ...lines.map((l) => `- ${l}`)].join("\n") };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 补充背景合成的块的名字(转录里显示成「补充背景」卡片)。 */
|
|
31
|
+
export const BACKGROUND_NAME = "补充背景";
|
|
32
|
+
|
|
33
|
+
/** 补充背景(store.appendContext)→ 发给智能体的块。每条有标题时写成「【标题】」。 */
|
|
34
|
+
export function backgroundBlock(items: Array<{ title?: string; text: string }>): TextBlock {
|
|
35
|
+
// 内容里碰巧有附件分隔行时错开一个空格,免得把块提前截断。
|
|
36
|
+
const safe = (t: string) => t.replace(/^(----- 附件)/gm, " $1");
|
|
37
|
+
const body = items.map((i) => (i.title ? `【${i.title}】\n${safe(i.text)}` : safe(i.text))).join("\n\n");
|
|
38
|
+
return { name: BACKGROUND_NAME, kind: "background", text: `以下是补充的项目背景信息,请在这次及之后的工作中一并参考:\n\n${body}` };
|
|
20
39
|
}
|
|
21
40
|
|
|
22
41
|
/** 支持的文件:只有 TXT、Markdown、Word(.docx)。 */
|
|
@@ -63,7 +82,7 @@ export function splitMessage(content: string): MessagePart[] {
|
|
|
63
82
|
if (!open || end === -1) { text.push(lines[i]); continue; }
|
|
64
83
|
flush();
|
|
65
84
|
const name = open[1];
|
|
66
|
-
parts.push({ kind: "block", block: { name, text: lines.slice(i + 1, end).join("\n"), kind: name === PASTE_NAME ? "paste" : "file" } });
|
|
85
|
+
parts.push({ kind: "block", block: { name, text: lines.slice(i + 1, end).join("\n"), kind: name === PASTE_NAME ? "paste" : name === BOARD_CONTEXT_NAME ? "context" : name === BACKGROUND_NAME ? "background" : "file" } });
|
|
67
86
|
i = end;
|
|
68
87
|
}
|
|
69
88
|
flush();
|