@spzxg/cli-ui 0.6.11 → 0.6.13
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 +20 -1
- package/README.md +9 -3
- package/dist/{cli-ui-embed-CbxWozLO.js → cli-ui-embed-CXaGT9e4.js} +14911 -14636
- package/dist/{cli-ui-index-ImgEunSf.js → cli-ui-index-bMQJjEBW.js} +2 -2
- package/dist/{cli-ui-jszip.min-DGdGLbxr.js → cli-ui-jszip.min-AMeWMDT5.js} +2 -2
- package/dist/{cli-ui-jszip.min-DWmufeZi.js → cli-ui-jszip.min-D_w3a6AF.js} +1 -1
- package/dist/{cli-ui-xlsx-s2TcknA1.js → cli-ui-xlsx-DMzew4IO.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +1 -1
- package/src/board/CliBoard.tsx +2 -3
- package/src/board/FileViewer.tsx +3 -3
- package/src/board/FilesPane.tsx +1 -1
- package/src/board/fileScope.ts +6 -3
- package/src/board/fileSync.ts +12 -4
- package/src/board/fileSyncProcessor.ts +4 -3
- package/src/board/fileSyncTransport.ts +2 -2
- package/src/board/fileWatch.ts +36 -20
- package/src/chat/CliChat.tsx +13 -2
- package/src/core/client.ts +4 -0
- package/src/core/conversation.ts +10 -3
- package/src/core/identity.ts +2 -14
- package/src/core/sha256.ts +21 -0
- package/src/core/subagents.ts +78 -0
- package/src/core/types.ts +12 -2
- package/src/operator/node/Chat.tsx +10 -4
- package/src/operator/node/Layout.tsx +3 -1
- package/src/operator/node/SessionRail.tsx +2 -2
- package/src/operator/node/Transcript.tsx +7 -2
- package/src/operator/node/modals.tsx +13 -2
- package/src/operator/node/protocol/types.ts +5 -2
- package/src/operator/node/useTaskStream.ts +8 -4
- package/src/operator.base.css +35 -0
- package/src/operator.css +35 -0
- package/src/ui/SubagentChat.tsx +197 -0
- package/src/ui/Transcript.tsx +5 -0
package/dist/cli-ui.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-
|
|
1
|
+
import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-CXaGT9e4.js";
|
|
2
2
|
export {
|
|
3
3
|
a as CliBoard,
|
|
4
4
|
i as CliBrowserView,
|
package/package.json
CHANGED
package/src/board/CliBoard.tsx
CHANGED
|
@@ -101,7 +101,7 @@ export interface CliBoardProps extends CliStoreOptions, WorkspaceHandlers {
|
|
|
101
101
|
* 缺省和 quoteToChat 一样。
|
|
102
102
|
*/
|
|
103
103
|
autoQuoteOpenFile?: boolean;
|
|
104
|
-
/**
|
|
104
|
+
/** 保留旧宿主兼容;看板不再显示内置收起按钮。 */
|
|
105
105
|
onCollapse?: () => void;
|
|
106
106
|
/**
|
|
107
107
|
* 收起状态:只显示一条窄栏(点一下展开)。收起时仍然接对话里的打开请求——接到就记下要打开的东西并调 onExpand,
|
|
@@ -125,7 +125,7 @@ export function CliBoard(props: CliBoardProps) {
|
|
|
125
125
|
|
|
126
126
|
interface OpenFile { name?: string; reloadKey?:number; path: string; size?: number; mtime?: number; version: number }
|
|
127
127
|
|
|
128
|
-
function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs, defaultTab, readOnly, fileActions, acceptOpen = true, quoteToChat, autoQuoteOpenFile, extraTabs,
|
|
128
|
+
function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs, defaultTab, readOnly, fileActions, acceptOpen = true, quoteToChat, autoQuoteOpenFile, extraTabs, collapsed, onExpand, onNavigate, onEvent, onFilesChange, onFilesWatch, onBoardContextChange, watchFiles = true, fileWatchIntervalMs, theme = "light", className, style, inProvider, inlineLayout, inlineOwner, visible: visibilityOverride, onQuoteToChat }: CliBoardProps & { inProvider: boolean }) {
|
|
129
129
|
useComponentEvents(onEvent, {onFilesChange,onFilesWatch,onBoardContextChange}, inProvider);
|
|
130
130
|
const store = useCliStore();
|
|
131
131
|
const backend = store.client.board;
|
|
@@ -471,7 +471,6 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
|
|
|
471
471
|
{info?.dispatched && !file && <button className="rail-collapse" onClick={refreshAll} title={running ? "运行中 · 本轮结束后同步,点击立即检查" : "刷新"}>
|
|
472
472
|
<span className={`cli-sync-dot ${running ? "on" : ""}`} />↻
|
|
473
473
|
</button>}
|
|
474
|
-
{onCollapse && <button className="rail-collapse" onClick={onCollapse} title="隐藏面板">{Icon.chevRight()}</button>}
|
|
475
474
|
</div>;
|
|
476
475
|
return <>
|
|
477
476
|
{inlineLayout?.tabsHost && createPortal(tabBar, inlineLayout.tabsHost)}
|
package/src/board/FileViewer.tsx
CHANGED
|
@@ -188,9 +188,9 @@ function FileViewerInner({ backend, jobId, path, displayName, size, mtime, versi
|
|
|
188
188
|
const fileReadAbort = useRef<AbortController | null>(null);
|
|
189
189
|
const readingAbort = useRef<AbortController | null>(null);
|
|
190
190
|
const cancelRead = () => { fileReadAbort.current?.abort(); readingAbort.current?.abort(); setSyncing(false); setParsing(false); setCancelled(true); };
|
|
191
|
-
useEffect(() => retainFileScope(backend, jobId, { paths: [path], dirs: [], onDenied:
|
|
191
|
+
useEffect(() => retainFileScope(backend, jobId, { paths: [path], dirs: [], onDenied: message => {
|
|
192
192
|
fileReadAbort.current?.abort(); readingAbort.current?.abort(); revisionRef.current = undefined;
|
|
193
|
-
setLoaded(null); setHtmlDoc(null); setSyncing(false); setParsing(false); setError("
|
|
193
|
+
setLoaded(null); setHtmlDoc(null); setSyncing(false); setParsing(false); setError(message || "文件访问受限,请检查任务文件目录或访问权限");
|
|
194
194
|
} }), [backend, jobId, path]);
|
|
195
195
|
// 放大:盖住页面看(看板窄的时候看报告、表格、图片)。Esc 还原。
|
|
196
196
|
const [big, setBig] = useState(false);
|
|
@@ -474,7 +474,7 @@ function FileViewerInner({ backend, jobId, path, displayName, size, mtime, versi
|
|
|
474
474
|
const toolbar = <>
|
|
475
475
|
{(copying || copied || busy) && <span className="cli-fv-status" role="status">{copying ? "正在复制路径…" : copied ? "路径已复制" : "正在下载…"}</span>}
|
|
476
476
|
<div className="cli-fv-actions">
|
|
477
|
-
{embedded && <button className="cli-fv-icon" title={syncing || parsing ? "正在同步当前文件" : "刷新当前文件"} aria-label="刷新当前文件" disabled={syncing || parsing} onClick={() => setAttempt(value => value + 1)}>↻</button>}
|
|
477
|
+
{embedded && <button className="cli-fv-icon" title={syncing || parsing ? "正在同步当前文件" : "刷新当前文件"} aria-label="刷新当前文件" aria-busy={syncing || parsing} disabled={syncing || parsing} onClick={() => setAttempt(value => value + 1)}>↻</button>}
|
|
478
478
|
<button ref={moreRef} className="cli-fv-icon cli-fv-more" title="更多文件操作" aria-label="更多文件操作" aria-haspopup="dialog" aria-expanded={actionsOpen} onClick={() => setActionsOpen(open => !open)}>{Icon.dots()}</button>
|
|
479
479
|
{actionsOpen && <Popover className="cli-fv-menu" ariaLabel="文件操作" returnFocusRef={moreRef} onClose={() => setActionsOpen(false)}>
|
|
480
480
|
<div className="cli-fv-menu-head"><strong>文件操作</strong><span title={path}>{path}</span></div>
|
package/src/board/FilesPane.tsx
CHANGED
|
@@ -50,7 +50,7 @@ export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onLi
|
|
|
50
50
|
const end = Math.min(entries?.length ?? 0, first + Math.min(80, Math.ceil(viewport / rowHeight) + 8));
|
|
51
51
|
const visiblePaths = (entries ?? []).slice(first, end).filter(file => file.type === "file").map(file => file.path);
|
|
52
52
|
const scopeKey = JSON.stringify(visiblePaths);
|
|
53
|
-
useEffect(() => retainFileScope(backend, jobId, { dirs: [dir || "."], paths: JSON.parse(scopeKey) as string[], onDenied:
|
|
53
|
+
useEffect(() => retainFileScope(backend, jobId, { dirs: [dir || "."], paths: JSON.parse(scopeKey) as string[], onDenied: message => { generation.current++; pageAbort.current?.abort(); setLoading(false); setMoreLoading(false); setEntries(null); setError(message || "工作区访问受限,请检查任务文件目录或访问权限"); } }), [backend, jobId, dir, scopeKey]);
|
|
54
54
|
useEffect(() => {
|
|
55
55
|
if (!listRef.current || typeof ResizeObserver === "undefined") return;
|
|
56
56
|
const observer = new ResizeObserver(([box]) => { if (box.contentRect.height) setViewport(box.contentRect.height); });
|
package/src/board/fileScope.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BoardBackend } from "../core/client";
|
|
2
2
|
|
|
3
|
-
type Scope = { paths: string[]; dirs: string[]; onDenied?: () => void };
|
|
3
|
+
type Scope = { paths: string[]; dirs: string[]; onDenied?: (message?: string) => void };
|
|
4
4
|
const scopes = new WeakMap<BoardBackend, Map<string, Map<object, Scope>>>();
|
|
5
5
|
const listeners = new WeakMap<BoardBackend, Map<string, Set<() => void>>>();
|
|
6
6
|
/** A preview/list owns its subscription. Unmounted/closed directories are not polled. */
|
|
@@ -17,8 +17,11 @@ export function readFileScope(backend: BoardBackend, jobId: string): Scope {
|
|
|
17
17
|
const active = [...(scopes.get(backend)?.get(jobId)?.values() ?? [])];
|
|
18
18
|
return { paths: [...new Set(active.flatMap(scope => scope.paths))].sort(), dirs: [...new Set(active.flatMap(scope => scope.dirs))].sort() };
|
|
19
19
|
}
|
|
20
|
-
export function denyFileScope(backend: BoardBackend, jobId: string) {
|
|
21
|
-
for (const scope of scopes.get(backend)?.get(jobId)?.values() ?? [])
|
|
20
|
+
export function denyFileScope(backend: BoardBackend, jobId: string, message?: string, denied?: { paths: readonly string[]; dirs: readonly string[] }) {
|
|
21
|
+
for (const scope of scopes.get(backend)?.get(jobId)?.values() ?? []) {
|
|
22
|
+
// A list owns its directory; a rejected child file must not erase that list.
|
|
23
|
+
if (!denied || (scope.dirs.length ? scope.dirs.some(dir => denied.dirs.includes(dir || ".")) : scope.paths.some(path => denied.paths.includes(path)))) scope.onDenied?.(message);
|
|
24
|
+
}
|
|
22
25
|
}
|
|
23
26
|
export function onFileScope(backend: BoardBackend, jobId: string, listener: () => void) {
|
|
24
27
|
let jobs = listeners.get(backend); if (!jobs) { jobs = new Map(); listeners.set(backend, jobs); }
|
package/src/board/fileSync.ts
CHANGED
|
@@ -15,7 +15,7 @@ export class FileSyncError extends Error {
|
|
|
15
15
|
}
|
|
16
16
|
export interface FileSyncProgress { phase: "manifest" | "chunks" | "verify"; loadedBytes: number; totalBytes: number; downloadedBytes: number; reusedBytes: number }
|
|
17
17
|
export interface FileSyncOptions { signal?: AbortSignal; onProgress?: (progress: FileSyncProgress) => void; decode?: boolean; range?: { start?: number; maxBytes: number; tail?: boolean }; pendingTimeoutMs?: number; /** Require a manifest request started after this explicit refresh. */ fresh?: boolean; /** Remember only successfully viewed ranges, never downloads. */ preview?: boolean }
|
|
18
|
-
export interface FilePreviewRefresh { changed: string[]; error?: Error }
|
|
18
|
+
export interface FilePreviewRefresh { changed: string[]; error?: Error; denied?: Array<{ path: string; message: string }> }
|
|
19
19
|
export interface SyncedFile {
|
|
20
20
|
readonly bytes: Uint8Array<ArrayBuffer>;
|
|
21
21
|
revision: string; namespace: string; hash: string; size: number; start: number; end: number; truncated: boolean;
|
|
@@ -151,7 +151,7 @@ export function createFileSynchronizer(transport: FileSyncTransport, cache = new
|
|
|
151
151
|
if ([401, 403, 404].includes(status)) {
|
|
152
152
|
last.delete(resource);
|
|
153
153
|
if (previous) {
|
|
154
|
-
if (status === 404) { const scope = { namespace: previous.namespace, taskId: jobId, path }; retireFile(scope); await cache.purgeFile(scope); }
|
|
154
|
+
if (status === 404 || (status === 403 && (error as FileSyncError).code === "file_forbidden")) { const scope = { namespace: previous.namespace, taskId: jobId, path }; retireFile(scope); await cache.purgeFile(scope); }
|
|
155
155
|
else { retireNamespace(previous.namespace); await cache.purgeNamespace(previous.namespace); }
|
|
156
156
|
}
|
|
157
157
|
}
|
|
@@ -261,6 +261,7 @@ export function createFileSynchronizer(transport: FileSyncTransport, cache = new
|
|
|
261
261
|
// prevent several 100MiB workers allocating full buffers at the same time.
|
|
262
262
|
const work = async (): Promise<FilePreviewRefresh> => {
|
|
263
263
|
const changed = new Set<string>(); let error: Error | undefined, hasRecoverableError = false, retryAfterMs = 0;
|
|
264
|
+
const denied: NonNullable<FilePreviewRefresh["denied"]> = [];
|
|
264
265
|
for (const path of requested) {
|
|
265
266
|
if (signal?.aborted) throw aborted();
|
|
266
267
|
const resource = JSON.stringify([jobId, path]), previous = previews.get(resource);
|
|
@@ -277,7 +278,14 @@ export function createFileSynchronizer(transport: FileSyncTransport, cache = new
|
|
|
277
278
|
}
|
|
278
279
|
} catch (failure) {
|
|
279
280
|
const status = statusOf(failure);
|
|
280
|
-
if (signal?.aborted || (failure instanceof Error && failure.name === "AbortError") || status === 401
|
|
281
|
+
if (signal?.aborted || (failure instanceof Error && failure.name === "AbortError") || status === 401) throw failure;
|
|
282
|
+
if (status === 403) {
|
|
283
|
+
if ((failure as FileSyncError).code !== "file_forbidden") throw failure;
|
|
284
|
+
const scope = { namespace: last.get(resource)?.namespace ?? previous.namespace, taskId: jobId, path };
|
|
285
|
+
last.delete(resource); previews.delete(resource); retireFile(scope); await cache.purgeFile(scope);
|
|
286
|
+
changed.delete(path); denied.push({ path, message: failure instanceof Error ? failure.message : String(failure) });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
281
289
|
if (status === 404) {
|
|
282
290
|
changed.add(path);
|
|
283
291
|
if (previews.get(resource) === previous) {
|
|
@@ -304,7 +312,7 @@ export function createFileSynchronizer(transport: FileSyncTransport, cache = new
|
|
|
304
312
|
}
|
|
305
313
|
// Preserve the original errors: consumers may share them with other reads.
|
|
306
314
|
const combined = error && hasRecoverableError && retryAfterMs > 0 ? Object.assign(new Error(error.message), error, { name: error.name, retryAfterMs }) : error;
|
|
307
|
-
return { changed: [...changed], ...(combined ? { error: combined } : {}) };
|
|
315
|
+
return { changed: [...changed], ...(combined ? { error: combined } : {}), ...(denied.length ? { denied } : {}) };
|
|
308
316
|
};
|
|
309
317
|
return refreshQueue.run(work, signal);
|
|
310
318
|
};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { decodeText } from "../core/textDecode";
|
|
2
|
+
import { sha256 } from "../core/sha256";
|
|
2
3
|
|
|
3
4
|
const aborted = () => new DOMException("文件同步已取消", "AbortError");
|
|
4
5
|
const integrity = (message: string) => Object.assign(new Error(message), { status: 422, code: "integrity" });
|
|
5
|
-
const digest = async (bytes: Uint8Array<ArrayBuffer>) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map(byte => byte.toString(16).padStart(2, "0")).join("");
|
|
6
|
+
const digest = async (bytes: Uint8Array<ArrayBuffer>) => globalThis.crypto?.subtle ? [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map(byte => byte.toString(16).padStart(2, "0")).join("") : sha256(bytes);
|
|
6
7
|
type Processed = { bytes: Uint8Array<ArrayBuffer>; text?: string; encoding?: string };
|
|
7
8
|
|
|
8
9
|
/** One worker owns the assembled buffer; chunks are transferred, not copied into a second main-thread file. */
|
|
@@ -18,8 +19,8 @@ export class FileSyncProcessor {
|
|
|
18
19
|
private failed: Error | null = null;
|
|
19
20
|
constructor(private signal: AbortSignal) {
|
|
20
21
|
if (typeof Worker !== "undefined" && typeof URL.createObjectURL === "function") try {
|
|
21
|
-
const script = `const decode=${decodeText.toString()};let output=null,offset=0;
|
|
22
|
-
const hash=async b=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',b)),v=>v.toString(16).padStart(2,'0')).join('');
|
|
22
|
+
const script = `const decode=${decodeText.toString()},fallback=${sha256.toString()};let output=null,offset=0;
|
|
23
|
+
const hash=async b=>globalThis.crypto?.subtle?Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',b)),v=>v.toString(16).padStart(2,'0')).join(''):fallback(b instanceof Uint8Array?b:new Uint8Array(b));
|
|
23
24
|
onmessage=async({data:m})=>{try{let value;
|
|
24
25
|
if(m.op==='start'){output=new Uint8Array(m.size);offset=0;value=true;}
|
|
25
26
|
if(m.op==='verify'){if(await hash(m.bytes)!==m.hash)throw Error('文件块校验失败');value={bytes:m.bytes};}
|
|
@@ -43,8 +43,8 @@ export function createFileSyncTransport(fetcher: typeof fetch, pathBuilder: (job
|
|
|
43
43
|
const retryAfterMs = retry ? (/^\d+(\.\d+)?$/.test(retry) ? Number(retry) * 1000 : Math.max(0, Date.parse(retry) - Date.now())) : undefined;
|
|
44
44
|
const missingRoute = response.status === 501 || (response.status === 404 && !["file_not_found", "task_not_found", "job_not_found"].includes(error.error ?? ""));
|
|
45
45
|
const code = missingRoute ? "unsupported" : error.error ?? "http_error";
|
|
46
|
-
const common: Record<string, string> = { node_unreachable: "执行节点暂时连不上,请稍后重试", unsupported: "服务端尚未支持大文件增量同步,请升级节点和中枢", file_sync_unsupported: "服务端尚未支持大文件增量同步,请升级节点和中枢", file_not_found: "文件不存在(可能已删除或改名)", file_forbidden: "
|
|
47
|
-
const message = common[code] ?? (
|
|
46
|
+
const common: Record<string, string> = { node_unreachable: "执行节点暂时连不上,请稍后重试", unsupported: "服务端尚未支持大文件增量同步,请升级节点和中枢", file_sync_unsupported: "服务端尚未支持大文件增量同步,请升级节点和中枢", file_not_found: "文件不存在(可能已删除或改名)", file_forbidden: error.detail || "文件不在当前任务允许访问的目录中,或没有访问权限", file_sync_busy: "文件服务繁忙,请稍后重试", too_many_snapshot_builds: "文件服务繁忙,请稍后重试", too_many_uploads: "上传任务较多,请稍后重试", file_sync_queue_timeout: "文件处理排队超时,请稍后重试", file_sync_timeout: "文件服务响应超时,请重试", snapshot_cache_full: "文件缓存空间不足,请稍后重试", upload_storage_full: "文件上传空间不足,请稍后重试" };
|
|
47
|
+
const message = response.status === 401 ? "登录已失效,请重新登录" : common[code] ?? (response.status === 403 ? error.detail || "没有读取该文件的权限,请检查任务访问权限" : error.detail ?? `文件同步失败(HTTP ${response.status})`);
|
|
48
48
|
throw new FileSyncError(message, response.status, code, retryAfterMs);
|
|
49
49
|
}
|
|
50
50
|
return { response, bytes };
|
package/src/board/fileWatch.ts
CHANGED
|
@@ -78,12 +78,43 @@ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listen
|
|
|
78
78
|
const state={baseline:new Map(baseline),known:new Set(known)},nextDirectories=new Map(directories),nextCursors=new Map(cursors);
|
|
79
79
|
try{
|
|
80
80
|
const changes:WorkspaceFileChange[]=[];
|
|
81
|
+
let accessError:string|undefined;
|
|
82
|
+
const forbidden=(error:unknown)=>(error as {status?:number;code?:string})?.status===403&&(error as {code?:string}).code==='file_forbidden';
|
|
83
|
+
const deny=(error:unknown,paths:string[],dirs:string[])=>{
|
|
84
|
+
accessError=error instanceof Error?error.message:String(error);
|
|
85
|
+
denyFileScope(backend,jobId,accessError,{paths,dirs});
|
|
86
|
+
};
|
|
87
|
+
// A rejected path in a batch is not evidence that the whole task lost access.
|
|
88
|
+
// Inspect only that batch's metadata; healthy files and directories remain usable.
|
|
89
|
+
const inspect=async(paths:string[],dirs:string[])=>{
|
|
90
|
+
const listed=new Map<string,FileEntry>(),deniedPaths=new Set<string>();
|
|
91
|
+
for(const dir of dirs){
|
|
92
|
+
try{
|
|
93
|
+
const result=await requests.run(()=>backend.stat(jobId,dir||'.'),ctl.signal);if(!current())return;
|
|
94
|
+
if(result.type!=='dir')continue;
|
|
95
|
+
for(const file of result.entries)listed.set(file.path,file);
|
|
96
|
+
const revision=result.revision??JSON.stringify(result.entries.map(file=>[file.path,fingerprint(file)]));
|
|
97
|
+
if(nextDirectories.has(dir)&&nextDirectories.get(dir)!==revision)changes.push({kind:'modified',path:dir});
|
|
98
|
+
nextDirectories.set(dir,revision);
|
|
99
|
+
}catch(error){if(!current())return;if(!forbidden(error))throw error;deny(error,[],[dir||'.']);}
|
|
100
|
+
}
|
|
101
|
+
const files:FileEntry[]=[];
|
|
102
|
+
for(const path of paths){
|
|
103
|
+
const listedFile=listed.get(path);if(listedFile){files.push(listedFile);continue;}
|
|
104
|
+
try{const result=await requests.run(()=>backend.stat(jobId,path),ctl.signal);if(result.type==='file')files.push(result);}
|
|
105
|
+
catch(error){if(!current())return;if(forbidden(error)){deniedPaths.add(path);state.baseline.delete(path);state.known.delete(path);deny(error,[path],[]);}else if((error as {status?:number}).status!==404)throw error;}
|
|
106
|
+
if(!current())return;
|
|
107
|
+
}
|
|
108
|
+
changes.push(...snapshot(files,paths.filter(path=>!deniedPaths.has(path)),state));
|
|
109
|
+
};
|
|
81
110
|
if(backend.pollFiles){
|
|
82
111
|
const activeKeys=new Set<string>();
|
|
83
112
|
const count=Math.max(Math.ceil(scope.paths.length/200),Math.ceil(scope.dirs.length/32));
|
|
84
113
|
for(let index=0;index<count;index++){
|
|
85
114
|
const paths=scope.paths.slice(index*200,index*200+200),dirs=scope.dirs.slice(index*32,index*32+32),key=JSON.stringify([paths,dirs]);activeKeys.add(key);
|
|
86
|
-
|
|
115
|
+
let batch:Awaited<ReturnType<NonNullable<BoardBackend['pollFiles']>>>;
|
|
116
|
+
try{batch=await requests.run(()=>backend.pollFiles!(jobId,{paths,dirs,cursor:nextCursors.get(key)},ctl.signal),ctl.signal);}
|
|
117
|
+
catch(error){if(!forbidden(error))throw error;nextCursors.delete(key);await inspect(paths,dirs);if(!current())return;continue;}
|
|
87
118
|
if(!current())return;
|
|
88
119
|
if(!batch||typeof batch!=='object')throw invalidResponse('文件变化响应不完整,请重试');
|
|
89
120
|
if(batch.type==='resync'){cursors.delete(key);throw new Error('文件修订已变化,请重新核对');}
|
|
@@ -99,27 +130,12 @@ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listen
|
|
|
99
130
|
for(const key of nextCursors.keys())if(!activeKeys.has(key))nextCursors.delete(key);
|
|
100
131
|
}else{
|
|
101
132
|
// Compatibility hosts: inspect requested entries, never recurse a workspace.
|
|
102
|
-
|
|
103
|
-
for(const dir of scope.dirs){
|
|
104
|
-
const result=await requests.run(()=>backend.stat(jobId,dir||'.'),ctl.signal);if(!current())return;
|
|
105
|
-
if(result.type!=='dir')continue;
|
|
106
|
-
for(const file of result.entries)listed.set(file.path,file);
|
|
107
|
-
const revision=result.revision??JSON.stringify(result.entries.map(file=>[file.path,fingerprint(file)]));
|
|
108
|
-
if(nextDirectories.has(dir)&&nextDirectories.get(dir)!==revision)changes.push({kind:'modified',path:dir});
|
|
109
|
-
nextDirectories.set(dir,revision);
|
|
110
|
-
}
|
|
111
|
-
const files:FileEntry[]=[];
|
|
112
|
-
for(const path of scope.paths){
|
|
113
|
-
const listedFile=listed.get(path);if(listedFile){files.push(listedFile);continue;}
|
|
114
|
-
try{const result=await requests.run(()=>backend.stat(jobId,path),ctl.signal);if(result.type==='file')files.push(result);}
|
|
115
|
-
catch(error){if((error as {status?:number}).status!==404)throw error;}
|
|
116
|
-
if(!current())return;
|
|
117
|
-
}
|
|
118
|
-
changes.push(...snapshot(files,scope.paths,state));
|
|
133
|
+
await inspect(scope.paths,scope.dirs);
|
|
119
134
|
}
|
|
120
135
|
if(!current())return;
|
|
121
136
|
const previews=backend.fileSync?await refreshFilePreviews(backend.fileSync,jobId,scope.paths,ctl.signal):undefined;
|
|
122
137
|
if(!current())return;
|
|
138
|
+
for(const file of previews?.denied??[])deny(new Error(file.message),[file.path],[]);
|
|
123
139
|
const changedPaths=new Set(changes.map(change=>change.path));
|
|
124
140
|
for(const path of previews?.changed??[])if(!changedPaths.has(path)){changes.push({kind:'modified',path});changedPaths.add(path);}
|
|
125
141
|
const active=new Set(scope.paths);for(const path of state.known)if(!active.has(path)){state.known.delete(path);state.baseline.delete(path);}
|
|
@@ -127,7 +143,7 @@ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listen
|
|
|
127
143
|
baseline=state.baseline;known=state.known;directories=nextDirectories;cursors=nextCursors;
|
|
128
144
|
publish(changes);
|
|
129
145
|
if(previews?.error)throw previews.error;
|
|
130
|
-
failed=0;serverNotBefore=0;retryAt=0;exhaustedUntil=0;if(current())status('ready');
|
|
146
|
+
failed=0;serverNotBefore=0;retryAt=0;exhaustedUntil=0;if(current())status(accessError?'error':'ready',accessError);
|
|
131
147
|
}catch(error){if(current()){
|
|
132
148
|
failed++;const code=(error as {status?:number}).status??0;
|
|
133
149
|
const failureCode=(error as {code?:string}).code;
|
|
@@ -139,7 +155,7 @@ export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listen
|
|
|
139
155
|
retryAt=Math.max(Date.now()+base,serverNotBefore)+Math.floor(Math.random()*base/2);
|
|
140
156
|
if(failed>retryDelays.length)exhaustedUntil=Math.max(Date.now()+30000,serverNotBefore);
|
|
141
157
|
}
|
|
142
|
-
if([401,403].includes(code))denyFileScope(backend,jobId);
|
|
158
|
+
if([401,403].includes(code))denyFileScope(backend,jobId,error instanceof Error?error.message:undefined);
|
|
143
159
|
status('error',error instanceof Error?error.message:String(error));
|
|
144
160
|
}}
|
|
145
161
|
finally{if(controller===ctl){
|
package/src/chat/CliChat.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// cli-chat:对话。界面取自 Operator 的会话视图(SessionView 的对话部分 + Composer),
|
|
2
2
|
// 数据来自 center 的对话任务(mode=conversation):实时流里的节点转录按 Operator 的规则渲染,
|
|
3
3
|
// 每条消息是中枢的一轮,排队 / 执行 / 结束都跟 center 保持一致。
|
|
4
|
+
import { SubagentChatScope } from "../ui/SubagentChat";
|
|
5
|
+
import { parentMessages } from "../core/subagents";
|
|
4
6
|
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type CSSProperties } from "react";
|
|
5
7
|
import { CliErrorBoundary } from "../ui/ErrorBoundary";
|
|
6
8
|
import type { CliEvent, CliStore, CliStoreOptions } from "../core/store";
|
|
@@ -198,11 +200,18 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
198
200
|
|
|
199
201
|
const allMessages = useMemo(() => (conv ? viewMessages(conv) : []), [conv]);
|
|
200
202
|
// config.transcript.tools=false:去掉工具卡片,只留文字往来(提问 / 输入卡片要用户操作,照常显示)。
|
|
201
|
-
const
|
|
203
|
+
const parent = useMemo(() => parentMessages(allMessages), [allMessages]);
|
|
204
|
+
const messages = useMemo(() => (cfg.transcript.tools ? parent : parent.filter((m) => {
|
|
202
205
|
if (m.role !== "tool") return true;
|
|
203
206
|
const data = toolDataOf(m);
|
|
204
207
|
return !!(data.ask || data.input);
|
|
205
|
-
})), [
|
|
208
|
+
})), [parent, cfg.transcript.tools]);
|
|
209
|
+
const remoteTaskId = conv?.remoteTaskId ?? conv?.task?.remote_task_id ?? null;
|
|
210
|
+
const loadSubagent = useCallback(async (id: string, generation: number, before?: string) => {
|
|
211
|
+
if (!key || !remoteTaskId || !store.client.subagent) throw new Error("当前接入未提供子代理历史接口");
|
|
212
|
+
return store.client.subagent(key, remoteTaskId, id, generation, before);
|
|
213
|
+
}, [store, key, remoteTaskId]);
|
|
214
|
+
const renderSubagentMessage = useCallback((message: Msg, name: string) => <MessageView m={message} initial={false} hideWho={false} handlers={{}} agentLabel={name} view={{ timestamps: true, initialBadge: false, userLabel: "收到的任务", agentName: name }} />, []);
|
|
206
215
|
const running = conv ? isRunning(conv) : false;
|
|
207
216
|
const job = entry?.job;
|
|
208
217
|
// 一次性任务没有轮次:补充消息通过 continueTask 接在当前子任务的对话后面。
|
|
@@ -691,6 +700,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
691
700
|
<FileLinkContext.Provider value={resolveFile}>
|
|
692
701
|
<FileOpenContext.Provider value={openFile}>
|
|
693
702
|
<div ref={chatRef} className={`cli-ui cli-chat ${className ?? ""}`} data-theme={theme} style={style}>
|
|
703
|
+
<SubagentChatScope scopeKey={`${key ?? "new"}:${remoteTaskId ?? ""}`} messages={allMessages} load={store.client.subagent ? loadSubagent : undefined} renderMessage={renderSubagentMessage}>
|
|
694
704
|
<div className="session">
|
|
695
705
|
{h.show && (workspaceAvailable || externalBoardVisible || h.crumb || h.export || h.title || h.agent || h.usage || h.model || h.status || h.close) && <div className="sess-head">
|
|
696
706
|
{workspaceTabs ? <WorkspaceTabs title={convTitle} subtitle={h.crumb ? title : undefined} chatActive={!workspaceActive} onChat={showChat} onTabsHost={setTabsHost} /> : (h.crumb || showCurrentTitle) && <div className="sh-main">
|
|
@@ -883,6 +893,7 @@ function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, j
|
|
|
883
893
|
inlineLayout={mobileWorkspace ? undefined : { tabsHost, active: workspaceActive, onActivate: showWorkspace, onChat: showChat }} />}
|
|
884
894
|
</WorkspaceDrawer>
|
|
885
895
|
</div>
|
|
896
|
+
</SubagentChatScope>
|
|
886
897
|
{popup && key && board && (
|
|
887
898
|
<div className="scrim" onClick={(e) => { if (e.target === e.currentTarget) setPopup(null); }}>
|
|
888
899
|
<div className={`modal cli-popup ${popup.kind}`} role="dialog" aria-modal="true" aria-label={popup.kind === "file" ? popup.path : "任务浏览器"}>
|
package/src/core/client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SubagentTranscript } from "./subagents";
|
|
1
2
|
import { createSseParser } from "./sse";
|
|
2
3
|
import { NEW_TASK, parseConvKey } from "./conversation";
|
|
3
4
|
import type {
|
|
@@ -107,6 +108,8 @@ export interface ConversationBackend {
|
|
|
107
108
|
/** taskId:只要对话任务里这一个对话的轮次。 */
|
|
108
109
|
listTurns(jobId: string, query?: { afterSeq?: number; limit?: number; taskId?: string }): Promise<{ job_id: string; turns: CenterTurn[] }>;
|
|
109
110
|
listTasks(): Promise<{ tasks: CenterTask[] }>;
|
|
111
|
+
/** 原生子代理的公开运行记录;before 用于读取更早的一页。 */
|
|
112
|
+
subagent?(jobId: string, remote: string, childId: string, generation: number, before?: string): Promise<SubagentTranscript>;
|
|
110
113
|
/**
|
|
111
114
|
* 更早的节点转录(往上翻):remote 是流里给的节点会话,before 是手里最早那条消息的 id。
|
|
112
115
|
* 返回这一页(旧的在前)和再往前还剩几条。旧版中枢没有这个接口(实时流也就给的是全量,用不到)。
|
|
@@ -344,6 +347,7 @@ export function createCenterClient(options: CenterClientOptions = {}): Conversat
|
|
|
344
347
|
return error instanceof Error ? error.message : String(error);
|
|
345
348
|
}
|
|
346
349
|
},
|
|
350
|
+
subagent: (jobId, remote, childId, generation, before) => request<SubagentTranscript>(`/api/jobs/${id(parseConvKey(jobId).jobId)}/subagents/${id(childId)}?remote=${id(remote)}&generation=${generation}&limit=200${before ? `&before=${id(before)}` : ""}`),
|
|
347
351
|
history: (jobId: string, remote: string, before: string, limit = 200) =>
|
|
348
352
|
request<{ messages: Message[]; older: number }>(`/api/jobs/${id(parseConvKey(jobId).jobId)}/history?remote=${id(remote)}&before=${id(before)}&limit=${limit}`),
|
|
349
353
|
createConversation: (firstMessage: string, opts: NewConversationOptions & { businessId?: string } = {}) => {
|
package/src/core/conversation.ts
CHANGED
|
@@ -229,19 +229,25 @@ export function applyNodeEvent(conv: ConvState, ev: NodeEvent): ConvState {
|
|
|
229
229
|
case "assistant":
|
|
230
230
|
return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("a"), role: "assistant", content: e.content, generation: gen, ts: e.ts }));
|
|
231
231
|
case "tool": {
|
|
232
|
-
const data: ToolData = { title: e.title, detail: e.detail, peek: e.peek, diff: e.diff };
|
|
233
|
-
return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("t"), role: "tool", content: JSON.stringify(data), generation: gen, toolId: e.id }));
|
|
232
|
+
const data: ToolData = { title: e.title, detail: e.detail, peek: e.peek, diff: e.diff, subagent: e.subagent, subagentMessage: e.subagentMessage };
|
|
233
|
+
return set(upsertMsg(conv.msgs, { id: e.msgId ?? localId("t"), role: "tool", content: JSON.stringify(data), generation: gen, toolId: e.id, ts: e.ts }));
|
|
234
234
|
}
|
|
235
235
|
case "tool_result": {
|
|
236
236
|
// 先按消息 id 找(快照加载的消息也能对上),找不到再按 tool_use id。结果多在末尾,从后往前找。
|
|
237
237
|
const arr = conv.msgs;
|
|
238
238
|
let i = arr.length - 1;
|
|
239
239
|
while (i >= 0 && (arr[i].role !== "tool" || (arr[i].id !== e.msgId && arr[i].toolId !== e.id))) i--;
|
|
240
|
-
if (i < 0)
|
|
240
|
+
if (i < 0) {
|
|
241
|
+
if (!e.msgId || (!e.subagent && !e.subagentMessage)) return conv;
|
|
242
|
+
const data: ToolData = { title: e.subagent?.title ?? e.subagentMessage?.tool?.title ?? "子代理消息", subagent: e.subagent, subagentMessage: e.subagentMessage };
|
|
243
|
+
return set(upsertMsg(arr, { id: e.msgId, role: "tool", content: JSON.stringify(data), generation: gen, toolId: e.id, ts: e.ts }));
|
|
244
|
+
}
|
|
241
245
|
try {
|
|
242
246
|
const d = JSON.parse(arr[i].content) as ToolData;
|
|
243
247
|
d.result = e.content;
|
|
244
248
|
d.isError = e.isError;
|
|
249
|
+
if (e.subagent) d.subagent = e.subagent;
|
|
250
|
+
if (e.subagentMessage) d.subagentMessage = e.subagentMessage;
|
|
245
251
|
if (e.peek) d.peek = e.peek;
|
|
246
252
|
const next = arr.slice();
|
|
247
253
|
next[i] = { ...arr[i], content: JSON.stringify(d) };
|
|
@@ -535,6 +541,7 @@ export function turnCallback(conv: ConvState, turn: CenterTurn, continuedFrom: s
|
|
|
535
541
|
context_reset: turn.context_reset,
|
|
536
542
|
merged: !!turn.merged,
|
|
537
543
|
merge_error: turn.merge_error ?? null,
|
|
544
|
+
...(turn.saved !== undefined ? { saved: turn.saved, saved_at: turn.saved_at ?? null, save_error: turn.save_error ?? null } : {}),
|
|
538
545
|
agent: turn.agent,
|
|
539
546
|
resolved_model: turn.resolved_model,
|
|
540
547
|
total_tokens: turn.total_tokens, input_tokens: turn.input_tokens, output_tokens: turn.output_tokens,
|
package/src/core/identity.ts
CHANGED
|
@@ -1,22 +1,10 @@
|
|
|
1
1
|
import type { CenterClientOptions } from './client';
|
|
2
2
|
import { clientId } from './id';
|
|
3
|
+
import { sha256 } from './sha256';
|
|
3
4
|
// A synchronous SHA-256 fingerprint partitions persistent drafts without putting
|
|
4
5
|
// credentials in localStorage keys. It is never used for authentication.
|
|
5
|
-
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];
|
|
6
|
-
const rotate = (x:number,n:number)=>(x>>>n)|(x<<(32-n));
|
|
7
6
|
export function connectionFingerprint(text:string):string {
|
|
8
|
-
|
|
9
|
-
const view=new DataView(data.buffer);view.setUint32(data.length-8,Math.floor(raw.length/0x20000000));view.setUint32(data.length-4,(raw.length*8)>>>0);
|
|
10
|
-
const hash=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],w=new Int32Array(64);
|
|
11
|
-
for(let offset=0;offset<data.length;offset+=64){
|
|
12
|
-
for(let i=0;i<16;i++)w[i]=view.getInt32(offset+i*4);
|
|
13
|
-
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;}
|
|
14
|
-
let [a,b,c,d,e,f,g,h]=hash;
|
|
15
|
-
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;
|
|
16
|
-
h=g;g=f;f=e;e=(d+t1)|0;d=c;c=b;b=a;a=(t1+t2)|0;}
|
|
17
|
-
[a,b,c,d,e,f,g,h].forEach((value,i)=>{hash[i]=(hash[i]+value)|0;});
|
|
18
|
-
}
|
|
19
|
-
return hash.map(value=>(value>>>0).toString(16).padStart(8,'0')).join('');
|
|
7
|
+
return sha256(new TextEncoder().encode(text));
|
|
20
8
|
}
|
|
21
9
|
const customScopes=new WeakMap<object,string>();
|
|
22
10
|
export function connectionDraftScope(options:CenterClientOptions&{namespace?:string|null;client?:object}):string {
|
|
@@ -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,78 @@
|
|
|
1
|
+
/** Shared wire format for native CLI subagents. These are not Center tasks. */
|
|
2
|
+
export interface SubagentInfo {
|
|
3
|
+
id: string;
|
|
4
|
+
parentId?: string;
|
|
5
|
+
title: string;
|
|
6
|
+
task?: string;
|
|
7
|
+
status: "running" | "completed" | "failed" | "cancelled" | "unknown";
|
|
8
|
+
detailAvailability: "full" | "partial" | "unavailable";
|
|
9
|
+
detailReason?: string;
|
|
10
|
+
updatedAt?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SubagentMessage {
|
|
14
|
+
subagentId: string;
|
|
15
|
+
role: "user" | "assistant" | "tool" | "system";
|
|
16
|
+
content: string;
|
|
17
|
+
tool?: { id: string; title: string; detail?: string; result?: string; isError?: boolean };
|
|
18
|
+
eventId: string;
|
|
19
|
+
updatedAt?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SubagentFields {
|
|
23
|
+
subagent?: SubagentInfo;
|
|
24
|
+
subagentMessage?: SubagentMessage;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Row = { id: string; role: string; content: string; generation: number; ts?: number; created_at?: number };
|
|
28
|
+
export interface SubagentTranscript { subagent: SubagentInfo; messages: Row[]; refreshable?: boolean; older?: number }
|
|
29
|
+
export type LoadSubagent = (id: string, generation: number, before?: string) => Promise<SubagentTranscript>;
|
|
30
|
+
|
|
31
|
+
const cache = new WeakMap<Row, SubagentFields>();
|
|
32
|
+
export function subagentFields(row: Row): SubagentFields {
|
|
33
|
+
const known = cache.get(row);
|
|
34
|
+
if (known) return known;
|
|
35
|
+
const fields: SubagentFields = {};
|
|
36
|
+
if (row.role === "tool") {
|
|
37
|
+
try {
|
|
38
|
+
const value = JSON.parse(row.content);
|
|
39
|
+
const info = value?.subagent;
|
|
40
|
+
if (info && typeof info.id === "string" && typeof info.title === "string") fields.subagent = {
|
|
41
|
+
id: info.id, title: info.title,
|
|
42
|
+
parentId: typeof info.parentId === "string" ? info.parentId : undefined,
|
|
43
|
+
task: typeof info.task === "string" ? info.task : undefined,
|
|
44
|
+
status: ["running", "completed", "failed", "cancelled", "unknown"].includes(info.status) ? info.status : "unknown",
|
|
45
|
+
detailAvailability: ["full", "partial", "unavailable"].includes(info.detailAvailability) ? info.detailAvailability : "partial",
|
|
46
|
+
detailReason: typeof info.detailReason === "string" ? info.detailReason : undefined,
|
|
47
|
+
updatedAt: typeof info.updatedAt === "number" && Number.isFinite(info.updatedAt) ? info.updatedAt : undefined,
|
|
48
|
+
};
|
|
49
|
+
if (value?.subagentMessage && typeof value.subagentMessage.subagentId === "string" && typeof value.subagentMessage.content === "string"
|
|
50
|
+
&& ["user", "assistant", "tool", "system"].includes(value.subagentMessage.role)) fields.subagentMessage = value.subagentMessage;
|
|
51
|
+
} catch { /* Ordinary or malformed tool output is handled by the existing renderer. */ }
|
|
52
|
+
}
|
|
53
|
+
cache.set(row, fields);
|
|
54
|
+
return fields;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Keep child activity out of the parent timeline, but retain an entry if its card is outside the loaded tail. */
|
|
58
|
+
export function parentMessages<T extends Row>(rows: T[]): T[] {
|
|
59
|
+
const cardKeys = new Set(rows.flatMap(row => {
|
|
60
|
+
const info = subagentFields(row).subagent;
|
|
61
|
+
return info ? [`${row.generation}:${info.id}`] : [];
|
|
62
|
+
}));
|
|
63
|
+
const emitted = new Set<string>();
|
|
64
|
+
return rows.flatMap(row => {
|
|
65
|
+
const { subagent, subagentMessage } = subagentFields(row);
|
|
66
|
+
if (subagent?.parentId && cardKeys.has(`${row.generation}:${subagent.parentId}`)) return [];
|
|
67
|
+
if (!subagentMessage) return [row];
|
|
68
|
+
const key = `${row.generation}:${subagentMessage.subagentId}`;
|
|
69
|
+
if (cardKeys.has(key) || emitted.has(key)) return [];
|
|
70
|
+
emitted.add(key);
|
|
71
|
+
const info: SubagentInfo = { id: subagentMessage.subagentId, title: "子代理", status: "unknown", detailAvailability: "partial", detailReason: "正在读取子代理详情" };
|
|
72
|
+
return [{ ...row, id: `subagent-entry:${key}`, role: "tool", content: JSON.stringify({ title: info.title, subagent: info }) }];
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function childMessages(rows: Row[], id: string, generation: number) {
|
|
77
|
+
return rows.filter(row => row.generation === generation && (subagentFields(row).subagentMessage?.subagentId === id || subagentFields(row).subagent?.parentId === id));
|
|
78
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SubagentInfo, SubagentMessage } from "./subagents";
|
|
1
2
|
/**
|
|
2
3
|
* 两套数据形状:
|
|
3
4
|
* · center 服务(cli-task-center)对话任务接口的返回(snake_case,原样保留,便于对照 API 文档);
|
|
@@ -37,6 +38,10 @@ export interface CenterTurn {
|
|
|
37
38
|
/** 这一轮的改动是否已合并回项目基线。 */
|
|
38
39
|
merged?: boolean;
|
|
39
40
|
merge_error?: string | null;
|
|
41
|
+
/** 保存本任务分支与合并项目主分支分别记录;旧版中枢不返回。 */
|
|
42
|
+
saved?: boolean;
|
|
43
|
+
saved_at?: string | null;
|
|
44
|
+
save_error?: string | null;
|
|
40
45
|
created_at: string;
|
|
41
46
|
started_at: string | null;
|
|
42
47
|
ended_at: string | null;
|
|
@@ -203,6 +208,8 @@ export type ToolPeek =
|
|
|
203
208
|
| { kind: "todos"; items: { text: string; status: string }[] };
|
|
204
209
|
|
|
205
210
|
export interface ToolData {
|
|
211
|
+
subagent?: SubagentInfo;
|
|
212
|
+
subagentMessage?: SubagentMessage;
|
|
206
213
|
title: string;
|
|
207
214
|
detail?: string;
|
|
208
215
|
result?: string;
|
|
@@ -228,8 +235,8 @@ export type NodeEvent =
|
|
|
228
235
|
| { type: "dequeued"; msgId: string }
|
|
229
236
|
| { type: "model"; model: string }
|
|
230
237
|
| { type: "assistant"; content: string; msgId?: string; generation?: number; ts?: number }
|
|
231
|
-
| { type: "tool"; id: string; title: string; detail: string; peek?: ToolPeek; diff?: DiffLine[]; msgId?: string; generation?: number }
|
|
232
|
-
| { type: "tool_result"; id: string; content: string; isError: boolean; peek?: ToolPeek; msgId?: string }
|
|
238
|
+
| { type: "tool"; id: string; title: string; detail: string; peek?: ToolPeek; diff?: DiffLine[]; subagent?: SubagentInfo; subagentMessage?: SubagentMessage; msgId?: string; generation?: number; ts?: number }
|
|
239
|
+
| { type: "tool_result"; id: string; content: string; isError: boolean; peek?: ToolPeek; subagent?: SubagentInfo; subagentMessage?: SubagentMessage; msgId?: string; generation?: number; ts?: number }
|
|
233
240
|
| { type: "ask"; id: string; questions: AskQuestion[]; msgId?: string; generation?: number }
|
|
234
241
|
| { type: "ask_answered"; id: string; answers: AskAnswers; msgId?: string; awaiting_input?: 0 | 1 }
|
|
235
242
|
| { type: "input"; input: InputRequest; msgId?: string; generation?: number; awaiting_input?: 0 | 1 }
|
|
@@ -338,6 +345,9 @@ export interface ConversationTurnCallback {
|
|
|
338
345
|
context_reset: boolean;
|
|
339
346
|
merged: boolean;
|
|
340
347
|
merge_error: string | null;
|
|
348
|
+
saved?: boolean;
|
|
349
|
+
saved_at?: string | null;
|
|
350
|
+
save_error?: string | null;
|
|
341
351
|
agent: string | null;
|
|
342
352
|
resolved_model: string | null;
|
|
343
353
|
total_tokens: number;
|