@spzxg/cli-ui 0.6.5 → 0.6.7
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 +55 -0
- package/README.md +109 -30
- package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
- package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-BmwOLWFI.js} +2 -2
- package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
- package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
- package/dist/{cli-ui-xlsx-zs7oEtPj.js → cli-ui-xlsx-Sl7ypvSe.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +11 -8
- package/src/board/BrowserPane.tsx +198 -53
- package/src/board/CliBoard.tsx +128 -100
- package/src/board/FileViewer.tsx +276 -136
- package/src/board/FilesPane.tsx +129 -40
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +19 -7
- package/src/board/fileKind.ts +138 -0
- package/src/board/fileOps.ts +9 -2
- package/src/board/fileScope.ts +29 -0
- package/src/board/fileSync.ts +343 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +71 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +177 -70
- package/src/board/folderDownload.ts +39 -5
- package/src/board/frameQuote.ts +12 -10
- package/src/board/htmlReading.ts +148 -0
- package/src/board/textLoad.ts +18 -41
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +53 -11
- package/src/chat/CliChat.tsx +105 -55
- package/src/chat/Composer.tsx +55 -21
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +132 -42
- package/src/chat/draft.ts +5 -2
- package/src/core/blocks.ts +17 -5
- package/src/core/client.ts +88 -19
- package/src/core/conversation.ts +1 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/provider.tsx +7 -0
- package/src/core/store.ts +134 -36
- package/src/core/types.ts +31 -10
- package/src/index.ts +3 -0
- package/src/layout/split.ts +67 -30
- package/src/msglist/CliMsgList.tsx +18 -5
- package/src/operator/node/Chat.tsx +7 -8
- package/src/operator/node/Composer.tsx +99 -72
- package/src/operator/node/Orchestrator.tsx +1 -1
- package/src/operator/node/SessionRail.tsx +71 -32
- package/src/operator/node/SessionView.tsx +25 -25
- package/src/operator/node/Transcript.tsx +28 -11
- package/src/operator/node/boardBackend.ts +6 -23
- package/src/operator/node/boardEvents.ts +21 -3
- package/src/operator/node/boardState.ts +18 -1
- package/src/operator/node/boardWorkspace.ts +4 -1
- package/src/operator/node/composerDraft.ts +6 -2
- package/src/operator/node/format.ts +4 -2
- package/src/operator/node/liveStream.ts +14 -2
- package/src/operator/node/protocol/types.ts +2 -2
- package/src/operator/node/useTaskStream.ts +8 -5
- package/src/operator.base.css +148 -37
- package/src/operator.css +148 -37
- package/src/styles.css +274 -32
- package/src/ui/Markdown.tsx +2 -2
- package/src/ui/Popover.tsx +32 -14
- package/src/ui/QueuedMessages.tsx +69 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/TextBlock.tsx +6 -4
- package/src/ui/Transcript.tsx +27 -12
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/format.ts +3 -1
- package/src/ui/reference.ts +40 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
- package/dist/cli-ui-embed-BmLN2Jjg.js +0 -43290
package/src/core/client.ts
CHANGED
|
@@ -2,9 +2,12 @@ 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
|
+
import { createDocumentUploader, extractDocumentReferences, documentRequest, isTaskUploadPath, type UploadDocument, type DocumentUploadOptions, type DocumentReceipt } from "./documentUpload";
|
|
9
|
+
import { syncFile, type FileSyncTransport } from "../board/fileSync";
|
|
10
|
+
import { createFileSyncTransport } from "../board/fileSyncTransport";
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* center 服务(cli-task-center)对话任务接口的浏览器端客户端。组件的数据全部从这里来。
|
|
@@ -78,6 +81,11 @@ const ERROR_TEXT: Record<string, string> = {
|
|
|
78
81
|
task_already_finished: "当前子任务已经结束,请刷新状态后继续",
|
|
79
82
|
task_not_paused: "当前子任务没有暂停,请刷新状态后重试",
|
|
80
83
|
job_already_paused: "整个任务已暂停,请先恢复整个任务",
|
|
84
|
+
file_watch_unsupported: "当前服务不支持文件事件",
|
|
85
|
+
browser_required: "传递浏览器 YAML 手册或操作间隔时,需要同时启用 browser: true",
|
|
86
|
+
invalid_browser_playbook: "浏览器 YAML 手册校验失败,请检查格式和步骤配置",
|
|
87
|
+
invalid_browser_pacing: "浏览器操作间隔无效,请检查最小值和最大值",
|
|
88
|
+
conversation_browser_fields_fixed: "浏览器手册和操作间隔在创建对话时确定,追加消息不能修改",
|
|
81
89
|
};
|
|
82
90
|
|
|
83
91
|
/**
|
|
@@ -87,6 +95,8 @@ const ERROR_TEXT: Record<string, string> = {
|
|
|
87
95
|
* 带 ? 的是可选能力:后端不提供,界面就按 Operator 在「没有这个能力」时的样子显示(例如提问的回答改走下一条消息)。
|
|
88
96
|
*/
|
|
89
97
|
export interface ConversationBackend {
|
|
98
|
+
/** Upload original HTML/Markdown without embedding document bytes in a message. */
|
|
99
|
+
uploadDocument?: UploadDocument;
|
|
90
100
|
baseUrl: string;
|
|
91
101
|
getJob(jobId: string): Promise<CenterJob>;
|
|
92
102
|
/** taskId:只要对话任务里这一个对话的轮次。 */
|
|
@@ -138,6 +148,15 @@ export interface ConversationBackend {
|
|
|
138
148
|
* 方法里的 jobId 也可以是对话键("job_x#task_y"):看对话任务里那个对话的文件、改动和浏览器。
|
|
139
149
|
*/
|
|
140
150
|
export interface BoardBackend {
|
|
151
|
+
/** Save an original file to this conversation without sending a message or starting the agent. */
|
|
152
|
+
uploadFile?(jobId: string, file: File, options: DocumentUploadOptions): Promise<DocumentReceipt>;
|
|
153
|
+
/** Authorized input directory of this task; browsing it never starts an agent turn. */
|
|
154
|
+
uploadDirectory?(jobId: string, signal?: AbortSignal): Promise<string | null>;
|
|
155
|
+
/** Immutable revisions and content-addressed blocks; refreshes reuse validated local blocks. */
|
|
156
|
+
fileSync?: FileSyncTransport;
|
|
157
|
+
/** One directory page only. No recursive scan or file body prefetch. */
|
|
158
|
+
listFiles?(jobId: string, dir: string, options?: { cursor?: string; limit?: number; signal?: AbortSignal }): Promise<DirListing>;
|
|
159
|
+
pollFiles?(jobId: string, scope: { paths: string[]; dirs: string[]; cursor?: string }, signal?: AbortSignal): Promise<WorkspaceFileBatch & { cursor?: string; directories: Array<{ path: string; revision: string | null }> }>;
|
|
141
160
|
/** fileUrl 给出的地址浏览器能直接打开(不需要额外请求头)。false 时看板先 fetch 再显示。 */
|
|
142
161
|
directLinks: boolean;
|
|
143
162
|
info(jobId: string): Promise<BoardInfo>;
|
|
@@ -158,12 +177,12 @@ export interface BoardBackend {
|
|
|
158
177
|
* tail = 读末尾那一段(超大日志);节点不支持只读一段时退回开头(返回的 tail 为 false)。
|
|
159
178
|
* 不是 UTF-8 的(GBK 导出的 CSV 之类)按 GB18030 解,encoding 说明按什么解的。
|
|
160
179
|
*/
|
|
161
|
-
readText(jobId: string, path: string, maxBytes?: number, opts?: { tail?: boolean; onBytes?: (bytes: number) => void }): Promise<TextChunk>;
|
|
180
|
+
readText(jobId: string, path: string, maxBytes?: number, opts?: { tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }): Promise<TextChunk>;
|
|
162
181
|
/**
|
|
163
182
|
* 读一段原始字节:从 start 开始(缺省 0)最多 maxBytes;tail = 末尾 maxBytes。返回这段在文件里的起点和文件总大小。
|
|
164
183
|
* 看板用它在文件变长时只取新增的部分(日志、还在写的报告),不整个重读。没有这个方法的后端每次整个重读。
|
|
165
184
|
*/
|
|
166
|
-
readBytes?(jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; onBytes?: (bytes: number) => void }): Promise<ByteChunk>;
|
|
185
|
+
readBytes?(jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }): Promise<ByteChunk>;
|
|
167
186
|
/**
|
|
168
187
|
* 读整个文件。signal 取消(打包下载点了取消);onBytes 报已收到的字节数(大文件显示进度)。
|
|
169
188
|
* 弱网:FILE_HEADER_TIMEOUT_MS 内没回响应头、或传输中 FILE_STALL_MS 没收到数据,按网络错误(NETWORK)失败。
|
|
@@ -281,6 +300,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
281
300
|
|
|
282
301
|
return {
|
|
283
302
|
baseUrl: base,
|
|
303
|
+
uploadDocument: createDocumentUploader(doFetch, `${base}/api/file-sync`, headers),
|
|
284
304
|
getJob: (jobId: string) => request<CenterJob>(`/api/jobs/${id(jobId)}`),
|
|
285
305
|
listTurns: (jobId: string, query: { afterSeq?: number; limit?: number; taskId?: string } = {}) => {
|
|
286
306
|
const params = new URLSearchParams();
|
|
@@ -321,18 +341,21 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
321
341
|
...(opts.callback ? { callback: opts.callback } : {}),
|
|
322
342
|
tasks: [{
|
|
323
343
|
title,
|
|
324
|
-
prompt: firstMessage,
|
|
344
|
+
prompt: extractDocumentReferences(firstMessage).text,
|
|
345
|
+
...(extractDocumentReferences(firstMessage).attachments ? { attachments: extractDocumentReferences(firstMessage).attachments } : {}),
|
|
325
346
|
...(opts.businessId ? { business_id: opts.businessId } : {}),
|
|
326
347
|
...(opts.agent ? { agent: opts.agent } : {}),
|
|
327
348
|
...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
|
|
328
|
-
...(opts.browser ? { browser:
|
|
349
|
+
...(opts.browser !== undefined ? { browser: opts.browser } : {}),
|
|
350
|
+
...(opts.browserPlaybook !== undefined ? { browser_playbook: opts.browserPlaybook } : {}),
|
|
351
|
+
...(opts.browserPacing !== undefined ? { browser_pacing: opts.browserPacing } : {}),
|
|
329
352
|
}],
|
|
330
353
|
});
|
|
331
354
|
},
|
|
332
355
|
/** 带 business_id 时 center 按它去重,网络超时可以放心重发同一条。 */
|
|
333
356
|
sendMessage: (jobId: string, text: string, opts: { businessId?: string; timeoutSeconds?: number; taskId?: string } = {}) =>
|
|
334
357
|
post<SendResult>(`/api/jobs/${id(jobId)}/messages`, {
|
|
335
|
-
text,
|
|
358
|
+
...extractDocumentReferences(text),
|
|
336
359
|
...(opts.taskId ? { task_id: opts.taskId } : {}),
|
|
337
360
|
...(opts.businessId ? { business_id: opts.businessId } : {}),
|
|
338
361
|
...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
|
|
@@ -340,7 +363,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
340
363
|
continueTask: async (jobId: string, taskId: string, text: string, opts: { title?: string; businessId?: string } = {}) => {
|
|
341
364
|
const result = await post<{ task_ids: string[] }>(`/api/jobs`, {
|
|
342
365
|
job_id: jobId, mode: "conversation",
|
|
343
|
-
tasks: [{ title: opts.title || firstLine(text, 60), prompt: text, continue_from: taskId, ...(opts.businessId ? { business_id: opts.businessId } : {}) }],
|
|
366
|
+
tasks: [{ title: opts.title || firstLine(text, 60), prompt: extractDocumentReferences(text).text, ...(extractDocumentReferences(text).attachments ? { attachments: extractDocumentReferences(text).attachments } : {}), continue_from: taskId, ...(opts.businessId ? { business_id: opts.businessId } : {}) }],
|
|
344
367
|
});
|
|
345
368
|
return { task_id: result.task_ids[0] };
|
|
346
369
|
},
|
|
@@ -349,7 +372,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
349
372
|
/** 同一个 business_id 重发只开一个对话。 */
|
|
350
373
|
openConversation: (jobId: string, text: string, opts: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number } = {}) =>
|
|
351
374
|
post<SendResult>(`/api/jobs/${id(jobId)}/conversations`, {
|
|
352
|
-
text,
|
|
375
|
+
...extractDocumentReferences(text),
|
|
353
376
|
...(opts.title ? { title: opts.title } : {}),
|
|
354
377
|
...(opts.agent ? { agent: opts.agent } : {}),
|
|
355
378
|
...(opts.browser !== undefined ? { browser: opts.browser } : {}),
|
|
@@ -465,6 +488,13 @@ function openSse(
|
|
|
465
488
|
try {
|
|
466
489
|
arm();
|
|
467
490
|
const response = await doFetch(url, { headers: { ...headers, Accept: "text/event-stream" }, signal: ctl.signal });
|
|
491
|
+
// A host-supplied fetch may resolve after cancellation despite signal.
|
|
492
|
+
// Retire its body before acquiring a reader or restarting the watchdog.
|
|
493
|
+
if (ctl.signal.aborted) {
|
|
494
|
+
void response.body?.cancel().catch(() => undefined);
|
|
495
|
+
if (silent) onClose?.(new CenterError("连接长时间没有数据,已断开重连", "NETWORK", 0));
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
468
498
|
if (!response.ok || !response.body) {
|
|
469
499
|
const body = (await response.json().catch(() => null)) as { error?: string; detail?: string } | null;
|
|
470
500
|
throw new CenterError(body?.detail ?? ERROR_TEXT[body?.error ?? ""] ?? `连接失败(HTTP ${response.status})`, body?.error ?? `HTTP_${response.status}`, response.status);
|
|
@@ -548,7 +578,11 @@ function createBoardClient(
|
|
|
548
578
|
if (ctl.signal.aborted) reject(new DOMException("aborted", "AbortError"));
|
|
549
579
|
ctl.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
|
|
550
580
|
});
|
|
551
|
-
|
|
581
|
+
const fetching = doFetch(boardFileUrl(base, jobId, path), { headers: headers(extra), signal: ctl.signal, cache: "no-store" });
|
|
582
|
+
// Host fetch adapters can ignore abort. Dispose a response that arrives
|
|
583
|
+
// after the caller already cancelled or timed out.
|
|
584
|
+
void fetching.then(late => { if (ctl.signal.aborted) void late.body?.cancel().catch(() => undefined); }, () => undefined);
|
|
585
|
+
response = await Promise.race([fetching, stopped]);
|
|
552
586
|
} catch (error) {
|
|
553
587
|
release();
|
|
554
588
|
if (signal?.aborted) throw aborted();
|
|
@@ -556,21 +590,19 @@ function createBoardClient(
|
|
|
556
590
|
throw new CenterError(`连不上任务中心:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
|
|
557
591
|
}
|
|
558
592
|
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
593
|
const stallError = () => new CenterError(`读取「${name}」时 ${FILE_STALL_MS / 1000} 秒没有收到数据,传输已中断,请重试`, "NETWORK", 0);
|
|
566
594
|
/** 读响应体:limit 读够就停;onBytes 报进度;FILE_STALL_MS 没数据当断线。 */
|
|
567
595
|
const body = async (opts: { limit?: number; onBytes?: (bytes: number) => void } = {}): Promise<Uint8Array<ArrayBuffer>> => {
|
|
596
|
+
if (signal?.aborted) { release(); void response.body?.cancel().catch(() => undefined); throw aborted(); }
|
|
568
597
|
// 空文件要「前 N 字节」:节点回 416,就是空的。
|
|
569
598
|
if (response.status === 416) { release(); return new Uint8Array(0); }
|
|
570
599
|
const reader = response.body?.getReader();
|
|
571
600
|
if (!reader) {
|
|
572
601
|
try { return new Uint8Array(await response.arrayBuffer()); } finally { release(); }
|
|
573
602
|
}
|
|
603
|
+
const cancelBody = () => { void reader.cancel().catch(() => undefined); };
|
|
604
|
+
ctl.signal.addEventListener("abort", cancelBody, { once: true });
|
|
605
|
+
if (ctl.signal.aborted) cancelBody();
|
|
574
606
|
const chunks: Uint8Array[] = [];
|
|
575
607
|
let got = 0;
|
|
576
608
|
let stalled = false;
|
|
@@ -598,6 +630,11 @@ function createBoardClient(
|
|
|
598
630
|
throw new CenterError(`读取「${name}」中断:${error instanceof Error ? error.message : String(error)}`, "NETWORK", 0);
|
|
599
631
|
} finally {
|
|
600
632
|
clearTimeout(stall);
|
|
633
|
+
ctl.signal.removeEventListener("abort", cancelBody);
|
|
634
|
+
// Progress callbacks may throw too; no abandoned response should keep
|
|
635
|
+
// consuming the mobile connection after the read has failed.
|
|
636
|
+
void reader.cancel().catch(() => undefined);
|
|
637
|
+
try { reader.releaseLock(); } catch { /* already released */ }
|
|
601
638
|
release();
|
|
602
639
|
}
|
|
603
640
|
if (signal?.aborted) throw aborted();
|
|
@@ -612,16 +649,25 @@ function createBoardClient(
|
|
|
612
649
|
}
|
|
613
650
|
return all;
|
|
614
651
|
};
|
|
652
|
+
if (!response.ok && response.status !== 416) {
|
|
653
|
+
// Error bodies need the same abort/stall bounds as file data. A proxy
|
|
654
|
+
// returning 401 headers and then hanging must not park a read forever.
|
|
655
|
+
const bytes = await body({ limit: 8192 });
|
|
656
|
+
let problem: { error?: string; detail?: string } | null = null;
|
|
657
|
+
try { problem = JSON.parse(new TextDecoder().decode(bytes)); } catch { /* use the HTTP fallback */ }
|
|
658
|
+
const fallback = response.status === 404 ? "文件不存在(可能已被任务删除或改名)" : problem?.error ?? `读取失败(HTTP ${response.status})`;
|
|
659
|
+
throw new CenterError(problem?.detail ?? ERROR_TEXT[problem?.error ?? ""] ?? fallback, problem?.error ?? `HTTP_${response.status}`, response.status);
|
|
660
|
+
}
|
|
615
661
|
return { response, body };
|
|
616
662
|
};
|
|
617
663
|
/**
|
|
618
664
|
* 读一段:开头 / 从 start 起 maxBytes,或者末尾 maxBytes(超大日志)。只向中枢要这一段(Range);
|
|
619
665
|
* 不支持 Range 的节点回整个文件(200),这里边读边截,大文件不整个拉进浏览器。
|
|
620
666
|
*/
|
|
621
|
-
const readRange = async (jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; onBytes?: (bytes: number) => void }) => {
|
|
667
|
+
const readRange = async (jobId: string, path: string, opts: { start?: number; maxBytes: number; tail?: boolean; signal?: AbortSignal; onBytes?: (bytes: number) => void }) => {
|
|
622
668
|
const from = Math.max(0, opts.start ?? 0);
|
|
623
669
|
const { maxBytes } = opts;
|
|
624
|
-
const { response, body } = await fileFetch(jobId, path, { Range: opts.tail ? `bytes=-${maxBytes}` : `bytes=${from}-${from + maxBytes - 1}` });
|
|
670
|
+
const { response, body } = await fileFetch(jobId, path, { Range: opts.tail ? `bytes=-${maxBytes}` : `bytes=${from}-${from + maxBytes - 1}` }, opts.signal);
|
|
625
671
|
const contentType = response.headers.get("content-type") ?? "text/plain";
|
|
626
672
|
const total = /\/(\d+)\s*$/.exec(response.headers.get("content-range") ?? "")?.[1];
|
|
627
673
|
// 416:要的起点不在文件里(空文件,或文件变短了)。
|
|
@@ -643,10 +689,28 @@ function createBoardClient(
|
|
|
643
689
|
const minting = new Map<string, Promise<string | null>>();
|
|
644
690
|
let linksUnsupported = false;
|
|
645
691
|
const LINK_REFRESH_MS = 5 * 60_000;
|
|
692
|
+
const uploads = new Map<string, UploadDocument>();
|
|
693
|
+
const fileSync = createFileSyncTransport(doFetch, (jobId, operation) => `${base}${boardPath(jobId, `file-sync/${operation}`)}`, headers);
|
|
646
694
|
return {
|
|
647
695
|
directLinks,
|
|
696
|
+
uploadFile(jobId, file, options) {
|
|
697
|
+
let upload = uploads.get(jobId);
|
|
698
|
+
if (!upload) {
|
|
699
|
+
const { job, task } = boardTarget(jobId);
|
|
700
|
+
upload = createDocumentUploader(doFetch, `${base}/api/jobs/${job}${task ? `/tasks/${task}` : ""}/file-sync`, headers);
|
|
701
|
+
uploads.set(jobId, upload);
|
|
702
|
+
}
|
|
703
|
+
return upload(file, options);
|
|
704
|
+
},
|
|
705
|
+
async uploadDirectory(jobId, signal) {
|
|
706
|
+
const capabilities = await documentRequest<{ uploadsPath?: string }>(doFetch, `${base}${boardPath(jobId, "file-sync/capabilities")}`, { signal }, headers);
|
|
707
|
+
return typeof capabilities.uploadsPath === "string" ? capabilities.uploadsPath : null;
|
|
708
|
+
},
|
|
709
|
+
fileSync,
|
|
710
|
+
listFiles: (jobId, dir, options = {}) => documentRequest<DirListing>(doFetch, `${base}${boardPath(jobId, "file-sync/list", new URLSearchParams({ dir: dir || ".", limit: String(options.limit ?? 200), ...(options.cursor ? { cursor: options.cursor } : {}) }).toString())}`, { signal: options.signal }, headers),
|
|
648
711
|
info: (jobId) => request<BoardInfo>(boardPath(jobId, "board")),
|
|
649
|
-
|
|
712
|
+
pollFiles: (jobId, scope, signal) => documentRequest(doFetch, `${base}${boardPath(jobId, "file-sync/changes")}`, { method: "POST", signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify(scope) }, headers),
|
|
713
|
+
stat: (jobId, path = ".") => documentRequest<DirListing | FileInfo>(doFetch, `${base}${boardPath(jobId, "file-sync/stat", `path=${encodeURIComponent(path)}`)}`, {}, headers),
|
|
650
714
|
fileUrl: (jobId, path, opts = {}) => boardFileUrl(base, jobId, path, opts.download),
|
|
651
715
|
async fileLinkBase(jobId) {
|
|
652
716
|
const { job, task } = boardTarget(jobId);
|
|
@@ -669,7 +733,7 @@ function createBoardClient(
|
|
|
669
733
|
return work;
|
|
670
734
|
},
|
|
671
735
|
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 });
|
|
736
|
+
const { bytes, start, size, contentType } = await readRange(jobId, path, { maxBytes, tail: opts.tail, signal: opts.signal, onBytes: opts.onBytes });
|
|
673
737
|
if (!size && !bytes.length) return { text: "", truncated: false, size: 0, contentType };
|
|
674
738
|
const { text, encoding } = decodeText(bytes, { fromMiddle: start > 0 });
|
|
675
739
|
return { text, truncated: bytes.length < size, size, contentType, tail: start > 0, encoding };
|
|
@@ -679,6 +743,11 @@ function createBoardClient(
|
|
|
679
743
|
return { bytes, start, size };
|
|
680
744
|
},
|
|
681
745
|
async readBuffer(jobId, path, opts = {}) {
|
|
746
|
+
if (isTaskUploadPath(path)) {
|
|
747
|
+
const file = await syncFile(fileSync, jobId, path, { signal: opts.signal, decode: false,
|
|
748
|
+
onProgress: progress => opts.onBytes?.(progress.loadedBytes) });
|
|
749
|
+
return file.bytes.buffer as ArrayBuffer;
|
|
750
|
+
}
|
|
682
751
|
const { body } = await fileFetch(jobId, path, {}, opts.signal);
|
|
683
752
|
return (await body({ onBytes: opts.onBytes })).buffer;
|
|
684
753
|
},
|
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
|
/** 没指定子任务时看哪个:最近一个派发过的,都没派发过就是最后一个。 */
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createFileWorkQueue } from "../board/fileSyncQueue";
|
|
2
|
+
/** Original attachments. The message contains an authorized reference only. */
|
|
3
|
+
export const DOCUMENT_MAX_BYTES = 100 * 1024 * 1024;
|
|
4
|
+
export const UPLOAD_OTHER_MAX_BYTES = 10 * 1024 * 1024;
|
|
5
|
+
export const DOCUMENT_CHUNK_BYTES = 1024 * 1024;
|
|
6
|
+
export const isOriginalDocument = (name: string) => /\.(html?|md|markdown)$/i.test(name);
|
|
7
|
+
export const isTaskUploadPath = (path: string) => /\/(?:uploads|attachments)\/[^/]+\/[^/]+$/.test(path.replace(/\\/g, "/"));
|
|
8
|
+
const UPLOAD_TYPES: Record<string, string> = {
|
|
9
|
+
html: "text/html", htm: "text/html", md: "text/markdown", markdown: "text/markdown",
|
|
10
|
+
txt: "text/plain", log: "text/plain", yaml: "text/yaml", yml: "text/yaml", csv: "text/csv", tsv: "text/tab-separated-values", json: "application/json",
|
|
11
|
+
js: "text/plain", jsx: "text/plain", mjs: "text/plain", cjs: "text/plain", ts: "text/plain", tsx: "text/plain", mts: "text/plain", cts: "text/plain", py: "text/plain", sh: "text/plain", css: "text/plain", mjsx: "text/plain", cjsx: "text/plain", mtsx: "text/plain", ctsx: "text/plain",
|
|
12
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
|
|
13
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
14
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", xlsm: "application/vnd.ms-excel.sheet.macroEnabled.12",
|
|
15
|
+
};
|
|
16
|
+
export const FILE_UPLOAD_ACCEPT = Object.keys(UPLOAD_TYPES).map(extension => `.${extension}`).join(",");
|
|
17
|
+
export const uploadFileType = (name: string) => {
|
|
18
|
+
const dot = name.lastIndexOf(".");
|
|
19
|
+
const extension = dot > 0 ? name.slice(dot + 1).toLowerCase() : undefined;
|
|
20
|
+
return extension && Object.hasOwn(UPLOAD_TYPES, extension) ? UPLOAD_TYPES[extension] : undefined;
|
|
21
|
+
};
|
|
22
|
+
export type DocumentReceipt = { uploadId: string; draftId: string; name: string; size: number; sha256: string; path?: string; url?: string };
|
|
23
|
+
export type UploadPhase = "queued" | "checking" | "uploading" | "verifying";
|
|
24
|
+
export type DocumentUploadOptions = { draftId: string; signal?: AbortSignal; onBytes?: (bytes: number) => void; onPhase?: (phase: UploadPhase) => void };
|
|
25
|
+
export type UploadDocument = (file: File, options: DocumentUploadOptions) => Promise<DocumentReceipt>;
|
|
26
|
+
const aborted = () => new DOMException("上传已取消", "AbortError");
|
|
27
|
+
function pause(ms: number, signal?: AbortSignal) {
|
|
28
|
+
return new Promise<void>((resolve, reject) => {
|
|
29
|
+
if (signal?.aborted) return reject(aborted());
|
|
30
|
+
const stop = () => { clearTimeout(timer); reject(aborted()); };
|
|
31
|
+
const timer = setTimeout(() => { signal?.removeEventListener("abort", stop); resolve(); }, ms);
|
|
32
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Blob cloning is cheap; hashing a 100 MiB file does not copy it through the UI thread. */
|
|
37
|
+
export async function hashDocument(file: Blob, signal?: AbortSignal): Promise<string> {
|
|
38
|
+
if (signal?.aborted) throw aborted();
|
|
39
|
+
if (typeof Worker !== "undefined" && typeof URL.createObjectURL === "function") {
|
|
40
|
+
const source = `onmessage=async e=>{try{const bytes=await e.data.arrayBuffer();const hash=await crypto.subtle.digest('SHA-256',bytes);postMessage({hash:Array.from(new Uint8Array(hash),b=>b.toString(16).padStart(2,'0')).join('')});}catch(e){postMessage({error:String(e)})}}`;
|
|
41
|
+
const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
|
|
42
|
+
let worker: Worker | undefined;
|
|
43
|
+
try {
|
|
44
|
+
worker = new Worker(url);
|
|
45
|
+
return await new Promise<string>((resolve, reject) => {
|
|
46
|
+
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener("abort", stop); worker!.onmessage = null; worker!.onerror = null; };
|
|
47
|
+
const stop = () => { cleanup(); reject(aborted()); };
|
|
48
|
+
const timer = setTimeout(() => { cleanup(); reject(Object.assign(new Error("文件校验超时,请重试"), { status: 408, code: "hash_timeout" })); }, 120_000);
|
|
49
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
50
|
+
worker!.onmessage = e => { cleanup(); e.data?.hash ? resolve(e.data.hash) : reject(new Error(e.data?.error || "文件校验失败,请重试")); };
|
|
51
|
+
worker!.onerror = () => { cleanup(); reject(new Error("文件校验失败,请重试")); };
|
|
52
|
+
if (signal?.aborted) { stop(); return; }
|
|
53
|
+
try { worker!.postMessage(file); } catch (error) { cleanup(); reject(error); }
|
|
54
|
+
});
|
|
55
|
+
} finally { worker?.terminate(); URL.revokeObjectURL(url); }
|
|
56
|
+
}
|
|
57
|
+
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
|
58
|
+
if (signal?.aborted) throw aborted();
|
|
59
|
+
return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, "0")).join("");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** All calls are bounded short requests, including body reads. */
|
|
63
|
+
export async function documentRequest<T>(fetcher: typeof fetch, url: string, init: RequestInit = {}, headers: () => Record<string, string> = () => ({})): Promise<T> {
|
|
64
|
+
const ctl = new AbortController();
|
|
65
|
+
const signal = init.signal;
|
|
66
|
+
const cancel = () => ctl.abort();
|
|
67
|
+
if (signal?.aborted) throw aborted();
|
|
68
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
69
|
+
const timer = setTimeout(cancel, 30_000);
|
|
70
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
71
|
+
let rejectAbort: (() => void) | undefined;
|
|
72
|
+
try {
|
|
73
|
+
const cancelled = new Promise<never>((_resolve, reject) => { rejectAbort = () => reject(aborted()); ctl.signal.addEventListener("abort", rejectAbort, { once: true }); });
|
|
74
|
+
return await Promise.race([cancelled, (async () => {
|
|
75
|
+
const response = await fetcher(url, { ...init, cache: "no-store", signal: ctl.signal, headers: { ...headers(), ...init.headers } });
|
|
76
|
+
if (ctl.signal.aborted) { void response.body?.cancel().catch(() => undefined); throw aborted(); }
|
|
77
|
+
reader = response.body?.getReader();
|
|
78
|
+
let raw = "", bytes = 0;
|
|
79
|
+
if (reader) {
|
|
80
|
+
const decoder = new TextDecoder();
|
|
81
|
+
for (;;) {
|
|
82
|
+
const part = await reader.read(); if (part.done) break;
|
|
83
|
+
bytes += part.value.byteLength;
|
|
84
|
+
if (bytes > 2 * 1024 * 1024) throw Object.assign(new Error("文件响应超过协议上限"), { status: 502, code: "response_too_large" });
|
|
85
|
+
raw += decoder.decode(part.value, { stream: true });
|
|
86
|
+
}
|
|
87
|
+
raw += decoder.decode();
|
|
88
|
+
} else raw = await response.text();
|
|
89
|
+
let value: Record<string, unknown>;
|
|
90
|
+
try { value = raw ? JSON.parse(raw) : {}; } catch { throw Object.assign(new Error("文件接口返回的数据无效"), { status: 502, code: "invalid_response" }); }
|
|
91
|
+
const retryHeader = response.headers.get("retry-after");
|
|
92
|
+
const retryHint = retryHeader ? (/^\d+(\.\d+)?$/.test(retryHeader) ? Number(retryHeader) * 1000 : Date.parse(retryHeader) - Date.now()) : undefined;
|
|
93
|
+
const retryAfterMs = retryHint !== undefined && Number.isFinite(retryHint) ? Math.max(0, retryHint) : undefined;
|
|
94
|
+
if (response.status === 202) throw Object.assign(new Error("文件正在校验"), { status: 202, retryAfterMs: retryAfterMs ?? 1000 });
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
const missing = value.error === "file_not_found" ? "文件不存在(可能已被删除或改名)" : "服务端尚未支持原件上传或增量文件同步,请升级节点和中枢";
|
|
97
|
+
const common: Record<string, string> = { file_sync_busy: "文件服务繁忙,请稍后重试", too_many_snapshot_builds: "文件服务繁忙,请稍后重试", too_many_uploads: "上传任务较多,请稍后重试", file_sync_queue_timeout: "文件处理排队超时,请稍后重试", file_sync_timeout: "文件服务响应超时,请重试", snapshot_cache_full: "文件缓存空间不足,请稍后重试", upload_storage_full: "文件上传空间不足,请稍后重试" };
|
|
98
|
+
const error = new Error(common[String(value.error)] ?? String(value.detail || (response.status === 404 ? missing : value.error) || `文件请求失败(HTTP ${response.status})`));
|
|
99
|
+
Object.assign(error, { status: response.status, code: value.error, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) });
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
return value as T;
|
|
103
|
+
})()]);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (signal?.aborted) throw aborted();
|
|
106
|
+
if (ctl.signal.aborted) throw Object.assign(new Error("文件请求超时,请重试"), { status: 0, code: "timeout" });
|
|
107
|
+
throw error;
|
|
108
|
+
} finally { clearTimeout(timer); signal?.removeEventListener("abort", cancel); if (rejectAbort) ctl.signal.removeEventListener("abort", rejectAbort); ctl.abort(); void reader?.cancel().catch(() => undefined); }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const uploadQueues = new WeakMap<typeof fetch, ReturnType<typeof createFileWorkQueue>>();
|
|
112
|
+
async function retryUploadRequest<T>(work: () => Promise<T>, signal: AbortSignal | undefined, busyOnly = false, deadline = Date.now() + 180_000): Promise<T> {
|
|
113
|
+
for (let attempt = 0; ; attempt++) {
|
|
114
|
+
if (signal?.aborted) throw aborted();
|
|
115
|
+
try { return await work(); } catch (error) {
|
|
116
|
+
const failure = error as { status?: number; code?: string; name?: string; retryAfterMs?: number };
|
|
117
|
+
const status = failure.status ?? 0;
|
|
118
|
+
const retryable = busyOnly ? status === 429 : !status || [408, 429, 500, 502, 503, 504].includes(status);
|
|
119
|
+
if (signal?.aborted || failure.name === "AbortError" || !retryable || attempt >= 3 || ["invalid_response", "response_too_large", "integrity", "client_queue_full"].includes(failure.code ?? "")) throw error;
|
|
120
|
+
const base = 500 * 2 ** attempt, hint = Number(failure.retryAfterMs ?? 0);
|
|
121
|
+
const delay = Math.max(base, Number.isFinite(hint) ? hint : 0) + Math.floor(Math.random() * base / 4);
|
|
122
|
+
if (delay > deadline - Date.now()) throw error;
|
|
123
|
+
await pause(delay, signal);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export function createDocumentUploader(fetcher: typeof fetch, base: string, headers: () => Record<string, string> = () => ({})): UploadDocument {
|
|
128
|
+
// Share hashing/upload slots across tasks using this client, with cancellable bounded waiters.
|
|
129
|
+
let queue = uploadQueues.get(fetcher);
|
|
130
|
+
if (!queue) { queue = createFileWorkQueue(1, 128); uploadQueues.set(fetcher, queue); }
|
|
131
|
+
return (file, options) => {
|
|
132
|
+
options.onPhase?.("queued");
|
|
133
|
+
const upload = async (): Promise<DocumentReceipt> => {
|
|
134
|
+
const { signal, onBytes, onPhase, draftId } = options;
|
|
135
|
+
if (signal?.aborted) throw aborted();
|
|
136
|
+
const type = uploadFileType(file.name);
|
|
137
|
+
if (!type) throw new Error("暂不支持此文件类型,请选择文档、图片或文本文件");
|
|
138
|
+
const maxBytes = isOriginalDocument(file.name) ? DOCUMENT_MAX_BYTES : UPLOAD_OTHER_MAX_BYTES;
|
|
139
|
+
if (file.size > maxBytes) throw new Error(`“${file.name}”超过 ${isOriginalDocument(file.name) ? "100" : "10"} MiB,请缩小后重试。`);
|
|
140
|
+
if (new TextEncoder().encode(file.name).length > 240) throw new Error("文件名超过 240 个 UTF-8 字节,请缩短文件名后重试。");
|
|
141
|
+
onPhase?.("checking");
|
|
142
|
+
const sha256 = await hashDocument(file, signal);
|
|
143
|
+
const request = <T>(path: string, init: RequestInit) => documentRequest<T>(fetcher, `${base}${path}`, { ...init, signal }, headers);
|
|
144
|
+
const session = await retryUploadRequest(() => request<{ uploadId: string; chunkBytes: number }>("/uploads", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draftId, name: file.name, size: file.size, type, sha256 }) }), signal, true);
|
|
145
|
+
if (!session.uploadId || session.chunkBytes !== DOCUMENT_CHUNK_BYTES) throw new Error("上传协议不匹配,请升级服务端");
|
|
146
|
+
const root = `/uploads/${encodeURIComponent(session.uploadId)}`;
|
|
147
|
+
try {
|
|
148
|
+
onPhase?.("uploading");
|
|
149
|
+
onBytes?.(0);
|
|
150
|
+
for (let start = 0, index = 0; start < file.size; start += session.chunkBytes, index++) {
|
|
151
|
+
const body = file.slice(start, Math.min(file.size, start + session.chunkBytes));
|
|
152
|
+
// Only these numbered chunks are safely idempotent after ambiguous network failures.
|
|
153
|
+
await retryUploadRequest(() => request(`${root}/chunks/${index}`, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body }), signal);
|
|
154
|
+
onBytes?.(Math.min(file.size, start + session.chunkBytes));
|
|
155
|
+
}
|
|
156
|
+
onPhase?.("verifying");
|
|
157
|
+
const deadline = Date.now() + 180_000;
|
|
158
|
+
for (;;) {
|
|
159
|
+
try {
|
|
160
|
+
const result = await retryUploadRequest(() => request<DocumentReceipt>(`${root}/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draftId }) }), signal, false, deadline);
|
|
161
|
+
if (result.sha256 !== sha256) throw new Error("上传文件校验不一致,请重试");
|
|
162
|
+
return { ...result, draftId, name: file.name, size: file.size };
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const pending = error as { status?: number; retryAfterMs?: number };
|
|
165
|
+
if (pending.status !== 202 || Date.now() >= deadline) throw error;
|
|
166
|
+
const delay = Math.max(100, pending.retryAfterMs ?? 1000);
|
|
167
|
+
if (delay > deadline - Date.now()) throw error;
|
|
168
|
+
await pause(delay, signal);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
void documentRequest(fetcher, `${base}${root}`, { method: "DELETE" }, headers).catch(() => undefined);
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
return queue.run(upload, options.signal);
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function documentMarker(receipt: DocumentReceipt) {
|
|
181
|
+
if (receipt.path) {
|
|
182
|
+
if (/[\r\n\]]/.test(receipt.path)) throw new Error("上传文件路径无效,请重试");
|
|
183
|
+
return `[Attached ${uploadFileType(receipt.name)?.startsWith("image/") ? "image" : "file"}: ${receipt.path}]`;
|
|
184
|
+
}
|
|
185
|
+
const name = receipt.name.replace(/[\r\n\[\]]/g, " ");
|
|
186
|
+
return `[文件原件:${name}](cli-upload://${encodeURIComponent(receipt.draftId)}/${encodeURIComponent(receipt.uploadId)})`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function extractDocumentReferences(text: string) {
|
|
190
|
+
const attachments: Array<{ uploadId: string; draftId: string }> = [];
|
|
191
|
+
const clean = text.replace(/\[文件原件:([^\]\r\n]*)\]\(cli-upload:\/\/([^/\s)]+)\/([^\s)]+)\)/g, (_match, name, draft, upload) => {
|
|
192
|
+
try { attachments.push({ draftId: decodeURIComponent(draft), uploadId: decodeURIComponent(upload) }); } catch { throw new Error("文件引用无效,请移除后重新上传"); }
|
|
193
|
+
return `[文件原件:${name}]`;
|
|
194
|
+
});
|
|
195
|
+
return { text: clean, ...(attachments.length ? { attachments } : {}) };
|
|
196
|
+
}
|
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;
|