@spzxg/cli-ui 0.6.6 → 0.6.8

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.
Files changed (79) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +90 -21
  3. package/dist/cli-ui-embed-Be9_d9jB.js +49691 -0
  4. package/dist/{cli-ui-index-b8wF7wrN.js → cli-ui-index-CzNfiynu.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-B0uLrCHg.js → cli-ui-jszip.min-BL0mSekN.js} +2 -2
  6. package/dist/{cli-ui-jszip.min-ggP8OokP.js → cli-ui-jszip.min-DjCIAfse.js} +1 -1
  7. package/dist/{cli-ui-xlsx-DEDdHrJJ.js → cli-ui-xlsx-CscI4xk0.js} +1 -1
  8. package/dist/cli-ui.css +1 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +3 -1
  11. package/src/board/CliBoard.tsx +128 -100
  12. package/src/board/FileViewer.tsx +259 -132
  13. package/src/board/FilesPane.tsx +129 -40
  14. package/src/board/HtmlReadingPreview.tsx +52 -0
  15. package/src/board/MarkdownPreview.tsx +134 -0
  16. package/src/board/PanelControls.tsx +19 -7
  17. package/src/board/fileKind.ts +138 -0
  18. package/src/board/fileOps.ts +9 -2
  19. package/src/board/fileScope.ts +29 -0
  20. package/src/board/fileSync.ts +343 -0
  21. package/src/board/fileSyncCache.ts +250 -0
  22. package/src/board/fileSyncProcessor.ts +71 -0
  23. package/src/board/fileSyncQueue.ts +56 -0
  24. package/src/board/fileSyncTransport.ts +85 -0
  25. package/src/board/fileWatch.ts +177 -76
  26. package/src/board/folderDownload.ts +39 -5
  27. package/src/board/frameQuote.ts +10 -8
  28. package/src/board/htmlReading.ts +148 -0
  29. package/src/board/textLoad.ts +14 -39
  30. package/src/board/useBoardUpload.tsx +51 -0
  31. package/src/board/useBoardVisibility.ts +35 -0
  32. package/src/board/workspace.ts +38 -6
  33. package/src/chat/CliChat.tsx +121 -51
  34. package/src/chat/Composer.tsx +54 -22
  35. package/src/chat/GroupChat.tsx +113 -0
  36. package/src/chat/RunControls.tsx +143 -46
  37. package/src/chat/draft.ts +12 -4
  38. package/src/chat/useExecutionConfig.ts +93 -0
  39. package/src/components.tsx +1 -1
  40. package/src/core/blocks.ts +2 -1
  41. package/src/core/client.ts +65 -61
  42. package/src/core/config.ts +6 -2
  43. package/src/core/documentUpload.ts +196 -0
  44. package/src/core/execution.ts +24 -0
  45. package/src/core/store.ts +81 -20
  46. package/src/core/types.ts +3 -1
  47. package/src/index.ts +4 -0
  48. package/src/msglist/CliMsgList.tsx +9 -2
  49. package/src/operator/node/Chat.tsx +11 -9
  50. package/src/operator/node/Composer.tsx +103 -75
  51. package/src/operator/node/Orchestrator.tsx +1 -1
  52. package/src/operator/node/SessionRail.tsx +71 -32
  53. package/src/operator/node/SessionView.tsx +31 -28
  54. package/src/operator/node/Transcript.tsx +21 -9
  55. package/src/operator/node/boardBackend.ts +0 -19
  56. package/src/operator/node/boardEvents.ts +21 -3
  57. package/src/operator/node/boardState.ts +18 -1
  58. package/src/operator/node/boardWorkspace.ts +4 -1
  59. package/src/operator/node/composerDraft.ts +6 -2
  60. package/src/operator/node/format.ts +4 -2
  61. package/src/operator/node/liveStream.ts +14 -2
  62. package/src/operator/node/useTaskStream.ts +3 -1
  63. package/src/operator.base.css +150 -43
  64. package/src/operator.css +150 -43
  65. package/src/styles.css +253 -39
  66. package/src/ui/Markdown.tsx +2 -2
  67. package/src/ui/Popover.tsx +25 -12
  68. package/src/ui/QueuedMessages.tsx +69 -0
  69. package/src/ui/ReferenceCard.tsx +28 -0
  70. package/src/ui/TextBlock.tsx +6 -4
  71. package/src/ui/Transcript.tsx +20 -10
  72. package/src/ui/UploadProgress.tsx +46 -0
  73. package/src/ui/WorkspaceDrawer.tsx +64 -0
  74. package/src/ui/WorkspaceTabs.tsx +60 -0
  75. package/src/ui/format.ts +3 -1
  76. package/src/ui/reference.ts +40 -0
  77. package/src/ui/useChatScrollbar.ts +29 -0
  78. package/src/ui/useComposerAutosize.ts +54 -0
  79. package/dist/cli-ui-embed-BAqonuoD.js +0 -45952
@@ -0,0 +1,93 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { CenterError, type ConversationBackend } from "../core/client";
3
+ import type { ExecutionConfiguration, ExecutionOptions } from "../core/execution";
4
+ import { isConfigurationBusy, useConfigurationBusy } from "./configuration";
5
+ import { readComposerDraft, updateComposerDraft, useComposerDraft } from "./draft";
6
+
7
+ const errorText = (error: unknown) => error instanceof CenterError && [404, 405, 501].includes(error.status)
8
+ ? "当前服务尚未支持执行设置,请升级配套服务。"
9
+ : error instanceof Error ? error.message : "执行设置读取失败,请重试。";
10
+
11
+ function validate(result: ExecutionConfiguration) {
12
+ if (!result || typeof result.agent !== "string" || !Number.isSafeInteger(result.revision) || result.revision < 0 || !Array.isArray(result.agents)
13
+ || result.agents.some(agent => typeof agent?.id !== "string" || typeof agent.label !== "string" || !agent.capabilities
14
+ || [agent.capabilities.models, agent.capabilities.reasoningOptions, agent.capabilities.permissionModes].some(options => !Array.isArray(options)
15
+ || options.some(option => typeof option?.value !== "string" || typeof option.label !== "string")))) {
16
+ throw new Error("服务未返回有效的执行设置,请检查配套服务版本。");
17
+ }
18
+ }
19
+
20
+ /** 按对话隔离读取与保存,不轮询;换对话后的迟到响应不覆盖当前选择。 */
21
+ export function useExecutionConfig({ client, scope, target, enabled, initial, running }: {
22
+ client: ConversationBackend; scope: string; target: string | null; enabled: boolean;
23
+ initial?: ExecutionOptions; running: boolean;
24
+ }) {
25
+ const [view, setView] = useState<{ scope: string; config?: ExecutionConfiguration; error?: string; loading: boolean; saveFailed?: boolean }>({ scope, loading: false });
26
+ const current = useRef(scope);
27
+ const request = useRef(0);
28
+ const mounted = useRef(true);
29
+ useLayoutEffect(() => { current.current = scope; request.current++; }, [scope]);
30
+ useEffect(() => { mounted.current = true; return () => { mounted.current = false; request.current++; }; }, []);
31
+ const draft = useComposerDraft(scope);
32
+ const busy = useConfigurationBusy(scope);
33
+ const config = view.scope === scope ? view.config : undefined;
34
+ const reload = useCallback(async () => {
35
+ const serial = ++request.current;
36
+ const active = () => mounted.current && current.current === scope && request.current === serial;
37
+ if (!enabled && !readComposerDraft(scope).executionUncertain) return;
38
+ if (!client.getExecutionConfig) {
39
+ setView({ scope, loading: false, error: "当前接入尚未提供执行设置。" });
40
+ return;
41
+ }
42
+ setView(value => ({ ...(value.scope === scope ? value : {}), scope, loading: true }));
43
+ try {
44
+ const result = await client.getExecutionConfig(target);
45
+ validate(result);
46
+ if (active()) {
47
+ if (!isConfigurationBusy(scope) && readComposerDraft(scope).executionUncertain) updateComposerDraft(scope, draft => ({ ...draft, executionUncertain: false }));
48
+ setView({ scope, config: result, loading: false });
49
+ }
50
+ } catch (error) {
51
+ if (active()) setView(value => ({ ...(value.scope === scope ? value : {}), scope, error: errorText(error), loading: false }));
52
+ }
53
+ }, [client, scope, target, enabled]);
54
+ useEffect(() => { void reload(); return () => { request.current++; }; }, [reload]);
55
+ const wasRunning = useRef(running);
56
+ useEffect(() => {
57
+ if (wasRunning.current && !running && !isConfigurationBusy(scope)) void reload();
58
+ wasRunning.current = running;
59
+ }, [running, reload, scope]);
60
+
61
+ const local = target === null ? { ...initial, ...draft.executionConfig } : undefined;
62
+ const selected = config ? { ...config, ...local } : undefined;
63
+ const save = async (changes: ExecutionOptions) => {
64
+ if (!config || !enabled || running) throw new Error("当前无法修改执行设置,请稍后重试。");
65
+ if (target === null) {
66
+ updateComposerDraft(scope, value => ({ ...value, executionConfig: { agent: config.agent, ...initial, ...value.executionConfig, ...changes } }));
67
+ return;
68
+ }
69
+ if (!client.setExecutionConfig) throw new Error("当前接入尚未提供保存执行设置的接口。");
70
+ // An earlier GET must not overwrite the response to this save.
71
+ const serial = ++request.current;
72
+ updateComposerDraft(scope, draft => ({ ...draft, executionUncertain: true }));
73
+ try {
74
+ const result = await client.setExecutionConfig(target, changes, config.revision);
75
+ validate(result);
76
+ updateComposerDraft(scope, draft => ({ ...draft, executionUncertain: false }));
77
+ if (mounted.current && current.current === scope && request.current === serial) setView({ scope, config: result, loading: false });
78
+ } catch (error) {
79
+ if (mounted.current && current.current === scope && request.current === serial) setView({ scope, config, loading: false, saveFailed: true, error: `${errorText(error)} 请重新读取设置后再发送。` });
80
+ throw error;
81
+ }
82
+ };
83
+ return {
84
+ config: selected, busy, loading: view.scope === scope && view.loading,
85
+ error: view.scope === scope && (enabled || view.saveFailed || draft.executionUncertain) ? view.error ?? (draft.executionUncertain && !busy ? "执行设置尚未确认,请重新读取后再发送。" : undefined) : undefined,
86
+ blocked: busy || !!draft.executionUncertain || (view.scope === scope && !!view.saveFailed), reload, save,
87
+ /** Read at submit time, including changes committed before React paints. */
88
+ prepareSend: () => {
89
+ if (isConfigurationBusy(scope) || readComposerDraft(scope).executionUncertain || (view.scope === scope && view.saveFailed)) throw new Error("请等待执行设置保存或重新读取设置后再发送。");
90
+ return target === null ? { executionConfig: readComposerDraft(scope).executionConfig } : { expectedExecutionRevision: config?.revision };
91
+ },
92
+ };
93
+ }
@@ -9,5 +9,5 @@ export interface CliMsgListProps extends CenterMsgListProps { operator?: Compone
9
9
  export interface CliChatProps extends CenterChatProps { operator?: ComponentProps<typeof SessionView> }
10
10
  export interface CliBoardProps extends CenterBoardProps { operator?: ComponentProps<typeof SessionRail> }
11
11
  export function CliMsgList(props: CliMsgListProps) { return props.operator ? <TasksColumn {...props.operator}/> : <CenterMsgList {...props}/>; }
12
- export function CliChat(props: CliChatProps) { return props.operator ? <SessionView {...props.operator} board={{...props.operator.board,autoContext:props.operator.board?.autoContext??props.passiveContext,onFilesChange:props.operator.board?.onFilesChange??props.onFilesChange,onFilesWatch:props.operator.board?.onFilesWatch??props.onFilesWatch,onBoardContextChange:props.operator.board?.onBoardContextChange??props.onBoardContextChange}}/> : <CenterChat {...props}/>; }
12
+ export function CliChat(props: CliChatProps) { return props.operator ? <SessionView {...props.operator} composer={props.config?.composer ?? props.operator.composer} board={{...props.operator.board,autoContext:props.operator.board?.autoContext??props.passiveContext,onFilesChange:props.operator.board?.onFilesChange??props.onFilesChange,onFilesWatch:props.operator.board?.onFilesWatch??props.onFilesWatch,onBoardContextChange:props.operator.board?.onBoardContextChange??props.onBoardContextChange}}/> : <CenterChat {...props}/>; }
13
13
  export function CliBoard(props: CliBoardProps) { return props.operator ? <SessionRail key={props.operator.task.id} {...props.operator} tabs={props.operator.tabs ?? props.tabs} availableTabs={props.operator.availableTabs ?? props.availableTabs} defaultTab={props.operator.defaultTab ?? props.defaultTab} autoContext={props.operator.autoContext ?? props.autoQuoteOpenFile} watchFiles={props.operator.watchFiles ?? props.watchFiles} fileWatchIntervalMs={props.operator.fileWatchIntervalMs ?? props.fileWatchIntervalMs} onFilesChange={props.operator.onFilesChange ?? props.onFilesChange} onFilesWatch={props.operator.onFilesWatch ?? props.onFilesWatch} onBoardContextChange={props.operator.onBoardContextChange ?? props.onBoardContextChange} onEvent={props.operator.onEvent ?? props.onEvent}/> : <CenterBoard {...props}/>; }
@@ -88,7 +88,8 @@ export function serializeBlock(block: TextBlock): string {
88
88
 
89
89
  /** 输入框里的文字 + 文本块 → 发给 center 的正文。 */
90
90
  export function composeMessage(typed: string, blocks: TextBlock[]): string {
91
- return [typed.trim(), ...blocks.map(serializeBlock)].filter(Boolean).join("\n\n");
91
+ const originals = blocks.some(block => block.kind === "file" && (/^\[Attached (?:file|image): /.test(block.text) || /^\[文件原件:.*\]\(cli-upload:\/\//.test(block.text)));
92
+ return [typed.trim(), originals ? "请先读取以下附件原件,再处理本条消息。" : "", ...blocks.map(serializeBlock)].filter(Boolean).join("\n\n");
92
93
  }
93
94
 
94
95
  export type MessagePart = { kind: "text"; text: string } | { kind: "block"; block: TextBlock };
@@ -5,6 +5,10 @@ import type {
5
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";
11
+ import type { ExecutionConfiguration, ExecutionOptions } from "./execution";
8
12
 
9
13
  /**
10
14
  * center 服务(cli-task-center)对话任务接口的浏览器端客户端。组件的数据全部从这里来。
@@ -92,6 +96,12 @@ const ERROR_TEXT: Record<string, string> = {
92
96
  * 带 ? 的是可选能力:后端不提供,界面就按 Operator 在「没有这个能力」时的样子显示(例如提问的回答改走下一条消息)。
93
97
  */
94
98
  export interface ConversationBackend {
99
+ /** 无 key 时读取新对话可用设置;有 key 时读取该对话的已保存设置。 */
100
+ getExecutionConfig?(key?: string | null): Promise<ExecutionConfiguration>;
101
+ /** 保存成功才切换界面;服务端用 revision 防止并发覆盖。 */
102
+ setExecutionConfig?(key: string, changes: ExecutionOptions, expectedRevision: number): Promise<ExecutionConfiguration>;
103
+ /** Upload original HTML/Markdown without embedding document bytes in a message. */
104
+ uploadDocument?: UploadDocument;
95
105
  baseUrl: string;
96
106
  getJob(jobId: string): Promise<CenterJob>;
97
107
  /** taskId:只要对话任务里这一个对话的轮次。 */
@@ -104,7 +114,7 @@ export interface ConversationBackend {
104
114
  history?(jobId: string, remote: string, before: string, limit?: number): Promise<{ messages: Message[]; older: number }>;
105
115
  createConversation(firstMessage: string, opts?: NewConversationOptions & { businessId?: string }): Promise<{ id: string; job_id: string; status: string; task_ids?: string[]; turn: CenterTurn }>;
106
116
  /** taskId:发给对话任务里的哪个对话,不给 = 第一个。 */
107
- sendMessage(jobId: string, text: string, opts?: { businessId?: string; timeoutSeconds?: number; taskId?: string }): Promise<SendResult>;
117
+ sendMessage(jobId: string, text: string, opts?: { businessId?: string; timeoutSeconds?: number; taskId?: string; expectedExecutionRevision?: number }): Promise<SendResult>;
108
118
  /**
109
119
  * 一次性任务里的对话式子任务接着聊:续派一条 mode=conversation、continue_from 这个子任务的子任务(接在同一个 Agent 会话里)。
110
120
  * 返回新子任务的 id。
@@ -114,7 +124,7 @@ export interface ConversationBackend {
114
124
  pauseTask?(taskId: string): Promise<CenterTask>;
115
125
  resumeTask?(taskId: string): Promise<CenterTask>;
116
126
  /** 在对话任务里新开一个对话(同一节点、同一工作区),text 是它的第一条消息。 */
117
- openConversation?(jobId: string, text: string, opts?: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number }): Promise<SendResult>;
127
+ openConversation?(jobId: string, text: string, opts?: { title?: string; agent?: string; executionConfig?: ExecutionOptions; browser?: boolean; businessId?: string; timeoutSeconds?: number }): Promise<SendResult>;
118
128
  cancelTurn(jobId: string, turnId: string): Promise<CenterTurn>;
119
129
  closeConversation(jobId: string): Promise<{ id: string; status: string }>;
120
130
  /** 只关掉对话任务里的一个对话(别的对话和对话任务照常)。 */
@@ -143,6 +153,15 @@ export interface ConversationBackend {
143
153
  * 方法里的 jobId 也可以是对话键("job_x#task_y"):看对话任务里那个对话的文件、改动和浏览器。
144
154
  */
145
155
  export interface BoardBackend {
156
+ /** Save an original file to this conversation without sending a message or starting the agent. */
157
+ uploadFile?(jobId: string, file: File, options: DocumentUploadOptions): Promise<DocumentReceipt>;
158
+ /** Authorized input directory of this task; browsing it never starts an agent turn. */
159
+ uploadDirectory?(jobId: string, signal?: AbortSignal): Promise<string | null>;
160
+ /** Immutable revisions and content-addressed blocks; refreshes reuse validated local blocks. */
161
+ fileSync?: FileSyncTransport;
162
+ /** One directory page only. No recursive scan or file body prefetch. */
163
+ listFiles?(jobId: string, dir: string, options?: { cursor?: string; limit?: number; signal?: AbortSignal }): Promise<DirListing>;
164
+ pollFiles?(jobId: string, scope: { paths: string[]; dirs: string[]; cursor?: string }, signal?: AbortSignal): Promise<WorkspaceFileBatch & { cursor?: string; directories: Array<{ path: string; revision: string | null }> }>;
146
165
  /** fileUrl 给出的地址浏览器能直接打开(不需要额外请求头)。false 时看板先 fetch 再显示。 */
147
166
  directLinks: boolean;
148
167
  info(jobId: string): Promise<BoardInfo>;
@@ -283,9 +302,19 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
283
302
 
284
303
  const post = <T>(path: string, body: unknown = {}) => request<T>(path, { method: "POST", body: JSON.stringify(body) });
285
304
  const id = encodeURIComponent;
305
+ const executionPath = (key?: string | null) => {
306
+ if (!key) return "/api/execution-config";
307
+ const ref = parseConvKey(key);
308
+ return `/api/jobs/${id(ref.jobId)}/execution-config${ref.taskId && ref.taskId !== NEW_TASK ? `?task_id=${id(ref.taskId)}` : ""}`;
309
+ };
286
310
 
287
311
  return {
288
312
  baseUrl: base,
313
+ getExecutionConfig: key => request<ExecutionConfiguration>(executionPath(key)),
314
+ setExecutionConfig: (key, changes, expectedRevision) => request<ExecutionConfiguration>(executionPath(key), {
315
+ method: "PATCH", body: JSON.stringify({ ...changes, expected_revision: expectedRevision }),
316
+ }),
317
+ uploadDocument: createDocumentUploader(doFetch, `${base}/api/file-sync`, headers),
289
318
  getJob: (jobId: string) => request<CenterJob>(`/api/jobs/${id(jobId)}`),
290
319
  listTurns: (jobId: string, query: { afterSeq?: number; limit?: number; taskId?: string } = {}) => {
291
320
  const params = new URLSearchParams();
@@ -326,9 +355,11 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
326
355
  ...(opts.callback ? { callback: opts.callback } : {}),
327
356
  tasks: [{
328
357
  title,
329
- prompt: firstMessage,
358
+ prompt: extractDocumentReferences(firstMessage).text,
359
+ ...(extractDocumentReferences(firstMessage).attachments ? { attachments: extractDocumentReferences(firstMessage).attachments } : {}),
330
360
  ...(opts.businessId ? { business_id: opts.businessId } : {}),
331
361
  ...(opts.agent ? { agent: opts.agent } : {}),
362
+ ...(opts.executionConfig ? { execution_config: opts.executionConfig } : {}),
332
363
  ...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
333
364
  ...(opts.browser !== undefined ? { browser: opts.browser } : {}),
334
365
  ...(opts.browserPlaybook !== undefined ? { browser_playbook: opts.browserPlaybook } : {}),
@@ -337,28 +368,30 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
337
368
  });
338
369
  },
339
370
  /** 带 business_id 时 center 按它去重,网络超时可以放心重发同一条。 */
340
- sendMessage: (jobId: string, text: string, opts: { businessId?: string; timeoutSeconds?: number; taskId?: string } = {}) =>
371
+ sendMessage: (jobId: string, text: string, opts: { businessId?: string; timeoutSeconds?: number; taskId?: string; expectedExecutionRevision?: number } = {}) =>
341
372
  post<SendResult>(`/api/jobs/${id(jobId)}/messages`, {
342
- text,
373
+ ...extractDocumentReferences(text),
343
374
  ...(opts.taskId ? { task_id: opts.taskId } : {}),
375
+ ...(opts.expectedExecutionRevision !== undefined ? { expected_execution_revision: opts.expectedExecutionRevision } : {}),
344
376
  ...(opts.businessId ? { business_id: opts.businessId } : {}),
345
377
  ...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
346
378
  }),
347
379
  continueTask: async (jobId: string, taskId: string, text: string, opts: { title?: string; businessId?: string } = {}) => {
348
380
  const result = await post<{ task_ids: string[] }>(`/api/jobs`, {
349
381
  job_id: jobId, mode: "conversation",
350
- tasks: [{ title: opts.title || firstLine(text, 60), prompt: text, continue_from: taskId, ...(opts.businessId ? { business_id: opts.businessId } : {}) }],
382
+ 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 } : {}) }],
351
383
  });
352
384
  return { task_id: result.task_ids[0] };
353
385
  },
354
386
  pauseTask: (taskId: string) => post<CenterTask>(`/api/tasks/${id(taskId)}/pause`, {}),
355
387
  resumeTask: (taskId: string) => post<CenterTask>(`/api/tasks/${id(taskId)}/resume`, {}),
356
388
  /** 同一个 business_id 重发只开一个对话。 */
357
- openConversation: (jobId: string, text: string, opts: { title?: string; agent?: string; browser?: boolean; businessId?: string; timeoutSeconds?: number } = {}) =>
389
+ openConversation: (jobId: string, text: string, opts: { title?: string; agent?: string; executionConfig?: ExecutionOptions; browser?: boolean; businessId?: string; timeoutSeconds?: number } = {}) =>
358
390
  post<SendResult>(`/api/jobs/${id(jobId)}/conversations`, {
359
- text,
391
+ ...extractDocumentReferences(text),
360
392
  ...(opts.title ? { title: opts.title } : {}),
361
393
  ...(opts.agent ? { agent: opts.agent } : {}),
394
+ ...(opts.executionConfig ? { execution_config: opts.executionConfig } : {}),
362
395
  ...(opts.browser !== undefined ? { browser: opts.browser } : {}),
363
396
  ...(opts.businessId ? { business_id: opts.businessId } : {}),
364
397
  ...(opts.timeoutSeconds ? { timeout_seconds: opts.timeoutSeconds } : {}),
@@ -534,31 +567,6 @@ function boardPath(key: string, rest: string, query = "") {
534
567
  return `/api/jobs/${job}/${rest}${params ? `?${params}` : ""}`;
535
568
  }
536
569
 
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
-
562
570
  function createBoardClient(
563
571
  base: string,
564
572
  headers: (extra?: Record<string, string>) => Record<string, string>,
@@ -698,37 +706,28 @@ function createBoardClient(
698
706
  const minting = new Map<string, Promise<string | null>>();
699
707
  let linksUnsupported = false;
700
708
  const LINK_REFRESH_MS = 5 * 60_000;
709
+ const uploads = new Map<string, UploadDocument>();
710
+ const fileSync = createFileSyncTransport(doFetch, (jobId, operation) => `${base}${boardPath(jobId, `file-sync/${operation}`)}`, headers);
701
711
  return {
702
712
  directLinks,
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(); };
713
+ uploadFile(jobId, file, options) {
714
+ let upload = uploads.get(jobId);
715
+ if (!upload) {
716
+ const { job, task } = boardTarget(jobId);
717
+ upload = createDocumentUploader(doFetch, `${base}/api/jobs/${job}${task ? `/tasks/${task}` : ""}/file-sync`, headers);
718
+ uploads.set(jobId, upload);
719
+ }
720
+ return upload(file, options);
730
721
  },
731
- stat: (jobId, path = ".") => request<DirListing | FileInfo>(boardPath(jobId, "files", `path=${encodeURIComponent(path)}`)),
722
+ async uploadDirectory(jobId, signal) {
723
+ const capabilities = await documentRequest<{ uploadsPath?: string }>(doFetch, `${base}${boardPath(jobId, "file-sync/capabilities")}`, { signal }, headers);
724
+ return typeof capabilities.uploadsPath === "string" ? capabilities.uploadsPath : null;
725
+ },
726
+ fileSync,
727
+ 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),
728
+ info: (jobId) => request<BoardInfo>(boardPath(jobId, "board")),
729
+ 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),
730
+ stat: (jobId, path = ".") => documentRequest<DirListing | FileInfo>(doFetch, `${base}${boardPath(jobId, "file-sync/stat", `path=${encodeURIComponent(path)}`)}`, {}, headers),
732
731
  fileUrl: (jobId, path, opts = {}) => boardFileUrl(base, jobId, path, opts.download),
733
732
  async fileLinkBase(jobId) {
734
733
  const { job, task } = boardTarget(jobId);
@@ -761,6 +760,11 @@ function createBoardClient(
761
760
  return { bytes, start, size };
762
761
  },
763
762
  async readBuffer(jobId, path, opts = {}) {
763
+ if (isTaskUploadPath(path)) {
764
+ const file = await syncFile(fileSync, jobId, path, { signal: opts.signal, decode: false,
765
+ onProgress: progress => opts.onBytes?.(progress.loadedBytes) });
766
+ return file.bytes.buffer as ArrayBuffer;
767
+ }
764
768
  const { body } = await fileFetch(jobId, path, {}, opts.signal);
765
769
  return (await body({ onBytes: opts.onBytes })).buffer;
766
770
  },
@@ -45,6 +45,10 @@ export interface CliChatConfig {
45
45
  charCount?: boolean;
46
46
  /** 文件上传(按钮、拖拽、粘贴文件)。只收文档:Word、Excel、Markdown、TXT、HTML、CSV。 */
47
47
  fileUpload?: boolean;
48
+ /** 智能体选择入口,默认显示;缺少服务端能力时禁用。 */
49
+ agentSelector?: boolean;
50
+ /** 执行模式选择入口,默认显示;缺少服务端能力时禁用。 */
51
+ executionMode?: boolean;
48
52
  /** 「文件」按钮的文字。 */
49
53
  fileButtonText?: string;
50
54
  /** 执行控制:普通对话停止当前轮次,一次性子任务暂停 / 继续。 */
@@ -87,7 +91,7 @@ export interface ResolvedChatConfig {
87
91
  header: { show: boolean; crumb: boolean; export: boolean; title: boolean; agent: boolean; usage: boolean; model: boolean; status: boolean; close: boolean };
88
92
  composer: {
89
93
  show: boolean; placeholder: string; placeholderNew: string; placeholderRunning: string; placeholderDisabled: string | null;
90
- hints: boolean; charCount: boolean; fileUpload: boolean; fileButtonText: string; stop: boolean; queue: boolean;
94
+ hints: boolean; charCount: boolean; fileUpload: boolean; agentSelector: boolean; executionMode: boolean; fileButtonText: string; stop: boolean; queue: boolean;
91
95
  };
92
96
  transcript: { nav: boolean; sessionLabel: boolean; timestamps: boolean; initialPrompt: boolean; initialBadge: boolean; tools: boolean; runningIndicator: boolean; status: boolean };
93
97
  labels: { user: string; agent: string };
@@ -108,7 +112,7 @@ export const DEFAULT_CHAT_CONFIG: ResolvedChatConfig = {
108
112
  placeholderNew: "输入消息,开始新对话…",
109
113
  placeholderRunning: "排队一条后续消息…(本轮结束后发送)",
110
114
  placeholderDisabled: null,
111
- hints: true, charCount: true, fileUpload: true, fileButtonText: "文件", stop: true, queue: true,
115
+ hints: false, charCount: false, fileUpload: true, agentSelector: true, executionMode: true, fileButtonText: "", stop: true, queue: true,
112
116
  },
113
117
  transcript: { nav: true, sessionLabel: true, timestamps: true, initialPrompt: false, initialBadge: true, tools: true, runningIndicator: true, status: false },
114
118
  labels: { user: "你", agent: "智能体" },
@@ -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
+ }
@@ -0,0 +1,24 @@
1
+ import type { RunCapabilities } from "../chat/RunControls";
2
+
3
+ /** 用户选择的下一轮执行设置;不包含账号或凭据。null 表示使用服务端默认值。 */
4
+ export interface ExecutionOptions {
5
+ agent?: string;
6
+ model?: string | null;
7
+ reasoning?: string | null;
8
+ permission_mode?: string | null;
9
+ }
10
+
11
+ export interface ExecutionConfiguration {
12
+ revision: number;
13
+ agent: string;
14
+ model: string | null;
15
+ reasoning: string | null;
16
+ permission_mode: string | null;
17
+ agents: Array<{
18
+ id: string;
19
+ label: string;
20
+ disabled?: boolean;
21
+ capabilities: RunCapabilities;
22
+ defaults?: Omit<ExecutionOptions, "agent">;
23
+ }>;
24
+ }