@spzxg/cli-ui 0.6.5 → 0.6.6
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/CHANGELOG.md +23 -0
- package/README.md +33 -13
- package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
- package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-b8wF7wrN.js} +2 -2
- package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
- package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
- package/dist/{cli-ui-xlsx-zs7oEtPj.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +9 -8
- package/src/board/BrowserPane.tsx +198 -53
- package/src/board/FileViewer.tsx +22 -9
- package/src/board/fileWatch.ts +9 -3
- package/src/board/frameQuote.ts +2 -2
- package/src/board/textLoad.ts +7 -5
- package/src/board/workspace.ts +15 -5
- package/src/chat/CliChat.tsx +21 -11
- package/src/chat/Composer.tsx +4 -2
- package/src/core/blocks.ts +15 -4
- package/src/core/client.ts +96 -14
- package/src/core/conversation.ts +1 -0
- package/src/core/provider.tsx +7 -0
- package/src/core/store.ts +68 -27
- package/src/core/types.ts +30 -9
- package/src/layout/split.ts +67 -30
- package/src/msglist/CliMsgList.tsx +9 -3
- package/src/operator/node/Transcript.tsx +7 -2
- package/src/operator/node/boardBackend.ts +6 -4
- package/src/operator/node/protocol/types.ts +2 -2
- package/src/operator/node/useTaskStream.ts +5 -4
- package/src/operator.base.css +8 -0
- package/src/operator.css +8 -0
- package/src/styles.css +29 -1
- package/src/ui/Popover.tsx +10 -5
- package/src/ui/Transcript.tsx +7 -2
- package/dist/cli-ui-embed-BmLN2Jjg.js +0 -43290
package/src/board/workspace.ts
CHANGED
|
@@ -62,7 +62,7 @@ export interface CliWorkspace {
|
|
|
62
62
|
/** 列 dir 下所有文件(递归,只含文件)。 */
|
|
63
63
|
listAll(dir?: string, opts?: { signal?: AbortSignal }): Promise<FileEntry[]>;
|
|
64
64
|
/** 读成文字(GBK 导出的文件会自动转码)。 */
|
|
65
|
-
readText(path: string): Promise<string>;
|
|
65
|
+
readText(path: string, opts?: { signal?: AbortSignal }): Promise<string>;
|
|
66
66
|
/** 读成 Blob。onProgress 报已收到的字节数;超过 maxBytes(缺省 500 MB)报错,改用 fileUrl / download。 */
|
|
67
67
|
readFile(path: string, opts?: { signal?: AbortSignal; onProgress?: (bytes: number) => void; maxBytes?: number }): Promise<Blob>;
|
|
68
68
|
/** 浏览器能直接打开 / 下载的地址(没有同源代理时是 30 分钟有效的短期链接);接入方式不支持时为 null,改用 readFile。 */
|
|
@@ -110,10 +110,20 @@ export function createWorkspace(deps: {
|
|
|
110
110
|
const files = await listFolder(backend(), key, clean(dir), { signal: opts.signal, maxFiles: WORKSPACE_LIST_MAX, maxBytes: Infinity });
|
|
111
111
|
return files.map((f) => ({ name: f.path.split("/").pop() || f.path, path: f.path, type: "file" as const, size: f.size, mtime: f.mtime }));
|
|
112
112
|
},
|
|
113
|
-
async readText(path) {
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
|
|
113
|
+
async readText(path, opts = {}) {
|
|
114
|
+
const signal = opts.signal;
|
|
115
|
+
if (signal?.aborted) throw new DOMException("已取消", "AbortError");
|
|
116
|
+
let rejectAbort: (() => void) | undefined;
|
|
117
|
+
try {
|
|
118
|
+
const reading = signal ? backend().readText(key, clean(path), WORKSPACE_TEXT_MAX, { signal }) : backend().readText(key, clean(path), WORKSPACE_TEXT_MAX);
|
|
119
|
+
const r = signal ? await Promise.race([reading, new Promise<never>((_resolve, reject) => {
|
|
120
|
+
rejectAbort = () => reject(new DOMException("已取消", "AbortError"));
|
|
121
|
+
signal.addEventListener("abort", rejectAbort, { once: true });
|
|
122
|
+
if (signal.aborted) rejectAbort();
|
|
123
|
+
})]) : await reading;
|
|
124
|
+
if (r.truncated) throw new Error(`文件较大(超过 ${WORKSPACE_TEXT_MAX / 1024 / 1024} MB),请用 readFile 读成 Blob`);
|
|
125
|
+
return r.text;
|
|
126
|
+
} finally { if (rejectAbort) signal?.removeEventListener("abort", rejectAbort); }
|
|
117
127
|
},
|
|
118
128
|
async readFile(path, opts = {}) {
|
|
119
129
|
const p = clean(path);
|
package/src/chat/CliChat.tsx
CHANGED
|
@@ -169,7 +169,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
169
169
|
useEffect(() => (key ? store.open(key) : undefined), [store, key]);
|
|
170
170
|
// 正在看的对话被接续成了新 job(别处发消息触发的也算):跟过去。
|
|
171
171
|
const keyRef = useRef(key);
|
|
172
|
-
keyRef.current = key;
|
|
172
|
+
useLayoutEffect(() => { keyRef.current = key; }, [key]);
|
|
173
173
|
useEffect(() => store.on((event) => {
|
|
174
174
|
if (event.type === "job_change" && event.previousKey && event.previousKey === keyRef.current) go(event.key);
|
|
175
175
|
}), [store, go]);
|
|
@@ -235,7 +235,13 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
235
235
|
: jobTitle ?? (key ? "对话" : "新对话");
|
|
236
236
|
const awaitingAnswer = useMemo(() => hasOpenAsk(messages), [messages]);
|
|
237
237
|
const lastTurn = conv?.turns[conv.turns.length - 1];
|
|
238
|
-
const
|
|
238
|
+
const listedAwaiting = (info ?? job?.conversation?.conversations?.find(item => item.task_id === task?.id))?.awaiting_input;
|
|
239
|
+
// An explicit null retires an old wait flag when its node observation expires.
|
|
240
|
+
const remoteAwaiting = conv?.task?.awaiting_input !== undefined ? conv.task.awaiting_input
|
|
241
|
+
: listedAwaiting !== undefined ? listedAwaiting : task?.awaiting_input !== undefined ? task.awaiting_input
|
|
242
|
+
: !manyConvs ? job?.conversation?.awaiting_input : undefined;
|
|
243
|
+
const awaiting = !ended && !TERMINAL_JOB.has(jobStatus ?? "") && !(batch && batchTaskStatus && SETTLED_TASK.has(batchTaskStatus))
|
|
244
|
+
&& (remoteAwaiting === true || (!running && !!lastTurn?.asked && lastTurn.status === "succeeded"));
|
|
239
245
|
// 一次性任务的子任务还没派发(没有节点对话)、或者流已经结束还没有转录:不再转圈,转录里有说明。
|
|
240
246
|
const loading = !!key && !isNew && !!conv && !conv.hasTranscript && !conv.turns.length && conv.stream !== "error" && conv.stream !== "ended" && !(conv.batch && !conv.remoteTaskId);
|
|
241
247
|
const batchStatus = batch ? conv?.batchTask?.status ?? task?.status ?? null : null;
|
|
@@ -248,9 +254,9 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
248
254
|
|
|
249
255
|
const { dotCls, statusText } = useMemo((): { dotCls: DotCls; statusText: string } => {
|
|
250
256
|
if (!key || isNew) return { dotCls: "r", statusText: "未开始" };
|
|
257
|
+
if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
|
|
251
258
|
if (batch && !chat) return batchStatusView(batchStatus, jobStatus);
|
|
252
259
|
if (ended) return { dotCls: status === "succeeded" ? "g" : "x", statusText: REASON_TEXT[closedReason ?? ""] ?? (status === "succeeded" ? "已结束" : status === "cancelled" ? "已取消" : "已失败") };
|
|
253
|
-
if (awaiting) return { dotCls: "a", statusText: "需要你处理" };
|
|
254
260
|
if (running) return { dotCls: "a", statusText: "运行中" };
|
|
255
261
|
if (status === "queued") return { dotCls: "a", statusText: "等待执行节点" };
|
|
256
262
|
return { dotCls: "a", statusText: "进行中" };
|
|
@@ -261,12 +267,13 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
261
267
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
262
268
|
useEffect(() => setActionError(null), [key]);
|
|
263
269
|
const report = useCallback(async <T,>(work: Promise<T>, what: string) => {
|
|
270
|
+
const sourceKey = keyRef.current;
|
|
264
271
|
try {
|
|
265
272
|
const result = await work;
|
|
266
|
-
setActionError(null);
|
|
273
|
+
if (keyRef.current === sourceKey) setActionError(null);
|
|
267
274
|
return result;
|
|
268
275
|
} catch (error) {
|
|
269
|
-
setActionError(`${what}:${error instanceof Error ? error.message : String(error)}`);
|
|
276
|
+
if (keyRef.current === sourceKey) setActionError(`${what}:${error instanceof Error ? error.message : String(error)}`);
|
|
270
277
|
throw error;
|
|
271
278
|
}
|
|
272
279
|
}, []);
|
|
@@ -274,12 +281,15 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
274
281
|
// (几个子任务同时跑时,卡片属于它,不是中枢缺省的那个)。和能不能发消息无关。
|
|
275
282
|
const runKey = batch && !taskId && conv?.task?.id && jobId ? convKey(jobId, conv.task.id) : key;
|
|
276
283
|
const latest = useRef({ key, messages, conv, runKey });
|
|
277
|
-
|
|
284
|
+
// A concurrent render may suspend while the old chat remains interactive.
|
|
285
|
+
// Event handlers must keep the target and options of the committed view.
|
|
286
|
+
useLayoutEffect(() => { latest.current = { key, messages, conv, runKey }; });
|
|
278
287
|
// 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
|
|
279
288
|
const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
|
|
280
|
-
sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled };
|
|
289
|
+
useLayoutEffect(() => { sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled }; });
|
|
281
290
|
const [skipPassive,setSkipPassive]=useState(false);
|
|
282
|
-
const skipPassiveRef=useRef(false);
|
|
291
|
+
const skipPassiveRef=useRef(false);
|
|
292
|
+
useLayoutEffect(()=>{skipPassiveRef.current=skipPassive;},[skipPassive]);
|
|
283
293
|
useEffect(()=>setSkipPassive(false),[key]);
|
|
284
294
|
const send = useCallback(async (text: string) => {
|
|
285
295
|
// 一次性任务:发给正在看的那个子任务(接着它聊)。
|
|
@@ -306,7 +316,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
306
316
|
throw error;
|
|
307
317
|
}
|
|
308
318
|
if(latest.current.key===refKey)setSkipPassive(false);
|
|
309
|
-
if (target !== id) go(target);
|
|
319
|
+
if (target !== id && latest.current.key === refKey) go(target);
|
|
310
320
|
}, [store, go, passiveContext]);
|
|
311
321
|
const passiveView=useCliState(state=>key?state.boardContext[key]:undefined);
|
|
312
322
|
const passiveText=passiveContext&&passiveView?formatBoardContext(passiveView,key??undefined):'';
|
|
@@ -351,7 +361,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
351
361
|
// 一次性任务:提问时回合开着等,答案直接交给节点;不能改发消息。
|
|
352
362
|
if (batch && !chat) { await report(store.answerAsk(latest.current.runKey ?? id, askId, questions, answers, { fallback: false }).then(() => undefined), "回答没有送到"); return; }
|
|
353
363
|
const sent = askId
|
|
354
|
-
? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id) go(target); })
|
|
364
|
+
? store.answerAsk(id, askId, questions, answers, sendOpts.current).then((target) => { if (target !== id && latest.current.key === id) go(target); })
|
|
355
365
|
: send(formatAnswersText(questions, answers));
|
|
356
366
|
await report(sent, "回答没有发出去");
|
|
357
367
|
},
|
|
@@ -726,7 +736,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
726
736
|
<button className="btn btn-ghost btn-sm" onClick={() => scrollToBottom("auto")}>跳到最新</button>
|
|
727
737
|
</div>
|
|
728
738
|
)}
|
|
729
|
-
{cfg.transcript.runningIndicator && running && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
|
|
739
|
+
{cfg.transcript.runningIndicator && running && !awaiting && !awaitingAnswer && !hasBelow && <RunningIndicator agent={agent} agentName={cfg.labels.agent} startedAt={turnStartedAt} lastActivityRef={lastActivityRef} />}
|
|
730
740
|
{!hasBelow && queued.map((m) => <MessageView key={m.id} m={m} initial={false} hideWho={false} handlers={handlers} view={view} />)}
|
|
731
741
|
{ended && cfg.transcript.status && (
|
|
732
742
|
<div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
|
package/src/chat/Composer.tsx
CHANGED
|
@@ -67,7 +67,9 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
67
67
|
readFileText(file)
|
|
68
68
|
.then((text) => {
|
|
69
69
|
if (!text.trim()) throw new Error(`「${file.name}」里没有可读的文字`);
|
|
70
|
-
updateComposerDraft(draft, s =>
|
|
70
|
+
updateComposerDraft(draft, s => s.pending.some(p => p.key === key && p.status === "reading")
|
|
71
|
+
? { ...s, pending: s.pending.filter(p => p.key !== key), blocks: [...s.blocks, { key, name: file.name, text, kind: "file" }] }
|
|
72
|
+
: s);
|
|
71
73
|
})
|
|
72
74
|
.catch((err: unknown) => {
|
|
73
75
|
setPending((prev) => prev.map((p) => (p.key === key ? { ...p, status: "error", error: err instanceof Error ? err.message : String(err) } : p)));
|
|
@@ -142,7 +144,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
142
144
|
<div key={p.key} className={`attach-chip file ${p.status}`} title={p.error || p.name}>
|
|
143
145
|
<span className="attach-file">{Icon.doc()} {p.name}</span>
|
|
144
146
|
<span className={`attach-badge ${p.status === "error" ? "err" : ""}`}>{p.status === "reading" ? "解析中…" : p.error}</span>
|
|
145
|
-
|
|
147
|
+
<button type="button" className="attach-x" title={p.status === "reading" ? "取消添加,忽略尚未完成的解析结果" : "移除"} aria-label={`移除 ${p.name}`} onClick={() => setPending((prev) => prev.filter((x) => x.key !== p.key))}>×</button>
|
|
146
148
|
</div>
|
|
147
149
|
))}
|
|
148
150
|
</div>
|
package/src/core/blocks.ts
CHANGED
|
@@ -48,16 +48,16 @@ export function backgroundBlock(items: Array<{ title?: string; text: string }>):
|
|
|
48
48
|
return { name: BACKGROUND_NAME, kind: "background", text: `以下是补充的项目背景信息,请在这次及之后的工作中一并参考:\n\n${body}` };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** 支持的文件:只收文档——Word(.docx)、Excel(.xlsx / .xlsm)、Markdown、TXT、HTML、CSV / TSV。都在浏览器里转成文字。 */
|
|
52
|
-
export const ACCEPTED_EXTENSIONS = [".docx", ".xlsx", ".xlsm", ".md", ".markdown", ".txt", ".html", ".htm", ".csv", ".tsv"] as const;
|
|
51
|
+
/** 支持的文件:只收文档——Word(.docx)、Excel(.xlsx / .xlsm)、Markdown、TXT、YAML、HTML、CSV / TSV。都在浏览器里转成文字。 */
|
|
52
|
+
export const ACCEPTED_EXTENSIONS = [".docx", ".xlsx", ".xlsm", ".md", ".markdown", ".txt", ".yaml", ".yml", ".html", ".htm", ".csv", ".tsv"] as const;
|
|
53
53
|
export const ACCEPT_ATTR = [
|
|
54
54
|
...ACCEPTED_EXTENSIONS,
|
|
55
|
-
"text/plain", "text/markdown", "text/html", "text/csv", "text/tab-separated-values",
|
|
55
|
+
"text/plain", "text/markdown", "text/yaml", "application/yaml", "text/html", "text/csv", "text/tab-separated-values",
|
|
56
56
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
57
57
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-excel.sheet.macroEnabled.12",
|
|
58
58
|
].join(",");
|
|
59
59
|
/** 不支持时提示里列的格式。 */
|
|
60
|
-
export const ACCEPTED_LABEL = "Word(.docx)、Excel(.xlsx)、Markdown、TXT、HTML、CSV";
|
|
60
|
+
export const ACCEPTED_LABEL = "Word(.docx)、Excel(.xlsx)、Markdown、TXT、YAML(.yaml / .yml)、HTML、CSV";
|
|
61
61
|
/** 一些常见但读不了的文档:给出另存为什么。 */
|
|
62
62
|
const SAVE_AS: Record<string, string> = {
|
|
63
63
|
".doc": "老版 Word(.doc)请另存为 .docx 后再上传",
|
|
@@ -72,6 +72,8 @@ export const PASTE_BLOCK_CHARS = 2_000;
|
|
|
72
72
|
export const PASTE_BLOCK_LINES = 40;
|
|
73
73
|
/** center 单条消息上限(POST /api/jobs/:id/messages 的 text 最长 200,000 字符)。 */
|
|
74
74
|
export const MAX_MESSAGE_CHARS = 200_000;
|
|
75
|
+
/** Same source-file budget as the full node composer; reject before reading/parsing. */
|
|
76
|
+
export const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
|
|
75
77
|
export const PASTE_NAME = "粘贴的文本";
|
|
76
78
|
|
|
77
79
|
const OPEN = /^----- 附件:(.+?)([\d,]+ 字)-----$/;
|
|
@@ -138,6 +140,13 @@ export class FileReadError extends Error {}
|
|
|
138
140
|
* 其他文件(图片、PDF、压缩包……)不收;老版 .doc / .xls 浏览器里读不了,提示另存为。
|
|
139
141
|
*/
|
|
140
142
|
export async function readFileText(file: File): Promise<string> {
|
|
143
|
+
if (file.size > MAX_DOCUMENT_BYTES) throw new FileReadError(`“${file.name}”超过 10 MB,请缩小后重试。`);
|
|
144
|
+
const text = await extractFileText(file);
|
|
145
|
+
if (text.length > MAX_MESSAGE_CHARS) throw new FileReadError(`“${file.name}”解析后的正文超过 ${MAX_MESSAGE_CHARS.toLocaleString("en-US")} 字符,请拆分后重试。`);
|
|
146
|
+
return text;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function extractFileText(file: File): Promise<string> {
|
|
141
150
|
const ext = extensionOf(file.name);
|
|
142
151
|
if (SAVE_AS[ext]) throw new FileReadError(`不支持这种格式:${SAVE_AS[ext]}`);
|
|
143
152
|
if (!(ACCEPTED_EXTENSIONS as readonly string[]).includes(ext)) throw new FileReadError(`「${file.name}」不是支持的文档,只能上传 ${ACCEPTED_LABEL}`);
|
|
@@ -165,6 +174,8 @@ export async function readFileText(file: File): Promise<string> {
|
|
|
165
174
|
}
|
|
166
175
|
}
|
|
167
176
|
const { text } = decodeText(new Uint8Array(await readAs(file, "buffer")));
|
|
177
|
+
// YAML literal scalars depend on blank lines, indentation and the final newline.
|
|
178
|
+
if (ext === ".yaml" || ext === ".yml") return text;
|
|
168
179
|
if (ext === ".html" || ext === ".htm") return normalizeText(text.replace(INLINE_DATA, (m) => `${m.slice(0, m.indexOf(",") + 1)}…(内嵌数据已省略)`));
|
|
169
180
|
return normalizeText(text);
|
|
170
181
|
}
|
package/src/core/client.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { createSseParser } from "./sse";
|
|
|
2
2
|
import { NEW_TASK, parseConvKey } from "./conversation";
|
|
3
3
|
import type {
|
|
4
4
|
BoardInfo, BrowserMessage, BrowserStatus, CenterJob, CenterTask, CenterTurn, DiffResult, DirListing, FileInfo, Message,
|
|
5
|
-
BatchStreamSnapshot, BrowserFramesState, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot,
|
|
5
|
+
BatchStreamSnapshot, BrowserFramesState, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot, WorkspaceFileBatch,
|
|
6
6
|
} from "./types";
|
|
7
7
|
import { decodeText } from "./textDecode";
|
|
8
8
|
|
|
@@ -78,6 +78,11 @@ const ERROR_TEXT: Record<string, string> = {
|
|
|
78
78
|
task_already_finished: "当前子任务已经结束,请刷新状态后继续",
|
|
79
79
|
task_not_paused: "当前子任务没有暂停,请刷新状态后重试",
|
|
80
80
|
job_already_paused: "整个任务已暂停,请先恢复整个任务",
|
|
81
|
+
file_watch_unsupported: "当前服务不支持文件事件",
|
|
82
|
+
browser_required: "传递浏览器 YAML 手册或操作间隔时,需要同时启用 browser: true",
|
|
83
|
+
invalid_browser_playbook: "浏览器 YAML 手册校验失败,请检查格式和步骤配置",
|
|
84
|
+
invalid_browser_pacing: "浏览器操作间隔无效,请检查最小值和最大值",
|
|
85
|
+
conversation_browser_fields_fixed: "浏览器手册和操作间隔在创建对话时确定,追加消息不能修改",
|
|
81
86
|
};
|
|
82
87
|
|
|
83
88
|
/**
|
|
@@ -158,12 +163,12 @@ export interface BoardBackend {
|
|
|
158
163
|
* tail = 读末尾那一段(超大日志);节点不支持只读一段时退回开头(返回的 tail 为 false)。
|
|
159
164
|
* 不是 UTF-8 的(GBK 导出的 CSV 之类)按 GB18030 解,encoding 说明按什么解的。
|
|
160
165
|
*/
|
|
161
|
-
readText(jobId: string, path: string, maxBytes?: number, opts?: { tail?: boolean; onBytes?: (bytes: number) => void }): Promise<TextChunk>;
|
|
166
|
+
readText(jobId: string, path: string, maxBytes?: number, opts?: { tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }): Promise<TextChunk>;
|
|
162
167
|
/**
|
|
163
168
|
* 读一段原始字节:从 start 开始(缺省 0)最多 maxBytes;tail = 末尾 maxBytes。返回这段在文件里的起点和文件总大小。
|
|
164
169
|
* 看板用它在文件变长时只取新增的部分(日志、还在写的报告),不整个重读。没有这个方法的后端每次整个重读。
|
|
165
170
|
*/
|
|
166
|
-
readBytes?(jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; onBytes?: (bytes: number) => void }): Promise<ByteChunk>;
|
|
171
|
+
readBytes?(jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }): Promise<ByteChunk>;
|
|
167
172
|
/**
|
|
168
173
|
* 读整个文件。signal 取消(打包下载点了取消);onBytes 报已收到的字节数(大文件显示进度)。
|
|
169
174
|
* 弱网:FILE_HEADER_TIMEOUT_MS 内没回响应头、或传输中 FILE_STALL_MS 没收到数据,按网络错误(NETWORK)失败。
|
|
@@ -325,7 +330,9 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
325
330
|
...(opts.businessId ? { business_id: opts.businessId } : {}),
|
|
326
331
|
...(opts.agent ? { agent: opts.agent } : {}),
|
|
327
332
|
...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
|
|
328
|
-
...(opts.browser ? { browser:
|
|
333
|
+
...(opts.browser !== undefined ? { browser: opts.browser } : {}),
|
|
334
|
+
...(opts.browserPlaybook !== undefined ? { browser_playbook: opts.browserPlaybook } : {}),
|
|
335
|
+
...(opts.browserPacing !== undefined ? { browser_pacing: opts.browserPacing } : {}),
|
|
329
336
|
}],
|
|
330
337
|
});
|
|
331
338
|
},
|
|
@@ -465,6 +472,13 @@ function openSse(
|
|
|
465
472
|
try {
|
|
466
473
|
arm();
|
|
467
474
|
const response = await doFetch(url, { headers: { ...headers, Accept: "text/event-stream" }, signal: ctl.signal });
|
|
475
|
+
// A host-supplied fetch may resolve after cancellation despite signal.
|
|
476
|
+
// Retire its body before acquiring a reader or restarting the watchdog.
|
|
477
|
+
if (ctl.signal.aborted) {
|
|
478
|
+
void response.body?.cancel().catch(() => undefined);
|
|
479
|
+
if (silent) onClose?.(new CenterError("连接长时间没有数据,已断开重连", "NETWORK", 0));
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
468
482
|
if (!response.ok || !response.body) {
|
|
469
483
|
const body = (await response.json().catch(() => null)) as { error?: string; detail?: string } | null;
|
|
470
484
|
throw new CenterError(body?.detail ?? ERROR_TEXT[body?.error ?? ""] ?? `连接失败(HTTP ${response.status})`, body?.error ?? `HTTP_${response.status}`, response.status);
|
|
@@ -520,6 +534,31 @@ function boardPath(key: string, rest: string, query = "") {
|
|
|
520
534
|
return `/api/jobs/${job}/${rest}${params ? `?${params}` : ""}`;
|
|
521
535
|
}
|
|
522
536
|
|
|
537
|
+
/** Reject a malformed manifest as a whole: silently dropping entries could invent deletions. */
|
|
538
|
+
function isWorkspaceFileBatch(value: unknown): value is WorkspaceFileBatch {
|
|
539
|
+
if (!value || typeof value !== "object") return false;
|
|
540
|
+
const batch = value as Record<string, unknown>;
|
|
541
|
+
if (typeof batch.complete !== "boolean" || (batch.revision !== undefined && typeof batch.revision !== "string")) return false;
|
|
542
|
+
const file = (value: unknown) => {
|
|
543
|
+
if (!value || typeof value !== "object") return false;
|
|
544
|
+
const entry = value as Record<string, unknown>;
|
|
545
|
+
return typeof entry.name === "string" && typeof entry.path === "string" && !!entry.path
|
|
546
|
+
&& (entry.type === "file" || entry.type === "dir")
|
|
547
|
+
&& typeof entry.size === "number" && Number.isFinite(entry.size) && entry.size >= 0
|
|
548
|
+
&& typeof entry.mtime === "number" && Number.isFinite(entry.mtime)
|
|
549
|
+
&& (entry.changeToken === undefined || typeof entry.changeToken === "string");
|
|
550
|
+
};
|
|
551
|
+
if (batch.type === "snapshot") return Array.isArray(batch.files) && batch.files.every(file);
|
|
552
|
+
if (batch.type === "resync") return batch.complete === false && typeof batch.reason === "string";
|
|
553
|
+
return batch.type === "changes" && Array.isArray(batch.changes) && batch.changes.every(value => {
|
|
554
|
+
if (!value || typeof value !== "object") return false;
|
|
555
|
+
const change = value as Record<string, unknown>;
|
|
556
|
+
return ["created", "modified", "deleted"].includes(String(change.kind)) && typeof change.path === "string" && !!change.path
|
|
557
|
+
&& (change.file === undefined || (file(change.file) && (change.file as { path: string }).path === change.path))
|
|
558
|
+
&& (change.previous === undefined || (file(change.previous) && (change.previous as { path: string }).path === change.path));
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
523
562
|
function createBoardClient(
|
|
524
563
|
base: string,
|
|
525
564
|
headers: (extra?: Record<string, string>) => Record<string, string>,
|
|
@@ -548,7 +587,11 @@ function createBoardClient(
|
|
|
548
587
|
if (ctl.signal.aborted) reject(new DOMException("aborted", "AbortError"));
|
|
549
588
|
ctl.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
|
|
550
589
|
});
|
|
551
|
-
|
|
590
|
+
const fetching = doFetch(boardFileUrl(base, jobId, path), { headers: headers(extra), signal: ctl.signal, cache: "no-store" });
|
|
591
|
+
// Host fetch adapters can ignore abort. Dispose a response that arrives
|
|
592
|
+
// after the caller already cancelled or timed out.
|
|
593
|
+
void fetching.then(late => { if (ctl.signal.aborted) void late.body?.cancel().catch(() => undefined); }, () => undefined);
|
|
594
|
+
response = await Promise.race([fetching, stopped]);
|
|
552
595
|
} catch (error) {
|
|
553
596
|
release();
|
|
554
597
|
if (signal?.aborted) throw aborted();
|
|
@@ -556,21 +599,19 @@ function createBoardClient(
|
|
|
556
599
|
throw new CenterError(`连不上任务中心:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
|
|
557
600
|
}
|
|
558
601
|
clearTimeout(timer);
|
|
559
|
-
if (!response.ok && response.status !== 416) {
|
|
560
|
-
release();
|
|
561
|
-
const body = (await response.json().catch(() => null)) as { error?: string; detail?: string } | null;
|
|
562
|
-
const fallback = response.status === 404 ? "文件不存在(可能已被任务删除或改名)" : body?.error ?? `读取失败(HTTP ${response.status})`;
|
|
563
|
-
throw new CenterError(body?.detail ?? ERROR_TEXT[body?.error ?? ""] ?? fallback, body?.error ?? `HTTP_${response.status}`, response.status);
|
|
564
|
-
}
|
|
565
602
|
const stallError = () => new CenterError(`读取「${name}」时 ${FILE_STALL_MS / 1000} 秒没有收到数据,传输已中断,请重试`, "NETWORK", 0);
|
|
566
603
|
/** 读响应体:limit 读够就停;onBytes 报进度;FILE_STALL_MS 没数据当断线。 */
|
|
567
604
|
const body = async (opts: { limit?: number; onBytes?: (bytes: number) => void } = {}): Promise<Uint8Array<ArrayBuffer>> => {
|
|
605
|
+
if (signal?.aborted) { release(); void response.body?.cancel().catch(() => undefined); throw aborted(); }
|
|
568
606
|
// 空文件要「前 N 字节」:节点回 416,就是空的。
|
|
569
607
|
if (response.status === 416) { release(); return new Uint8Array(0); }
|
|
570
608
|
const reader = response.body?.getReader();
|
|
571
609
|
if (!reader) {
|
|
572
610
|
try { return new Uint8Array(await response.arrayBuffer()); } finally { release(); }
|
|
573
611
|
}
|
|
612
|
+
const cancelBody = () => { void reader.cancel().catch(() => undefined); };
|
|
613
|
+
ctl.signal.addEventListener("abort", cancelBody, { once: true });
|
|
614
|
+
if (ctl.signal.aborted) cancelBody();
|
|
574
615
|
const chunks: Uint8Array[] = [];
|
|
575
616
|
let got = 0;
|
|
576
617
|
let stalled = false;
|
|
@@ -598,6 +639,11 @@ function createBoardClient(
|
|
|
598
639
|
throw new CenterError(`读取「${name}」中断:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
|
|
599
640
|
} finally {
|
|
600
641
|
clearTimeout(stall);
|
|
642
|
+
ctl.signal.removeEventListener("abort", cancelBody);
|
|
643
|
+
// Progress callbacks may throw too; no abandoned response should keep
|
|
644
|
+
// consuming the mobile connection after the read has failed.
|
|
645
|
+
void reader.cancel().catch(() => undefined);
|
|
646
|
+
try { reader.releaseLock(); } catch { /* already released */ }
|
|
601
647
|
release();
|
|
602
648
|
}
|
|
603
649
|
if (signal?.aborted) throw aborted();
|
|
@@ -612,16 +658,25 @@ function createBoardClient(
|
|
|
612
658
|
}
|
|
613
659
|
return all;
|
|
614
660
|
};
|
|
661
|
+
if (!response.ok && response.status !== 416) {
|
|
662
|
+
// Error bodies need the same abort/stall bounds as file data. A proxy
|
|
663
|
+
// returning 401 headers and then hanging must not park a read forever.
|
|
664
|
+
const bytes = await body({ limit: 8192 });
|
|
665
|
+
let problem: { error?: string; detail?: string } | null = null;
|
|
666
|
+
try { problem = JSON.parse(new TextDecoder().decode(bytes)); } catch { /* use the HTTP fallback */ }
|
|
667
|
+
const fallback = response.status === 404 ? "文件不存在(可能已被任务删除或改名)" : problem?.error ?? `读取失败(HTTP ${response.status})`;
|
|
668
|
+
throw new CenterError(problem?.detail ?? ERROR_TEXT[problem?.error ?? ""] ?? fallback, problem?.error ?? `HTTP_${response.status}`, response.status);
|
|
669
|
+
}
|
|
615
670
|
return { response, body };
|
|
616
671
|
};
|
|
617
672
|
/**
|
|
618
673
|
* 读一段:开头 / 从 start 起 maxBytes,或者末尾 maxBytes(超大日志)。只向中枢要这一段(Range);
|
|
619
674
|
* 不支持 Range 的节点回整个文件(200),这里边读边截,大文件不整个拉进浏览器。
|
|
620
675
|
*/
|
|
621
|
-
const readRange = async (jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; onBytes?: (bytes: number) => void }) => {
|
|
676
|
+
const readRange = async (jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }) => {
|
|
622
677
|
const from = Math.max(0, opts.start ?? 0);
|
|
623
678
|
const { maxBytes } = opts;
|
|
624
|
-
const { response, body } = await fileFetch(jobId, path, { Range: opts.tail ? `bytes=-${maxBytes}` : `bytes=${from}-${from + maxBytes - 1}` });
|
|
679
|
+
const { response, body } = await fileFetch(jobId, path, { Range: opts.tail ? `bytes=-${maxBytes}` : `bytes=${from}-${from + maxBytes - 1}` }, opts.signal);
|
|
625
680
|
const contentType = response.headers.get("content-type") ?? "text/plain";
|
|
626
681
|
const total = /\/(\d+)\s*$/.exec(response.headers.get("content-range") ?? "")?.[1];
|
|
627
682
|
// 416:要的起点不在文件里(空文件,或文件变短了)。
|
|
@@ -646,6 +701,33 @@ function createBoardClient(
|
|
|
646
701
|
return {
|
|
647
702
|
directLinks,
|
|
648
703
|
info: (jobId) => request<BoardInfo>(boardPath(jobId, "board")),
|
|
704
|
+
watchFiles(jobId, handlers) {
|
|
705
|
+
let active = true;
|
|
706
|
+
let stop: () => void = () => {};
|
|
707
|
+
const close = (error: CenterError | null) => {
|
|
708
|
+
if (!active) return;
|
|
709
|
+
active = false; stop(); handlers.onClose(error);
|
|
710
|
+
};
|
|
711
|
+
const fetchStream: typeof fetch = async (input, init) => {
|
|
712
|
+
const response = await doFetch(input, init);
|
|
713
|
+
if (response.ok && !/^text\/event-stream(?:\s*;|$)/i.test(response.headers.get("content-type") ?? "")) {
|
|
714
|
+
await response.body?.cancel().catch(() => undefined);
|
|
715
|
+
throw new CenterError("文件事件接口未返回事件流,请检查服务代理配置", "INVALID_RESPONSE", 502);
|
|
716
|
+
}
|
|
717
|
+
return response;
|
|
718
|
+
};
|
|
719
|
+
stop = openSse(fetchStream, `${base}${boardPath(jobId, "files/events")}`, headers(), (event, data) => {
|
|
720
|
+
if (!active) return;
|
|
721
|
+
if (event === "files" || event === "message") {
|
|
722
|
+
if (!isWorkspaceFileBatch(data)) { close(new CenterError("文件事件数据不完整,正在重新同步", "INVALID_RESPONSE", 502)); return; }
|
|
723
|
+
handlers.onEvent(data);
|
|
724
|
+
} else if (event === "end") {
|
|
725
|
+
const end = data && typeof data === "object" ? data as Record<string, unknown> : {};
|
|
726
|
+
close(new CenterError(typeof end.detail === "string" ? end.detail : "文件监听连接中断,正在重连", typeof end.reason === "string" ? end.reason : "NETWORK", typeof end.status === "number" ? end.status : 0));
|
|
727
|
+
}
|
|
728
|
+
}, close);
|
|
729
|
+
return () => { active = false; stop(); };
|
|
730
|
+
},
|
|
649
731
|
stat: (jobId, path = ".") => request<DirListing | FileInfo>(boardPath(jobId, "files", `path=${encodeURIComponent(path)}`)),
|
|
650
732
|
fileUrl: (jobId, path, opts = {}) => boardFileUrl(base, jobId, path, opts.download),
|
|
651
733
|
async fileLinkBase(jobId) {
|
|
@@ -669,7 +751,7 @@ function createBoardClient(
|
|
|
669
751
|
return work;
|
|
670
752
|
},
|
|
671
753
|
async readText(jobId, path, maxBytes = 512 * 1024, opts = {}) {
|
|
672
|
-
const { bytes, start, size, contentType } = await readRange(jobId, path, { maxBytes, tail: opts.tail, onBytes: opts.onBytes });
|
|
754
|
+
const { bytes, start, size, contentType } = await readRange(jobId, path, { maxBytes, tail: opts.tail, signal: opts.signal, onBytes: opts.onBytes });
|
|
673
755
|
if (!size && !bytes.length) return { text: "", truncated: false, size: 0, contentType };
|
|
674
756
|
const { text, encoding } = decodeText(bytes, { fromMiddle: start > 0 });
|
|
675
757
|
return { text, truncated: bytes.length < size, size, contentType, tail: start > 0, encoding };
|
package/src/core/conversation.ts
CHANGED
|
@@ -135,6 +135,7 @@ const batchStreamTask = (task: BatchStreamTask): StreamTask => ({
|
|
|
135
135
|
// 对话式子任务跑完一条不算结束:还能接着聊。
|
|
136
136
|
id: task.id, seq: task.seq, title: task.title, status: task.status, closed: !task.conversation && TERMINAL_TASK.has(task.status),
|
|
137
137
|
remote_task_id: task.remote_task_id, continue_from_task_id: task.continue_from_task_id,
|
|
138
|
+
awaiting_input: task.awaiting_input, awaiting_input_observed_at: task.awaiting_input_observed_at,
|
|
138
139
|
});
|
|
139
140
|
|
|
140
141
|
/** 没指定子任务时看哪个:最近一个派发过的,都没派发过就是最后一个。 */
|
package/src/core/provider.tsx
CHANGED
|
@@ -70,6 +70,13 @@ export function CliCenterProvider({ store: external, selectedId, onEvent, onTurn
|
|
|
70
70
|
if (selectedId !== undefined) { created.select(selectedId); applied.current = selectedId; }
|
|
71
71
|
return created;
|
|
72
72
|
}, [external]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
73
|
+
// Creation defaults can arrive after a file/configuration load. Update only
|
|
74
|
+
// these defaults on commit; the connection and workspace state stay stable.
|
|
75
|
+
useLayoutEffect(() => {
|
|
76
|
+
optionsRef.current.newConversation = options.newConversation;
|
|
77
|
+
optionsRef.current.createConversation = options.createConversation;
|
|
78
|
+
optionsRef.current.businessIdFor = options.businessIdFor;
|
|
79
|
+
}, [options.newConversation, options.createConversation, options.businessIdFor]);
|
|
73
80
|
// 之后宿主改了 selectedId:一起切过去(在绘制前,免得闪一下旧的)。
|
|
74
81
|
useLayoutEffect(() => {
|
|
75
82
|
if (selectedId === undefined || selectedId === applied.current) return;
|
package/src/core/store.ts
CHANGED
|
@@ -121,6 +121,9 @@ export type CliEvent =
|
|
|
121
121
|
export function cloneWorkspaceEvent(event:CliEvent):CliEvent {
|
|
122
122
|
if(event.type==='files_changed')return cloneWorkspaceChange(event);
|
|
123
123
|
if(event.type==='files_watch')return {...event};
|
|
124
|
+
if(event.type==='board_ref')return {...event,refs:event.refs.map(ref=>({...ref}))};
|
|
125
|
+
if(event.type==='context')return {...event,pending:event.pending.map(item=>({...item})),...(event.sent?{sent:event.sent.map(item=>({...item}))}:{})};
|
|
126
|
+
if(event.type==='open')return {...event,target:{...event.target}};
|
|
124
127
|
if(event.type==='board_context')return {...event,view:cloneBoardView(event.view)};
|
|
125
128
|
if(event.type==='board_change')return {...event,view:event.view?cloneBoardView(event.view):null};
|
|
126
129
|
return event;
|
|
@@ -384,24 +387,27 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
384
387
|
function connect(key: string) {
|
|
385
388
|
const entry = streams.get(key);
|
|
386
389
|
if (!entry || entry.refs <= 0) return;
|
|
390
|
+
const previous = entry.stop;
|
|
391
|
+
entry.stop = null;
|
|
392
|
+
previous?.();
|
|
393
|
+
if (streams.get(key) !== entry || entry.refs <= 0) return;
|
|
387
394
|
const { jobId, taskId } = parseConvKey(key);
|
|
388
|
-
|
|
389
|
-
let
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if (!current || current.refs <= 0) return;
|
|
395
|
+
let ended = false, retired = false;
|
|
396
|
+
let cleanup: (() => void) | null = null;
|
|
397
|
+
const owns = () => streams.get(key) === entry && entry.refs > 0 && entry.stop === retire;
|
|
398
|
+
const active = () => !retired && owns();
|
|
399
|
+
const retire = () => {
|
|
400
|
+
if (retired) return;
|
|
401
|
+
retired = true;
|
|
402
|
+
if (entry.stop === retire) entry.stop = null;
|
|
403
|
+
const dispose = cleanup; cleanup = null;
|
|
404
|
+
notifyCallback(dispose ?? undefined, undefined);
|
|
405
|
+
};
|
|
406
|
+
const close = (error: CenterError | null) => {
|
|
407
|
+
if (!active()) return;
|
|
408
|
+
retire();
|
|
409
|
+
const current = streams.get(key);
|
|
410
|
+
if (current !== entry || current.refs <= 0 || current.stop) return;
|
|
405
411
|
// 对话结束(end)不再连;中枢停机(server_shutdown)稍后重连。
|
|
406
412
|
if (ended && state.convs[key]?.endReason !== "server_shutdown") return;
|
|
407
413
|
// 404:对话不存在,重连也没用。
|
|
@@ -422,8 +428,32 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
422
428
|
current.attempt += 1;
|
|
423
429
|
patchConv(key, (conv) => ({ ...conv, stream: "reconnecting", streamError: error?.message ?? null }));
|
|
424
430
|
current.timer = setTimeout(() => { current.timer = null; connect(key); }, jitter(delay));
|
|
425
|
-
|
|
426
|
-
|
|
431
|
+
};
|
|
432
|
+
entry.stop = retire;
|
|
433
|
+
patchConv(key, (conv) => ({ ...conv, stream: entry.attempt ? "reconnecting" : "connecting", streamError: null }));
|
|
434
|
+
if (!active()) return;
|
|
435
|
+
try {
|
|
436
|
+
cleanup = client.stream(jobId, {
|
|
437
|
+
onOpen: () => { if (!active()) return; entry.attempt = 0; patchConv(key, (conv) => ({ ...conv, stream: "live", streamError: null })); },
|
|
438
|
+
onSnapshot: (snapshot) => { if (active()) patchConv(key, (conv) => applySnapshot(conv, snapshot)); },
|
|
439
|
+
onChunk: (items) => { if (active()) patchConv(key, (conv) => applyItems(conv, items)); },
|
|
440
|
+
onEnd: (end) => {
|
|
441
|
+
if (!active()) return;
|
|
442
|
+
ended = true;
|
|
443
|
+
const continuedBy = taskId ? (end.continued_by_job_id && end.continued_by_task_id ? convKey(end.continued_by_job_id, end.continued_by_task_id) : null) : end.continued_by_job_id ?? null;
|
|
444
|
+
patchConv(key, (conv) => ({ ...conv, stream: "ended", endReason: end.reason, continuedBy, nodeRunning: false }));
|
|
445
|
+
void refreshJob(jobId, true);
|
|
446
|
+
},
|
|
447
|
+
onClose: close,
|
|
448
|
+
}, undefined, taskId);
|
|
449
|
+
// Some custom transports close synchronously before returning cleanup.
|
|
450
|
+
if (retired || !owns()) {
|
|
451
|
+
const dispose = cleanup; cleanup = null;
|
|
452
|
+
notifyCallback(dispose ?? undefined, undefined);
|
|
453
|
+
}
|
|
454
|
+
} catch (error) {
|
|
455
|
+
close(error instanceof CenterError ? error : new CenterError(String(error), "NETWORK", 0));
|
|
456
|
+
}
|
|
427
457
|
}
|
|
428
458
|
|
|
429
459
|
// 网络恢复 / 标签页切回来时,正在退避等待的连接立刻重连,不用等完退避时间。
|
|
@@ -472,16 +502,22 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
472
502
|
void refreshJob(jobId);
|
|
473
503
|
connect(key);
|
|
474
504
|
}
|
|
505
|
+
const owned = entry;
|
|
506
|
+
let active = true;
|
|
475
507
|
return () => {
|
|
508
|
+
if (!active) return;
|
|
509
|
+
active = false;
|
|
476
510
|
const current = streams.get(key);
|
|
477
|
-
if (
|
|
511
|
+
if (current !== owned) return;
|
|
478
512
|
current.refs -= 1;
|
|
479
513
|
if (current.refs > 0) return;
|
|
480
|
-
|
|
481
|
-
|
|
514
|
+
// Retire the registry before calling host cleanup: it may synchronously
|
|
515
|
+
// acquire a new lease for the same task.
|
|
482
516
|
streams.delete(key);
|
|
517
|
+
if (current.timer) { clearTimeout(current.timer); current.timer = null; }
|
|
483
518
|
syncLive();
|
|
484
|
-
|
|
519
|
+
current.stop?.();
|
|
520
|
+
if (!streams.has(key)) patchConv(key, (conv) => ({ ...conv, stream: conv.stream === "ended" ? "ended" : "idle" }));
|
|
485
521
|
};
|
|
486
522
|
}
|
|
487
523
|
|
|
@@ -552,6 +588,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
552
588
|
return target;
|
|
553
589
|
}
|
|
554
590
|
if (!key || !ref) {
|
|
591
|
+
const selectedBeforeCreate = state.selected;
|
|
555
592
|
let newId: string;
|
|
556
593
|
if (createConversation) {
|
|
557
594
|
newId = await createConversation(text);
|
|
@@ -563,7 +600,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
563
600
|
}
|
|
564
601
|
addJob(newId);
|
|
565
602
|
// 共享选中只由「跟着选中走」的组件改;传了 jobId、自己接管的对话组件新建对话不去动别人的选中。
|
|
566
|
-
if (overrides.select !== false) set({ selected: newId });
|
|
603
|
+
if (overrides.select !== false && state.selected === selectedBeforeCreate) set({ selected: newId });
|
|
567
604
|
emit({ type: "job_change", jobId: newId, previousJobId: null, taskId: null, key: newId, previousKey: null });
|
|
568
605
|
void refreshJob(newId, true);
|
|
569
606
|
return newId;
|
|
@@ -695,7 +732,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
695
732
|
|
|
696
733
|
function setRefs(key: string, refs: BoardRef[]) {
|
|
697
734
|
const next = { ...state.refs };
|
|
698
|
-
if (refs.length) next[key] = refs; else delete next[key];
|
|
735
|
+
if (refs.length) next[key] = refs.map(ref => ({ ...ref })); else delete next[key];
|
|
699
736
|
set({ refs: next });
|
|
700
737
|
emit({ type: "board_ref", key, refs });
|
|
701
738
|
}
|
|
@@ -822,7 +859,7 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
822
859
|
if (next.length !== list.length) setContext(k, next);
|
|
823
860
|
}
|
|
824
861
|
/** 还没发出的补充背景。 */
|
|
825
|
-
const pendingContext = (key: string | null): ContextItem[] =>
|
|
862
|
+
const pendingContext = (key: string | null): ContextItem[] => (state.context[ctxKey(key)] ?? []).map(item => ({ ...item }));
|
|
826
863
|
|
|
827
864
|
function workspace(key?: string | null): CliWorkspace {
|
|
828
865
|
const k = key ?? state.selected;
|
|
@@ -855,7 +892,11 @@ export function createCliStore(options: CliStoreOptions = {}) {
|
|
|
855
892
|
|
|
856
893
|
function openInBoard(jobId: string, target: OpenTarget): boolean {
|
|
857
894
|
let handled = false;
|
|
858
|
-
for (
|
|
895
|
+
for (const surface of [...surfaces].reverse()) {
|
|
896
|
+
try { handled = surface(jobId, { ...target }); }
|
|
897
|
+
catch (error) { console.error("[@spzxg/cli-ui] board surface callback failed", error); }
|
|
898
|
+
if (handled) break;
|
|
899
|
+
}
|
|
859
900
|
emit({ type: "open", jobId, target, handled });
|
|
860
901
|
return handled;
|
|
861
902
|
}
|