@xgspz/cli-ui 0.0.0-stage → 0.6.14

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 (169) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +512 -2
  3. package/dist/cli-ui-embed-D-3DvyTN.js +50279 -0
  4. package/dist/cli-ui-index-C34qFb4u.js +11209 -0
  5. package/dist/cli-ui-jszip.min-BObcE2nn.js +26 -0
  6. package/dist/cli-ui-jszip.min-DojbZPiE.js +2324 -0
  7. package/dist/cli-ui-xlsx-JVLtx0qR.js +93 -0
  8. package/dist/cli-ui.css +32 -0
  9. package/dist/cli-ui.js +17 -0
  10. package/package.json +60 -4
  11. package/src/board/BrowserPane.tsx +969 -0
  12. package/src/board/ChangesPane.tsx +185 -0
  13. package/src/board/CliBoard.tsx +484 -0
  14. package/src/board/FileViewer.tsx +721 -0
  15. package/src/board/FilesPane.tsx +262 -0
  16. package/src/board/HtmlReadingPreview.tsx +52 -0
  17. package/src/board/MarkdownPreview.tsx +134 -0
  18. package/src/board/PanelControls.tsx +40 -0
  19. package/src/board/blocks.tsx +97 -0
  20. package/src/board/context.ts +65 -0
  21. package/src/board/diffLines.ts +46 -0
  22. package/src/board/fileKind.ts +250 -0
  23. package/src/board/fileOps.ts +85 -0
  24. package/src/board/fileScope.ts +32 -0
  25. package/src/board/fileSync.ts +351 -0
  26. package/src/board/fileSyncCache.ts +250 -0
  27. package/src/board/fileSyncProcessor.ts +72 -0
  28. package/src/board/fileSyncQueue.ts +56 -0
  29. package/src/board/fileSyncTransport.ts +85 -0
  30. package/src/board/fileWatch.ts +211 -0
  31. package/src/board/folderDownload.ts +211 -0
  32. package/src/board/frameQuote.ts +52 -0
  33. package/src/board/htmlReading.ts +148 -0
  34. package/src/board/panels.ts +33 -0
  35. package/src/board/textLoad.ts +76 -0
  36. package/src/board/useBoardUpload.tsx +51 -0
  37. package/src/board/useBoardVisibility.ts +35 -0
  38. package/src/board/workspace.ts +200 -0
  39. package/src/board/zip.ts +85 -0
  40. package/src/chat/CliChat.tsx +932 -0
  41. package/src/chat/Composer.tsx +239 -0
  42. package/src/chat/GroupChat.tsx +113 -0
  43. package/src/chat/RunControls.tsx +196 -0
  44. package/src/chat/configuration.ts +15 -0
  45. package/src/chat/draft.ts +49 -0
  46. package/src/chat/export.ts +34 -0
  47. package/src/chat/queueEdit.ts +27 -0
  48. package/src/chat/useExecutionConfig.ts +93 -0
  49. package/src/components.tsx +13 -0
  50. package/src/core/blocks.ts +205 -0
  51. package/src/core/callbacks.ts +8 -0
  52. package/src/core/client.ts +822 -0
  53. package/src/core/config.ts +252 -0
  54. package/src/core/conversation.ts +577 -0
  55. package/src/core/documentUpload.ts +196 -0
  56. package/src/core/execution.ts +24 -0
  57. package/src/core/id.ts +15 -0
  58. package/src/core/identity.ts +21 -0
  59. package/src/core/provider.tsx +141 -0
  60. package/src/core/sha256.ts +21 -0
  61. package/src/core/sse.ts +51 -0
  62. package/src/core/store.ts +1103 -0
  63. package/src/core/subagents.ts +78 -0
  64. package/src/core/textDecode.ts +24 -0
  65. package/src/core/types.ts +505 -0
  66. package/src/core/xlsx.ts +142 -0
  67. package/src/elements.tsx +376 -0
  68. package/src/embed.ts +56 -0
  69. package/src/index.ts +56 -0
  70. package/src/layout/CliSplit.tsx +53 -0
  71. package/src/layout/split.ts +251 -0
  72. package/src/msglist/CliMsgList.tsx +422 -0
  73. package/src/operator/branchFlow.ts +35 -0
  74. package/src/operator/index.ts +9 -0
  75. package/src/operator/liveStream.ts +1 -0
  76. package/src/operator/node/AgentConnect.tsx +619 -0
  77. package/src/operator/node/AppearancePanel.tsx +35 -0
  78. package/src/operator/node/BranchHistory.tsx +67 -0
  79. package/src/operator/node/BranchNavigator.tsx +36 -0
  80. package/src/operator/node/BrowserPane.tsx +128 -0
  81. package/src/operator/node/BrowserPopup.tsx +22 -0
  82. package/src/operator/node/Chat.tsx +538 -0
  83. package/src/operator/node/CommandPalette.tsx +213 -0
  84. package/src/operator/node/Composer.tsx +316 -0
  85. package/src/operator/node/InsightsView.tsx +831 -0
  86. package/src/operator/node/Layout.tsx +192 -0
  87. package/src/operator/node/Markdown.tsx +61 -0
  88. package/src/operator/node/Modal.tsx +179 -0
  89. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  90. package/src/operator/node/OnboardingWizard.tsx +248 -0
  91. package/src/operator/node/Orchestrator.tsx +644 -0
  92. package/src/operator/node/ProjectLanding.tsx +86 -0
  93. package/src/operator/node/ProjectsColumn.tsx +137 -0
  94. package/src/operator/node/ProviderAuth.tsx +559 -0
  95. package/src/operator/node/RunControls.tsx +2 -0
  96. package/src/operator/node/RunSwitch.tsx +113 -0
  97. package/src/operator/node/Services.tsx +245 -0
  98. package/src/operator/node/SessionRail.tsx +299 -0
  99. package/src/operator/node/SessionView.tsx +292 -0
  100. package/src/operator/node/SettingsView.tsx +690 -0
  101. package/src/operator/node/TaskBoard.tsx +352 -0
  102. package/src/operator/node/TaskChanges.tsx +526 -0
  103. package/src/operator/node/TaskHero.tsx +50 -0
  104. package/src/operator/node/TasksColumn.tsx +200 -0
  105. package/src/operator/node/Terminal.tsx +143 -0
  106. package/src/operator/node/Transcript.tsx +485 -0
  107. package/src/operator/node/ViewFile.tsx +79 -0
  108. package/src/operator/node/Welcome.tsx +101 -0
  109. package/src/operator/node/WorktreePrune.tsx +333 -0
  110. package/src/operator/node/agents.ts +58 -0
  111. package/src/operator/node/api.ts +57 -0
  112. package/src/operator/node/boardBackend.ts +63 -0
  113. package/src/operator/node/boardEvents.ts +40 -0
  114. package/src/operator/node/boardInteraction.ts +15 -0
  115. package/src/operator/node/boardState.ts +74 -0
  116. package/src/operator/node/boardWorkspace.ts +15 -0
  117. package/src/operator/node/boundedPoll.ts +47 -0
  118. package/src/operator/node/branchFlow.ts +1 -0
  119. package/src/operator/node/composerDraft.ts +78 -0
  120. package/src/operator/node/fileLinks.ts +81 -0
  121. package/src/operator/node/format.ts +292 -0
  122. package/src/operator/node/github.tsx +232 -0
  123. package/src/operator/node/icons.tsx +88 -0
  124. package/src/operator/node/liveStream.ts +323 -0
  125. package/src/operator/node/modals.tsx +766 -0
  126. package/src/operator/node/navHistory.ts +70 -0
  127. package/src/operator/node/persist.ts +26 -0
  128. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  129. package/src/operator/node/protocol/agents/types.ts +394 -0
  130. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  131. package/src/operator/node/protocol/authFailure.ts +66 -0
  132. package/src/operator/node/protocol/customModels.ts +23 -0
  133. package/src/operator/node/protocol/events.ts +13 -0
  134. package/src/operator/node/protocol/features.ts +70 -0
  135. package/src/operator/node/protocol/internalUsage.ts +1 -0
  136. package/src/operator/node/protocol/promptLimits.ts +52 -0
  137. package/src/operator/node/protocol/storageRetention.ts +13 -0
  138. package/src/operator/node/protocol/types.ts +335 -0
  139. package/src/operator/node/protocol/usageLimit.ts +72 -0
  140. package/src/operator/node/shared.tsx +89 -0
  141. package/src/operator/node/transcriptModel.ts +129 -0
  142. package/src/operator/node/types.ts +305 -0
  143. package/src/operator/node/useGlobalEvents.ts +97 -0
  144. package/src/operator/node/useOrchestrator.ts +697 -0
  145. package/src/operator/node/usePrefs.ts +114 -0
  146. package/src/operator/node/useRecaps.ts +102 -0
  147. package/src/operator/node/useTaskStream.ts +286 -0
  148. package/src/operator.base.css +1949 -0
  149. package/src/operator.css +1949 -0
  150. package/src/styles.css +579 -0
  151. package/src/ui/ErrorBoundary.tsx +38 -0
  152. package/src/ui/Markdown.tsx +60 -0
  153. package/src/ui/Popover.tsx +95 -0
  154. package/src/ui/QueuedMessages.tsx +110 -0
  155. package/src/ui/ReferenceCard.tsx +28 -0
  156. package/src/ui/SubagentChat.tsx +233 -0
  157. package/src/ui/TextBlock.tsx +51 -0
  158. package/src/ui/Transcript.tsx +435 -0
  159. package/src/ui/UploadProgress.tsx +46 -0
  160. package/src/ui/WorkspaceDrawer.tsx +64 -0
  161. package/src/ui/WorkspaceTabs.tsx +60 -0
  162. package/src/ui/fileLinks.ts +52 -0
  163. package/src/ui/format.ts +103 -0
  164. package/src/ui/icons.tsx +92 -0
  165. package/src/ui/reference.ts +40 -0
  166. package/src/ui/shared.tsx +38 -0
  167. package/src/ui/transcriptModel.ts +151 -0
  168. package/src/ui/useChatScrollbar.ts +29 -0
  169. package/src/ui/useComposerAutosize.ts +54 -0
@@ -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
+ }
package/src/core/id.ts ADDED
@@ -0,0 +1,15 @@
1
+ /** Correlation/draft IDs only: never use this fallback as a credential or token. */
2
+ let sequence = 0;
3
+ export function clientId(): string {
4
+ try { if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID(); } catch { /* Fall back in restricted or insecure contexts. */ }
5
+ try {
6
+ if (typeof globalThis.crypto?.getRandomValues === 'function') {
7
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
8
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
9
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
10
+ const hex = Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
11
+ return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
12
+ }
13
+ } catch { /* Drafts still work when Web Crypto is unavailable. */ }
14
+ return `${Date.now().toString(36)}-${(++sequence).toString(36)}-${Math.random().toString(36).slice(2,10)}`;
15
+ }
@@ -0,0 +1,21 @@
1
+ import type { CenterClientOptions } from './client';
2
+ import { clientId } from './id';
3
+ import { sha256 } from './sha256';
4
+ // A synchronous SHA-256 fingerprint partitions persistent drafts without putting
5
+ // credentials in localStorage keys. It is never used for authentication.
6
+ export function connectionFingerprint(text:string):string {
7
+ return sha256(new TextEncoder().encode(text));
8
+ }
9
+ const customScopes=new WeakMap<object,string>();
10
+ export function connectionDraftScope(options:CenterClientOptions&{namespace?:string|null;client?:object}):string {
11
+ // Custom transports may hide cookies or credentials. Isolate by object identity
12
+ // rather than pretending their URL proves the account identity.
13
+ const headers = options.headers?.() ?? {};
14
+ const signature=JSON.stringify([options.baseUrl?.replace(/\/+$/,'')??'',options.token??'',options.line??'',options.namespace??'',Object.keys(headers).sort().map(key=>[key,headers[key]])]);
15
+ const transport=options.client;
16
+ if(transport){let scope=customScopes.get(transport);if(!scope){scope=clientId();customScopes.set(transport,scope);}return `custom-${scope}:${connectionFingerprint(signature)}`;}
17
+ // Preserve the historical default same-origin draft keys. Cookie-auth hosts
18
+ // must supply an account-specific namespace and remount after account changes.
19
+ if(!options.baseUrl&&!options.token&&!options.line&&!options.namespace&&!Object.keys(headers).length)return '';
20
+ return connectionFingerprint(signature);
21
+ }
@@ -0,0 +1,141 @@
1
+ import { notifyCallback } from "./callbacks";
2
+ import { Fragment, createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
3
+ import { connectionDraftScope } from "./identity";
4
+ import { createCliStore, cloneWorkspaceEvent, type CliEvent, type CliState, type CliStore, type CliStoreOptions } from "./store";
5
+ import type { ConversationCallback, ConversationEndPayload, TurnEndPayload } from "./types";
6
+
7
+ /**
8
+ * 积木的拼接点。放在同一个 <CliCenterProvider> 里的组件共享一个仓库,切换时一起变:
9
+ * 列表点哪个对话,对话和看板就显示哪个;对话里新建 / 接续出新对话,列表跟着选中、看板跟着切;
10
+ * 对话里点文件在看板里打开。同一个对话只连一条实时流。
11
+ *
12
+ * <CliCenterProvider baseUrl="/api/center" selectedId={key} onEvent={(e) => e.type === "select" && setKey(e.jobId)}>
13
+ * <CliMsgList />
14
+ * <CliChat />
15
+ * <CliBoard />
16
+ * </CliCenterProvider>
17
+ *
18
+ * 要一起切换,里面的组件就不要再传 jobId(传了的组件只看自己那个,不跟着切);从外面打开某个对话用 selectedId。
19
+ *
20
+ * 组件单独使用时不需要它:组件发现外面没有 Provider,会用自己的 props 建一个。
21
+ */
22
+ const StoreContext = createContext<CliStore | null>(null);
23
+
24
+ export interface CliCenterProviderProps extends CliStoreOptions, ResultHandlers {
25
+ /** 外部建好的仓库(例如 Web Component 按 group 共享的);给了就不再自己建。 */
26
+ store?: CliStore;
27
+ /**
28
+ * 里面这几个组件一起看哪个对话(对话键:"job_x" 或 "job_x#task_y";null = 新对话)。不给就由列表的点击决定。
29
+ * 给了之后改它,列表、对话、看板一起切过去;列表点击、对话里新建出的对话照样会切,并发 select / job_change 事件。
30
+ */
31
+ selectedId?: string | null;
32
+ /** 监听仓库事件:选中、一轮结束、对话结束、job 变化、错误。 */
33
+ onEvent?: (event: CliEvent) => void;
34
+ children: ReactNode;
35
+ }
36
+
37
+ /**
38
+ * 结果通知:组件从实时流里看到一轮结束 / 对话结束时调用,全在浏览器里发生,**和 center 的 callback.url 无关**——
39
+ * 不配 callback 也照样会调。载荷字段与 center 回调体一致,方便业务后端和前端用同一套代码解析。
40
+ */
41
+ export interface WorkspaceHandlers {
42
+ onFilesChange?: (event: import("./types").WorkspaceChangeEvent) => void;
43
+ onFilesWatch?: (event: import("./types").WorkspaceWatchStatus) => void;
44
+ onBoardContextChange?: (event: Extract<CliEvent,{type:"board_context"}>) => void;
45
+ }
46
+ export interface ResultHandlers extends WorkspaceHandlers {
47
+ /** 一轮结束(成功 / 失败 / 取消)。 */
48
+ onTurnEnd?: (payload: TurnEndPayload) => void;
49
+ /** 对话结束(关闭 / 取消 / 过期……)。 */
50
+ onConversationEnd?: (payload: ConversationEndPayload) => void;
51
+ /** @deprecated 旧名字,等同于同时监听 onTurnEnd 和 onConversationEnd(payload.type 区分)。 */
52
+ onCallback?: (payload: ConversationCallback) => void;
53
+ }
54
+
55
+ export function dispatchResult(event: CliEvent, handlers: ResultHandlers) {
56
+ if (event.type === "conversation_turn") { notifyCallback(handlers.onTurnEnd, event.payload); notifyCallback(handlers.onCallback, event.payload); }
57
+ else if (event.type === "conversation_closed") { notifyCallback(handlers.onConversationEnd, event.payload); notifyCallback(handlers.onCallback, event.payload); }
58
+ else if(event.type === "files_changed") notifyCallback(handlers.onFilesChange,event);
59
+ else if(event.type === "files_watch") notifyCallback(handlers.onFilesWatch,event);
60
+ else if(event.type === "board_context") notifyCallback(handlers.onBoardContextChange,event);
61
+ }
62
+
63
+ export function CliCenterProvider({ store: external, selectedId, onEvent, onTurnEnd, onConversationEnd, onCallback, onFilesChange, onFilesWatch, onBoardContextChange, children, ...options }: CliCenterProviderProps) {
64
+ // 连接参数只在建仓库时用一次:中途换 baseUrl / token 请换 key 重建。
65
+ const optionsRef = useRef(options);
66
+ const applied = useRef<string | null | undefined>(undefined);
67
+ const store = useMemo(() => {
68
+ const created = external ?? createCliStore(optionsRef.current);
69
+ // 第一次渲染前就选好:里面的组件一上来就看这个对话,不会先打开别的。
70
+ if (selectedId !== undefined) { created.select(selectedId); applied.current = selectedId; }
71
+ return created;
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]);
80
+ // 之后宿主改了 selectedId:一起切过去(在绘制前,免得闪一下旧的)。
81
+ useLayoutEffect(() => {
82
+ if (selectedId === undefined || selectedId === applied.current) return;
83
+ applied.current = selectedId;
84
+ store.select(selectedId);
85
+ }, [store, selectedId]);
86
+ const onEventRef = useRef(onEvent);
87
+ onEventRef.current = onEvent;
88
+ const resultRef = useRef<ResultHandlers>({});
89
+ resultRef.current = { onTurnEnd, onConversationEnd, onCallback, onFilesChange, onFilesWatch, onBoardContextChange };
90
+ useEffect(() => store.on((event) => {
91
+ notifyCallback(onEventRef.current, cloneWorkspaceEvent(event));
92
+ dispatchResult(event, resultRef.current);
93
+ }), [store]);
94
+ return <StoreContext.Provider value={store}><Fragment key={store.draftScope}>{children}</Fragment></StoreContext.Provider>;
95
+ }
96
+
97
+ export function useCliStoreContext(): CliStore | null {
98
+ return useContext(StoreContext);
99
+ }
100
+
101
+ export function useCliStore(): CliStore {
102
+ const store = useContext(StoreContext);
103
+ if (!store) throw new Error("useCliStore 必须在 <CliCenterProvider> 内使用");
104
+ return store;
105
+ }
106
+
107
+ /** 订阅仓库状态的一部分。选择器返回的值按引用比较,状态是不可变的,所以返回子对象是安全的。 */
108
+ export function useCliState<T>(selector: (state: CliState) => T): T {
109
+ const store = useCliStore();
110
+ return useSyncExternalStore(store.subscribe, () => selector(store.getState()), () => selector(store.getState()));
111
+ }
112
+
113
+ /** 组件外面没有 Provider 时,用组件自己的 props 包一层。 */
114
+ export function withOwnProvider<P extends object>(props: P & CliStoreOptions & ResultHandlers & { onEvent?: (event: CliEvent) => void; store?: CliStore }, inner: ReactNode, hasContext: boolean) {
115
+ if (hasContext) return inner;
116
+ const { baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, onFilesChange, onFilesWatch, onBoardContextChange, store } = props;
117
+ // 连接参数变了(换了中枢地址、令牌、产品线、命名空间)就换一个新仓库重连,不沿用旧连接。
118
+ const connection = connectionDraftScope({baseUrl,token,line,namespace,headers,fetch,client});
119
+ return (
120
+ <CliCenterProvider key={store ? "external" : connection} {...{ baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, onFilesChange, onFilesWatch, onBoardContextChange, store }}>
121
+ {inner}
122
+ </CliCenterProvider>
123
+ );
124
+ }
125
+
126
+ /**
127
+ * 组件自己的 onEvent / onTurnEnd / onConversationEnd:组件放在外层 Provider 里时由这里订阅
128
+ * (自己建 Provider 时交给那个 Provider,免得调两次)。
129
+ */
130
+ export function useComponentEvents(onEvent: ((event: CliEvent) => void) | undefined, results: ResultHandlers, enabled: boolean) {
131
+ const store = useContext(StoreContext);
132
+ const ref = useRef({ onEvent, results });
133
+ ref.current = { onEvent, results };
134
+ useEffect(() => {
135
+ if (!enabled || !store) return;
136
+ return store.on((event) => {
137
+ notifyCallback(ref.current.onEvent, cloneWorkspaceEvent(event));
138
+ dispatchResult(event, ref.current.results);
139
+ });
140
+ }, [store, enabled]);
141
+ }
@@ -0,0 +1,21 @@
1
+ /** Keep this byte-only fallback self-contained so file workers can use the same implementation. */
2
+ export function sha256(bytes: Uint8Array): string {
3
+ const K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
4
+ const rotate = (x:number,n:number)=>(x>>>n)|(x<<(32-n));
5
+ // Only the final one or two blocks need padding; do not copy an entire large file.
6
+ const blockBytes=bytes.length-bytes.length%64,tail=new Uint8Array(bytes.length%64<56?64:128);
7
+ tail.set(bytes.subarray(blockBytes));tail[bytes.length%64]=128;
8
+ const input=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength),padding=new DataView(tail.buffer);
9
+ padding.setUint32(tail.length-8,Math.floor(bytes.length/0x20000000));padding.setUint32(tail.length-4,(bytes.length*8)>>>0);
10
+ const hash=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],w=new Int32Array(64);
11
+ for(let offset=0;offset<blockBytes+tail.length;offset+=64){
12
+ const view=offset<blockBytes?input:padding,base=offset<blockBytes?offset:offset-blockBytes;
13
+ for(let i=0;i<16;i++)w[i]=view.getInt32(base+i*4);
14
+ for(let i=16;i<64;i++){const x=w[i-15],y=w[i-2];w[i]=(w[i-16]+(rotate(x,7)^rotate(x,18)^(x>>>3))+w[i-7]+(rotate(y,17)^rotate(y,19)^(y>>>10)))|0;}
15
+ let [a,b,c,d,e,f,g,h]=hash;
16
+ for(let i=0;i<64;i++){const t1=(h+(rotate(e,6)^rotate(e,11)^rotate(e,25))+((e&f)^(~e&g))+K[i]+w[i])|0,t2=((rotate(a,2)^rotate(a,13)^rotate(a,22))+((a&b)^(a&c)^(b&c)))|0;
17
+ h=g;g=f;f=e;e=(d+t1)|0;d=c;c=b;b=a;a=(t1+t2)|0;}
18
+ [a,b,c,d,e,f,g,h].forEach((value,i)=>{hash[i]=(hash[i]+value)|0;});
19
+ }
20
+ return hash.map(value=>(value>>>0).toString(16).padStart(8,'0')).join('');
21
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 极简 SSE 解析器。用 fetch 读流而不是 EventSource:EventSource 不能带 Authorization 头,
3
+ * 而 center 的接口按产品线令牌鉴权(直连或经代理都可能需要带头)。
4
+ *
5
+ * 按 https://html.spec.whatwg.org/multipage/server-sent-events.html 的行规则:
6
+ * `event:` 设事件名,`data:` 累加数据,空行派发;以 `:` 开头的是注释(心跳 ping)。
7
+ */
8
+ export interface SseMessage { event: string; data: string }
9
+
10
+ export function createSseParser(onMessage: (message: SseMessage) => void) {
11
+ let buffer = "";
12
+ let event = "";
13
+ let data: string[] = [];
14
+
15
+ const dispatch = () => {
16
+ if (data.length) onMessage({ event: event || "message", data: data.join("\n") });
17
+ event = "";
18
+ data = [];
19
+ };
20
+
21
+ const line = (raw: string) => {
22
+ if (raw === "") return dispatch();
23
+ if (raw.startsWith(":")) return;
24
+ const colon = raw.indexOf(":");
25
+ const field = colon === -1 ? raw : raw.slice(0, colon);
26
+ let value = colon === -1 ? "" : raw.slice(colon + 1);
27
+ if (value.startsWith(" ")) value = value.slice(1);
28
+ if (field === "event") event = value;
29
+ else if (field === "data") data.push(value);
30
+ };
31
+
32
+ return {
33
+ push(chunk: string) {
34
+ buffer += chunk;
35
+ let index: number;
36
+ // 行尾可能是 \n、\r\n 或 \r。
37
+ while ((index = buffer.search(/\r\n|\r|\n/)) !== -1) {
38
+ const sep = buffer.startsWith("\r\n", index) ? 2 : 1;
39
+ // 只剩一个 \r 时等下一块:它可能是 \r\n 的前半。
40
+ if (buffer[index] === "\r" && sep === 1 && index === buffer.length - 1) break;
41
+ line(buffer.slice(0, index));
42
+ buffer = buffer.slice(index + sep);
43
+ }
44
+ },
45
+ end() {
46
+ if (buffer) line(buffer);
47
+ buffer = "";
48
+ dispatch();
49
+ },
50
+ };
51
+ }