@spzxg/cli-ui 0.6.2 → 0.6.5
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 +26 -0
- package/README.md +137 -11
- package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
- package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-D4qfPHMd.js} +2 -2
- package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
- package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
- package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
- package/dist/cli-ui.css +32 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +14 -8
- package/src/board/BrowserPane.tsx +160 -74
- package/src/board/ChangesPane.tsx +2 -2
- package/src/board/CliBoard.tsx +79 -50
- package/src/board/FileViewer.tsx +32 -22
- package/src/board/FilesPane.tsx +32 -15
- package/src/board/PanelControls.tsx +28 -0
- package/src/board/blocks.tsx +5 -5
- package/src/board/context.ts +65 -0
- package/src/board/fileOps.ts +26 -3
- package/src/board/fileWatch.ts +88 -0
- package/src/board/folderDownload.ts +30 -14
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +2 -2
- package/src/board/workspace.ts +38 -9
- package/src/chat/CliChat.tsx +73 -30
- package/src/chat/Composer.tsx +41 -41
- package/src/chat/RunControls.tsx +99 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +41 -0
- package/src/chat/export.ts +2 -2
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +2 -2
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +8 -6
- package/src/core/conversation.ts +3 -3
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +33 -0
- package/src/core/provider.tsx +24 -14
- package/src/core/store.ts +83 -26
- package/src/core/types.ts +34 -0
- package/src/elements.tsx +135 -29
- package/src/embed.ts +6 -8
- package/src/index.ts +24 -17
- package/src/layout/CliSplit.tsx +1 -1
- package/src/msglist/CliMsgList.tsx +10 -10
- package/src/operator/branchFlow.ts +35 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/liveStream.ts +1 -0
- package/src/operator/node/AgentConnect.tsx +624 -0
- package/src/operator/node/AppearancePanel.tsx +35 -0
- package/src/operator/node/BranchHistory.tsx +67 -0
- package/src/operator/node/BranchNavigator.tsx +36 -0
- package/src/operator/node/BrowserPane.tsx +128 -0
- package/src/operator/node/BrowserPopup.tsx +22 -0
- package/src/operator/node/Chat.tsx +517 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +287 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +190 -0
- package/src/operator/node/Markdown.tsx +61 -0
- package/src/operator/node/Modal.tsx +179 -0
- package/src/operator/node/NeedsYouMenu.tsx +54 -0
- package/src/operator/node/OnboardingWizard.tsx +250 -0
- package/src/operator/node/Orchestrator.tsx +634 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +561 -0
- package/src/operator/node/RunControls.tsx +2 -0
- package/src/operator/node/RunSwitch.tsx +113 -0
- package/src/operator/node/Services.tsx +245 -0
- package/src/operator/node/SessionRail.tsx +260 -0
- package/src/operator/node/SessionView.tsx +288 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +530 -0
- package/src/operator/node/TaskHero.tsx +50 -0
- package/src/operator/node/TasksColumn.tsx +200 -0
- package/src/operator/node/Terminal.tsx +143 -0
- package/src/operator/node/Transcript.tsx +463 -0
- package/src/operator/node/ViewFile.tsx +79 -0
- package/src/operator/node/Welcome.tsx +101 -0
- package/src/operator/node/WorktreePrune.tsx +333 -0
- package/src/operator/node/agents.ts +58 -0
- package/src/operator/node/api.ts +40 -0
- package/src/operator/node/boardBackend.ts +80 -0
- package/src/operator/node/boardEvents.ts +22 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +57 -0
- package/src/operator/node/boardWorkspace.ts +12 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +52 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +290 -0
- package/src/operator/node/github.tsx +234 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +270 -0
- package/src/operator/node/modals.tsx +720 -0
- package/src/operator/node/navHistory.ts +70 -0
- package/src/operator/node/persist.ts +26 -0
- package/src/operator/node/protocol/agents/optIn.ts +9 -0
- package/src/operator/node/protocol/agents/types.ts +394 -0
- package/src/operator/node/protocol/approvalFailure.ts +77 -0
- package/src/operator/node/protocol/authFailure.ts +66 -0
- package/src/operator/node/protocol/customModels.ts +23 -0
- package/src/operator/node/protocol/events.ts +13 -0
- package/src/operator/node/protocol/features.ts +70 -0
- package/src/operator/node/protocol/internalUsage.ts +1 -0
- package/src/operator/node/protocol/promptLimits.ts +52 -0
- package/src/operator/node/protocol/storageRetention.ts +13 -0
- package/src/operator/node/protocol/types.ts +332 -0
- package/src/operator/node/protocol/usageLimit.ts +72 -0
- package/src/operator/node/shared.tsx +89 -0
- package/src/operator/node/transcriptModel.ts +129 -0
- package/src/operator/node/types.ts +296 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +696 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +91 -0
- package/src/operator/node/useTaskStream.ts +279 -0
- package/src/operator.base.css +1798 -0
- package/src/operator.css +186 -16
- package/src/styles.css +20 -0
- package/src/ui/Markdown.tsx +7 -2
- package/src/ui/Popover.tsx +77 -0
- package/src/ui/TextBlock.tsx +3 -3
- package/src/ui/Transcript.tsx +10 -8
- package/src/ui/fileLinks.ts +6 -0
- package/src/ui/format.ts +1 -1
- package/src/ui/shared.tsx +1 -1
- package/src/ui/transcriptModel.ts +2 -2
- package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
package/src/board/fileOps.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// 工作区文件的基础操作(不依赖界面):可直接打开的地址、下载到本地、读成 Blob。
|
|
2
2
|
// 看板、文件预览和给业务方的工作区接口(workspace.ts)共用。
|
|
3
|
-
import { fileUrlFromBase, type BoardBackend } from "../core/client
|
|
4
|
-
import { extOf, fmtSize } from "./fileKind
|
|
3
|
+
import { fileUrlFromBase, type BoardBackend } from "../core/client";
|
|
4
|
+
import { extOf, fmtSize } from "./fileKind";
|
|
5
5
|
|
|
6
6
|
/** 只能先 fetch 成 blob 时(中枢太旧):超过这么大的不在浏览器里拼下载。 */
|
|
7
7
|
const BLOB_DOWNLOAD_MAX = 300 * 1024 * 1024;
|
|
@@ -26,6 +26,29 @@ export async function directFileUrl(backend: BoardBackend, jobId: string, path:
|
|
|
26
26
|
return linkBase ? fileUrlFromBase(linkBase, path, download) : null;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
export class FileReadLimitError extends Error {
|
|
30
|
+
constructor(maxBytes:number){super(`文件超过当前读取上限(${fmtSize(maxBytes)}),请使用直接下载链接`);this.name="FileReadLimitError";}
|
|
31
|
+
}
|
|
32
|
+
/** Bound bytes even with stale metadata; cancellation must settle callers when a custom backend ignores AbortSignal. */
|
|
33
|
+
export async function readBoundedBuffer(backend:BoardBackend,jobId:string,path:string,maxBytes:number,options:{signal?:AbortSignal;onBytes?:(bytes:number)=>void}={}):Promise<ArrayBuffer> {
|
|
34
|
+
const ctl=new AbortController();let limit:FileReadLimitError|undefined;
|
|
35
|
+
const abort=()=>ctl.abort();options.signal?.addEventListener("abort",abort,{once:true});
|
|
36
|
+
if(options.signal?.aborted)ctl.abort();
|
|
37
|
+
let rejectAbort:(()=>void)|undefined;
|
|
38
|
+
try{
|
|
39
|
+
if(ctl.signal.aborted)throw new DOMException("已取消","AbortError");
|
|
40
|
+
const cancelled=new Promise<never>((_resolve,reject)=>{rejectAbort=()=>reject(limit??new DOMException("已取消","AbortError"));ctl.signal.addEventListener("abort",rejectAbort,{once:true});});
|
|
41
|
+
const result=await Promise.race([backend.readBuffer(jobId,path,{signal:ctl.signal,onBytes:bytes=>{
|
|
42
|
+
if(ctl.signal.aborted)return;
|
|
43
|
+
if(bytes>maxBytes){limit=new FileReadLimitError(maxBytes);ctl.abort();return;}
|
|
44
|
+
options.onBytes?.(bytes);
|
|
45
|
+
}}),cancelled]);
|
|
46
|
+
if(ctl.signal.aborted)throw limit??new DOMException("已取消","AbortError");
|
|
47
|
+
if(result.byteLength>maxBytes)throw new FileReadLimitError(maxBytes);
|
|
48
|
+
return result;
|
|
49
|
+
}finally{options.signal?.removeEventListener("abort",abort);if(rejectAbort)ctl.signal.removeEventListener("abort",rejectAbort);ctl.abort();}
|
|
50
|
+
}
|
|
51
|
+
|
|
29
52
|
/** 把 Blob 存到本地(浏览器下载)。 */
|
|
30
53
|
export function saveBlob(blob: Blob, name: string) {
|
|
31
54
|
const url = URL.createObjectURL(blob);
|
|
@@ -51,5 +74,5 @@ export async function downloadFile(backend: BoardBackend, jobId: string, path: s
|
|
|
51
74
|
const url = await directFileUrl(backend, jobId, path, true);
|
|
52
75
|
if (url) { clickDownload(url, name); return; }
|
|
53
76
|
if (size !== undefined && size > BLOB_DOWNLOAD_MAX) throw new Error(`文件较大(${fmtSize(size)}),当前接入方式不能在浏览器里下载,请升级任务中心`);
|
|
54
|
-
saveBlob(new Blob([await backend
|
|
77
|
+
saveBlob(new Blob([await readBoundedBuffer(backend, jobId, path, BLOB_DOWNLOAD_MAX)]), name);
|
|
55
78
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { CenterError, type BoardBackend } from '../core/client';
|
|
2
|
+
import { notifyCallback } from '../core/callbacks';
|
|
3
|
+
import type { FileEntry, WorkspaceChangeEvent, WorkspaceFileBatch, WorkspaceFileChange, WorkspaceWatchStatus } from '../core/types';
|
|
4
|
+
import { listFolder } from './folderDownload';
|
|
5
|
+
export interface WorkspaceWatchOptions { intervalMs?:number; onStatus?:(status:WorkspaceWatchStatus)=>void }
|
|
6
|
+
type Listener={change:(event:WorkspaceChangeEvent)=>void;status?:WorkspaceWatchOptions['onStatus']};
|
|
7
|
+
type Entry={listeners:Set<Listener>;stop:()=>void;retry?:()=>void;status:WorkspaceWatchStatus};
|
|
8
|
+
export function cloneWorkspaceChange(event:WorkspaceChangeEvent):WorkspaceChangeEvent {return {...event,changes:event.changes.map(change=>({...change,file:change.file?{...change.file}:undefined,previous:change.previous?{...change.previous}:undefined}))};}
|
|
9
|
+
const registries=new WeakMap<BoardBackend,Map<string,Entry>>();
|
|
10
|
+
const MAX_TRACKED_FILES=100000;
|
|
11
|
+
/** Internal: a new lease in an aggregating store can retry a terminal stream. */
|
|
12
|
+
export function retryWorkspaceFiles(backend:BoardBackend,jobId:string):void {registries.get(backend)?.get(jobId)?.retry?.();}
|
|
13
|
+
/** One transport per backend/workspace; independent of the active directory or file tab. */
|
|
14
|
+
export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listener:(event:WorkspaceChangeEvent)=>void,options:WorkspaceWatchOptions={}):()=>void {
|
|
15
|
+
let entries=registries.get(backend);if(!entries){entries=new Map();registries.set(backend,entries);}
|
|
16
|
+
let entry=entries.get(jobId);const subscription={change:listener,status:options.onStatus};
|
|
17
|
+
if(!entry){
|
|
18
|
+
const mode=backend.watchFiles?'events':'poll',coverage=mode==='events'?'workspace':'listed-files';
|
|
19
|
+
entry={listeners:new Set(),stop:()=>{},status:{type:'files_watch',jobId,state:'connecting',mode,coverage,complete:false}};entries.set(jobId,entry);
|
|
20
|
+
const owned=entry;let alive=true,baseline:Map<string,FileEntry>|null=null,limited=false,blocked=false,stopTransport:(()=>void)|null=null;
|
|
21
|
+
let timer:ReturnType<typeof setTimeout>|undefined,controller:AbortController|null=null,attempt=0,generation=0;
|
|
22
|
+
const status=(state:WorkspaceWatchStatus['state'],complete=false,error?:string)=>{
|
|
23
|
+
if(!alive)return;owned.status={type:'files_watch',jobId,state,mode,coverage,complete:mode==='events'&&complete&&!limited,...(error?{error}:{})};
|
|
24
|
+
for(const item of [...owned.listeners])notifyCallback(item.status,{...owned.status});
|
|
25
|
+
};
|
|
26
|
+
const publish=(changes:WorkspaceFileChange[],batch:WorkspaceFileBatch,resync=false)=>{
|
|
27
|
+
if(!alive||!changes.length)return;
|
|
28
|
+
const event:WorkspaceChangeEvent={type:'files_changed',jobId,mode,coverage,complete:mode==='events'&&batch.complete&&!limited,changes,revision:batch.revision,...(resync?{resync:true}:{})};
|
|
29
|
+
for(const item of [...owned.listeners])notifyCallback(item.change,cloneWorkspaceChange(event));
|
|
30
|
+
};
|
|
31
|
+
const accept=(batch:WorkspaceFileBatch)=>{
|
|
32
|
+
if(!alive)return;
|
|
33
|
+
if(batch.type==='resync'){if(batch.reason==='workspace_unavailable'&&baseline===null)baseline=new Map();status('reconnecting',false,batch.reason);return;}
|
|
34
|
+
if(batch.type==='snapshot'){
|
|
35
|
+
const next=new Map<string,FileEntry>();let overflow=false;const changes:WorkspaceFileChange[]=[];
|
|
36
|
+
for(const file of batch.files)if(file.type==='file'){if(next.has(file.path)||next.size<MAX_TRACKED_FILES)next.set(file.path,{...file});else overflow=true;}
|
|
37
|
+
if(baseline){
|
|
38
|
+
for(const [path,file] of next){const previous=baseline.get(path);if(!previous)changes.push({kind:'created',path,file});else if(previous.mtime!==file.mtime||previous.size!==file.size||previous.changeToken!==file.changeToken)changes.push({kind:'modified',path,file,previous});}
|
|
39
|
+
if(batch.complete&&!overflow)for(const [path,previous] of baseline)if(!next.has(path))changes.push({kind:'deleted',path,previous});
|
|
40
|
+
}
|
|
41
|
+
// Once tracking overflows, only a complete, bounded snapshot restores
|
|
42
|
+
// completeness. Partial snapshots never prove that missing files vanished.
|
|
43
|
+
if(batch.complete){baseline=next;limited=overflow;}
|
|
44
|
+
else{baseline??=new Map();for(const [path,file] of next){if(baseline.has(path)||baseline.size<MAX_TRACKED_FILES)baseline.set(path,file);else overflow=true;}limited||=overflow;}
|
|
45
|
+
publish(changes,batch,true);
|
|
46
|
+
}else{
|
|
47
|
+
baseline??=new Map();for(const change of batch.changes){if(change.kind==='deleted')baseline.delete(change.path);else if(change.file){if(baseline.has(change.path)||baseline.size<MAX_TRACKED_FILES)baseline.set(change.path,{...change.file});else limited=true;}}
|
|
48
|
+
// Native change events are authoritative even when size/mtime are unchanged.
|
|
49
|
+
publish(batch.changes,batch);
|
|
50
|
+
}
|
|
51
|
+
attempt=0;status('ready',batch.complete);
|
|
52
|
+
};
|
|
53
|
+
const interval=Math.max(1000,options.intervalMs??15000);
|
|
54
|
+
const scan=async()=>{
|
|
55
|
+
if(!alive)return;controller=new AbortController();const signal=controller.signal;
|
|
56
|
+
try{const files=await listFolder(backend,jobId,'',{signal,maxFiles:10000,maxBytes:Infinity});if(alive&&!signal.aborted)accept({type:'snapshot',files:files.map(file=>({...file,name:file.path.split('/').pop()??file.path,type:'file' as const})),complete:true});}
|
|
57
|
+
catch(error){if(alive&&!signal.aborted){attempt++;status('error',false,error instanceof Error?error.message:String(error));}}
|
|
58
|
+
finally{controller=null;if(alive)timer=setTimeout(()=>{timer=undefined;void scan();},Math.min(120000,interval*Math.max(1,attempt)));}
|
|
59
|
+
};
|
|
60
|
+
const connect=()=>{
|
|
61
|
+
if(!alive||!backend.watchFiles)return;const token=++generation;let finished=false,cleanup:(()=>void)|null=null;
|
|
62
|
+
blocked=false;
|
|
63
|
+
status(attempt?'reconnecting':'connecting');
|
|
64
|
+
if(!alive||token!==generation)return;
|
|
65
|
+
// Retirement can precede watchFiles returning its disposer. Clearing the
|
|
66
|
+
// owned handle first also makes reentrant cancellation release it once.
|
|
67
|
+
const retire=()=>{finished=true;if(stopTransport===retire)stopTransport=null;const dispose=cleanup;cleanup=null;notifyCallback(dispose??undefined,undefined);};
|
|
68
|
+
const close=(error:unknown)=>{
|
|
69
|
+
if(!alive||finished||token!==generation)return;retire();if(!alive||token!==generation)return;const code=error instanceof CenterError?error.status:0;
|
|
70
|
+
blocked=[400,404,405,410,501].includes(code);const slow=[401,403,429].includes(code);
|
|
71
|
+
status(blocked||slow?'error':'reconnecting',false,error instanceof Error?error.message:undefined);
|
|
72
|
+
if(!alive||token!==generation||blocked)return;
|
|
73
|
+
const delay=slow?30000:Math.min(30000,1000*2**Math.min(attempt++,5));
|
|
74
|
+
timer=setTimeout(()=>{timer=undefined;connect();},delay);
|
|
75
|
+
};
|
|
76
|
+
stopTransport=retire;
|
|
77
|
+
try{cleanup=backend.watchFiles(jobId,{onEvent:batch=>{if(alive&&!finished&&token===generation)accept(batch);},onClose:close});if(finished||!alive||token!==generation)retire();}catch(error){close(error);}
|
|
78
|
+
};
|
|
79
|
+
const wake=()=>{if(!alive)return;if(typeof document!=='undefined'&&document.visibilityState==='hidden')return;if(timer){clearTimeout(timer);timer=undefined;}if(mode==='poll'){if(!controller)void scan();}else{const token=++generation,retire=stopTransport;stopTransport=null;retire?.();if(alive&&token===generation)connect();}};
|
|
80
|
+
owned.retry=()=>{if(blocked)wake();};
|
|
81
|
+
if(typeof window!=='undefined'){window.addEventListener('online',wake);document.addEventListener('visibilitychange',wake);}
|
|
82
|
+
owned.stop=()=>{alive=false;generation++;if(timer)clearTimeout(timer);controller?.abort();stopTransport?.();if(typeof window!=='undefined'){window.removeEventListener('online',wake);document.removeEventListener('visibilitychange',wake);}};
|
|
83
|
+
// Defer initialization until the first listener has been registered.
|
|
84
|
+
queueMicrotask(()=>{if(alive){if(mode==='events')connect();else void scan();}});
|
|
85
|
+
}
|
|
86
|
+
const owned=entry;owned.listeners.add(subscription);notifyCallback(subscription.status,{...owned.status});owned.retry?.();
|
|
87
|
+
let active=true;return()=>{if(!active)return;active=false;owned.listeners.delete(subscription);if(!owned.listeners.size){if(entries!.get(jobId)===owned)entries!.delete(jobId);owned.stop();}};
|
|
88
|
+
}
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// 有上限:文件数、总大小(读进内存再打包);超了直接报出来,让用户进到更小的目录或单个下载,不会把页面撑爆。
|
|
3
3
|
// 弱网 / 节点忙:每个请求失败了按退避重试几次;某个文件一直读不到(或读的时候被任务删了)就跳过,
|
|
4
4
|
// 照样把其余的打包,失败的清单放进压缩包里的 FAILED_LIST_NAME,并告诉调用方。取消会中止正在传的请求。
|
|
5
|
-
import { CenterError, type BoardBackend } from "../core/client
|
|
6
|
-
import { fmtSize } from "./fileKind
|
|
7
|
-
import { saveBlob } from "./fileOps
|
|
8
|
-
import { buildZip, type ZipEntry } from "./zip
|
|
5
|
+
import { CenterError, type BoardBackend } from "../core/client";
|
|
6
|
+
import { fmtSize } from "./fileKind";
|
|
7
|
+
import { saveBlob, readBoundedBuffer, FileReadLimitError } from "./fileOps";
|
|
8
|
+
import { buildZip, type ZipEntry } from "./zip";
|
|
9
9
|
|
|
10
10
|
export const FOLDER_MAX_FILES = 2000;
|
|
11
11
|
export const FOLDER_MAX_BYTES = 300 * 1024 * 1024;
|
|
@@ -17,7 +17,7 @@ const BACKOFF_MS = [1_000, 3_000];
|
|
|
17
17
|
export const FAILED_LIST_NAME = "_下载失败的文件.txt";
|
|
18
18
|
|
|
19
19
|
export type FolderProgress = { phase: "listing" | "reading" | "zipping"; files: number; total: number; bytes: number; totalBytes?: number; failed?: number; retrying?: string };
|
|
20
|
-
export type FolderResult = { files: number; failed: Array<{ path: string; reason: string }
|
|
20
|
+
export type FolderResult = { files: number; failed: Array<{ path: string; reason: string }>; /** ZIP failure report path, when generated; may differ to preserve a user file. */ failedListName?:string };
|
|
21
21
|
export type FolderZip = FolderResult & { blob: Blob; name: string };
|
|
22
22
|
export type ListOptions = { signal?: AbortSignal; backoff?: number[]; maxFiles?: number; maxBytes?: number };
|
|
23
23
|
|
|
@@ -43,15 +43,19 @@ function sleep(ms: number, signal?: AbortSignal) {
|
|
|
43
43
|
export async function withRetry<T>(fn: () => Promise<T>, signal?: AbortSignal, onRetry?: (attempt: number, error: unknown) => void, backoff = BACKOFF_MS): Promise<T> {
|
|
44
44
|
for (let attempt = 1; ; attempt++) {
|
|
45
45
|
if (signal?.aborted) throw abortError();
|
|
46
|
+
let stop:(()=>void)|undefined;
|
|
46
47
|
try {
|
|
47
|
-
|
|
48
|
+
const cancelled=new Promise<never>((_resolve,reject)=>{stop=()=>reject(abortError());signal?.addEventListener("abort",stop,{once:true});});
|
|
49
|
+
const value=await Promise.race([fn(),cancelled]);
|
|
50
|
+
if(signal?.aborted)throw abortError();
|
|
51
|
+
return value;
|
|
48
52
|
} catch (error) {
|
|
49
53
|
if (isAbort(error) || signal?.aborted) throw abortError();
|
|
50
|
-
if (attempt >= ATTEMPTS || !isTransient(error)) throw error;
|
|
54
|
+
if (attempt >= ATTEMPTS || error instanceof FileReadLimitError || !isTransient(error)) throw error;
|
|
51
55
|
onRetry?.(attempt, error);
|
|
52
56
|
const base = backoff[Math.min(attempt - 1, backoff.length - 1)] ?? 0;
|
|
53
57
|
await sleep(Math.round(base * (0.7 + Math.random() * 0.6)), signal);
|
|
54
|
-
}
|
|
58
|
+
} finally {if(stop)signal?.removeEventListener("abort",stop);}
|
|
55
59
|
}
|
|
56
60
|
}
|
|
57
61
|
|
|
@@ -89,13 +93,21 @@ export type FolderOptions = { onProgress?: (p: FolderProgress) => void; signal?:
|
|
|
89
93
|
|
|
90
94
|
/** 打包下载 dir("" = 整个工作区)到本地。返回打了几个文件、哪些没读到。 */
|
|
91
95
|
export async function downloadFolder(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions = {}): Promise<FolderResult> {
|
|
92
|
-
const { blob, name, files, failed } = await zipFolder(backend, jobId, dir, opts);
|
|
96
|
+
const { blob, name, files, failed, failedListName } = await zipFolder(backend, jobId, dir, opts);
|
|
97
|
+
if(opts.signal?.aborted)throw abortError();
|
|
93
98
|
saveBlob(blob, name);
|
|
94
|
-
return { files, failed };
|
|
99
|
+
return { files, failed, ...(failedListName?{failedListName}:{}) };
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
/** 把 dir("" = 整个工作区)打成 ZIP,返回 Blob(不下载;业务方可以自己上传到服务器)。 */
|
|
98
103
|
export async function zipFolder(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions = {}): Promise<FolderZip> {
|
|
104
|
+
const ctl=new AbortController(),abort=()=>ctl.abort();let limit:FileReadLimitError|undefined;
|
|
105
|
+
opts.signal?.addEventListener("abort",abort,{once:true});if(opts.signal?.aborted)ctl.abort();
|
|
106
|
+
try{return await zipFolderRun(backend,jobId,dir,{...opts,signal:ctl.signal},()=>{limit=new FileReadLimitError(FOLDER_MAX_BYTES);ctl.abort();});}
|
|
107
|
+
catch(error){throw limit??error;}
|
|
108
|
+
finally{opts.signal?.removeEventListener("abort",abort);ctl.abort();}
|
|
109
|
+
}
|
|
110
|
+
async function zipFolderRun(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions,exceeded:()=>void): Promise<FolderZip> {
|
|
99
111
|
const { onProgress, signal } = opts;
|
|
100
112
|
onProgress?.({ phase: "listing", files: 0, total: 0, bytes: 0 });
|
|
101
113
|
const files = await listFolder(backend, jobId, dir, { signal, backoff: opts.backoff });
|
|
@@ -123,7 +135,7 @@ export async function zipFolder(backend: BoardBackend, jobId: string, dir: strin
|
|
|
123
135
|
const f = files[i];
|
|
124
136
|
try {
|
|
125
137
|
const buffer = await withRetry(
|
|
126
|
-
() => { got[i] = 0; return backend
|
|
138
|
+
() => { got[i] = 0; return readBoundedBuffer(backend,jobId,f.path,FOLDER_MAX_BYTES,{ signal, onBytes: (n) => { got[i] = n; if(got.reduce((a,b)=>a+b,0)>FOLDER_MAX_BYTES){exceeded();return;} report(); } }); },
|
|
127
139
|
signal,
|
|
128
140
|
() => { retrying = f.path; report(true); },
|
|
129
141
|
opts.backoff,
|
|
@@ -131,7 +143,7 @@ export async function zipFolder(backend: BoardBackend, jobId: string, dir: strin
|
|
|
131
143
|
const data = new Uint8Array(buffer);
|
|
132
144
|
got[i] = data.length;
|
|
133
145
|
// 任务在写、文件比列的时候大了很多:总量还是要守住上限。
|
|
134
|
-
if (got.reduce((a, b) => a + b, 0) > FOLDER_MAX_BYTES
|
|
146
|
+
if (got.reduce((a, b) => a + b, 0) > FOLDER_MAX_BYTES) throw new Error(`读到的总大小超过 ${fmtSize(FOLDER_MAX_BYTES)}(文件还在变大),请进到更小的目录或单个下载`);
|
|
135
147
|
// ZIP 里的路径相对下载的目录(文件夹名本身作为 ZIP 文件名)。
|
|
136
148
|
entries[i] = { path: f.path.startsWith(base) ? f.path.slice(base.length) : f.path, data, mtime: f.mtime };
|
|
137
149
|
} catch (error) {
|
|
@@ -151,11 +163,15 @@ export async function zipFolder(backend: BoardBackend, jobId: string, dir: strin
|
|
|
151
163
|
const ok = entries.filter((e): e is ZipEntry => !!e);
|
|
152
164
|
if (!ok.length) throw new Error(`${failed.length} 个文件都没读到:${failed[0]?.reason ?? "未知原因"}`);
|
|
153
165
|
onProgress?.({ phase: "zipping", files: done, total: files.length, bytes: got.reduce((a, b) => a + b, 0), totalBytes, failed: failed.length });
|
|
166
|
+
if(signal?.aborted)throw abortError();
|
|
167
|
+
let failedListName:string|undefined;
|
|
154
168
|
if (failed.length) {
|
|
169
|
+
const names=new Set(ok.map(entry=>entry.path));failedListName=FAILED_LIST_NAME;
|
|
170
|
+
for(let suffix=1;names.has(failedListName);suffix++)failedListName=`_下载失败的文件 (${suffix}).txt`;
|
|
155
171
|
const list = ["下面这些文件没有下载下来(其余文件都在压缩包里):", "", ...failed.map((f) => `${f.path}\t${f.reason}`), ""].join("\n");
|
|
156
|
-
ok.push({ path:
|
|
172
|
+
ok.push({ path: failedListName, data: new TextEncoder().encode(list) as Uint8Array<ArrayBuffer> });
|
|
157
173
|
}
|
|
158
174
|
const blob = buildZip(ok);
|
|
159
175
|
const name = opts.zipName ?? `${(dir.split("/").filter(Boolean).pop() || "工作区")}.zip`;
|
|
160
|
-
return { blob, name, files: ok.length - (failed.length ? 1 : 0), failed };
|
|
176
|
+
return { blob, name, files: ok.length - (failed.length ? 1 : 0), failed, ...(failedListName?{failedListName}:{}) };
|
|
161
177
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
|
2
|
+
const useLayout = typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
3
|
+
/** Initial visibility is separate from capability. Closing a panel never disables its backend. */
|
|
4
|
+
export function useBoardPanels(scope: string | null, available: readonly string[], initial: readonly string[]) {
|
|
5
|
+
const allowed = [...new Set(available)];
|
|
6
|
+
const defaults = [...new Set(initial)].filter(id => allowed.includes(id));
|
|
7
|
+
const signature = JSON.stringify([allowed, defaults]);
|
|
8
|
+
const key = scope ? `cli-ui:panels:${scope}:${signature}` : null;
|
|
9
|
+
const [state, setState] = useState<{ key: string | null; open: string[] }>({ key, open: defaults });
|
|
10
|
+
const current = state.key === key ? state.open.filter(id => allowed.includes(id)) : defaults;
|
|
11
|
+
useLayout(() => {
|
|
12
|
+
let open = defaults;
|
|
13
|
+
try {
|
|
14
|
+
const saved = key ? JSON.parse(sessionStorage.getItem(key) ?? 'null') : null;
|
|
15
|
+
if (Array.isArray(saved)) open = [...new Set(saved.filter((id): id is string => typeof id === 'string' && allowed.includes(id)))];
|
|
16
|
+
} catch { /* Restricted storage does not disable panel controls. */ }
|
|
17
|
+
setState({ key, open });
|
|
18
|
+
}, [key, signature]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (key && state.key === key) try { sessionStorage.setItem(key, JSON.stringify(state.open)); } catch { /* In-memory only. */ }
|
|
21
|
+
}, [key, state]);
|
|
22
|
+
const set = useCallback((id: string, open: boolean) => {
|
|
23
|
+
if (!allowed.includes(id)) return false;
|
|
24
|
+
setState(previous => {
|
|
25
|
+
const list = previous.key === key ? previous.open : defaults;
|
|
26
|
+
return { key, open: open ? [...new Set([...list, id])] : list.filter(item => item !== id) };
|
|
27
|
+
});
|
|
28
|
+
return true;
|
|
29
|
+
}, [key, signature]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
30
|
+
const show=useCallback((id:string)=>set(id,true),[set]);
|
|
31
|
+
const close=useCallback((id:string)=>set(id,false),[set]);
|
|
32
|
+
return { open: current, available: allowed, show, close };
|
|
33
|
+
}
|
package/src/board/textLoad.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// 这一小段和手里的末尾一模一样就说明文件只是往后追加了,只取新增的部分接上;对不上(文件被改写、变短了)才整个重读。
|
|
6
6
|
// 只对 UTF-8 文件这样做(GBK 的字节边界不好对齐,整个重读)。
|
|
7
7
|
|
|
8
|
-
import type { BoardBackend } from "../core/client
|
|
9
|
-
import { decodeText } from "../core/textDecode
|
|
8
|
+
import type { BoardBackend } from "../core/client";
|
|
9
|
+
import { decodeText } from "../core/textDecode";
|
|
10
10
|
|
|
11
11
|
/** 往前多取这么多字节,用来确认文件只是追加。 */
|
|
12
12
|
export const OVERLAP = 256;
|
package/src/board/workspace.ts
CHANGED
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
*
|
|
12
12
|
* 数据都经中枢读执行节点(和看板一样),弱网时按网络错误(CenterError,code = "NETWORK")失败,可以重试。
|
|
13
13
|
*/
|
|
14
|
-
import type
|
|
15
|
-
import type {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
14
|
+
import { subscribeWorkspaceFiles, type WorkspaceWatchOptions } from "./fileWatch";
|
|
15
|
+
import type { BoardBackend } from "../core/client";
|
|
16
|
+
import type { BoardInfo, FileEntry, WorkspaceChangeEvent } from "../core/types";
|
|
17
|
+
import { directFileUrl, downloadFile, mimeOf } from "./fileOps";
|
|
18
|
+
import { fmtSize } from "./fileKind";
|
|
19
|
+
import { downloadFolder, listFolder, zipFolder, type FolderOptions, type FolderResult, type FolderZip } from "./folderDownload";
|
|
19
20
|
|
|
20
21
|
/** readText 最多读这么大(更大的用 readFile)。 */
|
|
21
22
|
export const WORKSPACE_TEXT_MAX = 20 * 1024 * 1024;
|
|
@@ -24,6 +25,8 @@ export const WORKSPACE_FILE_MAX = 500 * 1024 * 1024;
|
|
|
24
25
|
/** listAll 最多列这么多个文件。 */
|
|
25
26
|
export const WORKSPACE_LIST_MAX = 10_000;
|
|
26
27
|
|
|
28
|
+
export interface BoardFile { path: string; sourceId?: string }
|
|
29
|
+
|
|
27
30
|
/** 看板现在的样子(看板组件报告给仓库;没有看板时没有)。 */
|
|
28
31
|
export interface BoardView {
|
|
29
32
|
/** 当前页签:files / changes / browser、"file:<路径>"(打开的文件)或自己加的页签 id。 */
|
|
@@ -34,6 +37,14 @@ export interface BoardView {
|
|
|
34
37
|
file: string | null;
|
|
35
38
|
/** 开着的文件页签。 */
|
|
36
39
|
files: string[];
|
|
40
|
+
/** 当前文件身份;切到目录/浏览器时为 null。sourceId 保留来源任务。 */
|
|
41
|
+
current?: BoardFile | null;
|
|
42
|
+
opened?: BoardFile[];
|
|
43
|
+
/** 最近真正访问过的文件(关闭页签后保留,最多40条)。 */
|
|
44
|
+
history?: BoardFile[];
|
|
45
|
+
browser?: { url: string; title: string } | null;
|
|
46
|
+
visible?: boolean;
|
|
47
|
+
autoContext?: boolean;
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
export interface CliWorkspace {
|
|
@@ -41,6 +52,11 @@ export interface CliWorkspace {
|
|
|
41
52
|
readonly jobId: string;
|
|
42
53
|
/** 看板概况:派发到节点没有、节点是否在线、有没有浏览器。 */
|
|
43
54
|
info(): Promise<BoardInfo>;
|
|
55
|
+
stat(path: string): ReturnType<BoardBackend["stat"]>;
|
|
56
|
+
/** 订阅文件变更。初始快照只建立基线;返回取消函数。 */
|
|
57
|
+
watch(listener: (event: WorkspaceChangeEvent) => void, options?: WorkspaceWatchOptions): () => void;
|
|
58
|
+
/** 包括当前文件、打开页签、最近浏览和显示状态的背景快照。 */
|
|
59
|
+
context(): BoardView | null;
|
|
44
60
|
/** 列一个目录(不递归)。 */
|
|
45
61
|
list(dir?: string): Promise<FileEntry[]>;
|
|
46
62
|
/** 列 dir 下所有文件(递归,只含文件)。 */
|
|
@@ -70,6 +86,8 @@ export function createWorkspace(deps: {
|
|
|
70
86
|
key: string;
|
|
71
87
|
view: () => BoardView | undefined;
|
|
72
88
|
open: (path: string) => boolean;
|
|
89
|
+
context?: () => BoardView | null;
|
|
90
|
+
watch?: (listener:(event:WorkspaceChangeEvent)=>void,options?:WorkspaceWatchOptions)=>()=>void;
|
|
73
91
|
}): CliWorkspace {
|
|
74
92
|
const { key } = deps;
|
|
75
93
|
const backend = () => {
|
|
@@ -80,6 +98,9 @@ export function createWorkspace(deps: {
|
|
|
80
98
|
return {
|
|
81
99
|
jobId: key,
|
|
82
100
|
info: () => backend().info(key),
|
|
101
|
+
stat: path => backend().stat(key, clean(path) || "."),
|
|
102
|
+
watch: (listener, options) => deps.watch ? deps.watch(listener,options) : subscribeWorkspaceFiles(backend(), key, listener, options),
|
|
103
|
+
context: () => deps.context?.() ?? deps.view() ?? null,
|
|
83
104
|
async list(dir = "") {
|
|
84
105
|
const res = await backend().stat(key, clean(dir) || ".");
|
|
85
106
|
if (res.type !== "dir") throw new Error(`「${dir}」不是目录`);
|
|
@@ -96,23 +117,31 @@ export function createWorkspace(deps: {
|
|
|
96
117
|
},
|
|
97
118
|
async readFile(path, opts = {}) {
|
|
98
119
|
const p = clean(path);
|
|
120
|
+
const max = opts.maxBytes ?? WORKSPACE_FILE_MAX;
|
|
121
|
+
if(!Number.isFinite(max)||max<0)throw new RangeError("maxBytes 必须是非负有限字节数");
|
|
99
122
|
// 太大的读到一半就停:整个读进内存会把页面撑爆。
|
|
100
123
|
const ctl = new AbortController();
|
|
101
124
|
const stop = () => ctl.abort();
|
|
102
125
|
opts.signal?.addEventListener("abort", stop, { once: true });
|
|
126
|
+
if(opts.signal?.aborted)ctl.abort();
|
|
103
127
|
let tooBig = false;
|
|
104
|
-
|
|
128
|
+
let rejectAbort:(()=>void)|undefined;
|
|
105
129
|
try {
|
|
106
|
-
|
|
130
|
+
if(ctl.signal.aborted)throw new DOMException("已取消", "AbortError");
|
|
131
|
+
const aborted=new Promise<never>((_resolve,reject)=>{rejectAbort=()=>reject(new DOMException("已取消", "AbortError"));ctl.signal.addEventListener("abort",rejectAbort,{once:true});});
|
|
132
|
+
const buffer = await Promise.race([backend().readBuffer(key, p, {
|
|
107
133
|
signal: ctl.signal,
|
|
108
|
-
onBytes: (n) => { if (n > max) { tooBig = true; ctl.abort(); } else opts.onProgress?.(n); },
|
|
109
|
-
});
|
|
134
|
+
onBytes: (n) => { if(ctl.signal.aborted)return; if (n > max) { tooBig = true; ctl.abort(); } else opts.onProgress?.(n); },
|
|
135
|
+
}),aborted]);
|
|
136
|
+
if(buffer.byteLength>max){tooBig=true;throw new Error("文件超过读取上限");}
|
|
137
|
+
if(ctl.signal.aborted)throw new DOMException("已取消", "AbortError");
|
|
110
138
|
return new Blob([buffer], { type: mimeOf(p) });
|
|
111
139
|
} catch (error) {
|
|
112
140
|
if (tooBig) throw new Error(`文件超过 ${fmtSize(max)},请用 fileUrl / download(浏览器流式下载,不占内存)`);
|
|
113
141
|
throw error;
|
|
114
142
|
} finally {
|
|
115
143
|
opts.signal?.removeEventListener("abort", stop);
|
|
144
|
+
if(rejectAbort)ctl.signal.removeEventListener("abort",rejectAbort);
|
|
116
145
|
}
|
|
117
146
|
},
|
|
118
147
|
async fileUrl(path, opts = {}) {
|
package/src/chat/CliChat.tsx
CHANGED
|
@@ -1,27 +1,36 @@
|
|
|
1
1
|
// cli-chat:对话。界面取自 Operator 的会话视图(SessionView 的对话部分 + Composer),
|
|
2
2
|
// 数据来自 center 的对话任务(mode=conversation):实时流里的节点转录按 Operator 的规则渲染,
|
|
3
3
|
// 每条消息是中枢的一轮,排队 / 执行 / 结束都跟 center 保持一致。
|
|
4
|
-
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
5
|
-
import { CliErrorBoundary } from "../ui/ErrorBoundary
|
|
6
|
-
import type { CliEvent, CliStore, CliStoreOptions } from "../core/store
|
|
7
|
-
import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
4
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type CSSProperties } from "react";
|
|
5
|
+
import { CliErrorBoundary } from "../ui/ErrorBoundary";
|
|
6
|
+
import type { CliEvent, CliStore, CliStoreOptions } from "../core/store";
|
|
7
|
+
import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider, type ResultHandlers } from "../core/provider";
|
|
8
|
+
import { formatBoardContext, withoutBoardContext } from "../board/context";
|
|
9
|
+
import { MAX_MESSAGE_CHARS, charCount, boardContextBlock, serializeBlock, viewingBlock } from "../core/blocks";
|
|
10
|
+
import { convKey, emptyConv, isChatTask, isNewInJob, isRunning, parseConvKey, runningTurn, TERMINAL_JOB, viewMessages } from "../core/conversation";
|
|
11
|
+
import type { AskAnswers, AskQuestion, CenterTask, Msg } from "../core/types";
|
|
12
|
+
import { Icon } from "../ui/icons";
|
|
13
|
+
import { agentLabel as labelOf, fmtTokens, formatAnswersText, modelLabel } from "../ui/format";
|
|
14
|
+
import { AgentBadge, Avatar, Skel, StatusDot, type DotCls } from "../ui/shared";
|
|
15
|
+
import { HANDOFF_NOTICE_PREFIX, MessageView, SessionBreak, type MessageHandlers } from "../ui/Transcript";
|
|
16
|
+
import { RENDER_MAX, RENDER_STEP, RENDER_WINDOW, buildTranscriptItems, hasOpenAsk, toolDataOf } from "../ui/transcriptModel";
|
|
17
|
+
import { Composer, type ComposerOptions } from "./Composer";
|
|
18
|
+
import { fillTemplate, refShown, resolveChatConfig, type CliChatConfig } from "../core/config";
|
|
19
|
+
import { exportMarkdown } from "./export";
|
|
20
|
+
import { FileLinkContext, FileOpenContext } from "../ui/fileLinks";
|
|
21
|
+
import { FileViewer } from "../board/FileViewer";
|
|
22
|
+
import { BrowserPane } from "../board/BrowserPane";
|
|
22
23
|
|
|
23
24
|
/** onTurnEnd / onConversationEnd:浏览器里从实时流算出的结果通知,不依赖 center 的 callback.url。 */
|
|
24
25
|
export interface CliChatProps extends CliStoreOptions, ResultHandlers {
|
|
26
|
+
/** Integration slots share this transcript/composer; they do not create another chat implementation. */
|
|
27
|
+
slots?: { beforeMessages?: ReactNode; afterMessages?: ReactNode; composerToolbar?: ReactNode; beforeComposer?: ReactNode };
|
|
28
|
+
/** Saving execution settings blocks send until persistence completes. */
|
|
29
|
+
configurationBusy?: boolean;
|
|
30
|
+
onClear?: () => void;
|
|
31
|
+
/** 每条消息自动带入看板当前文件、页签和最近浏览;默认 true。 */
|
|
32
|
+
passiveContext?: boolean;
|
|
33
|
+
|
|
25
34
|
/**
|
|
26
35
|
* 显示哪个对话(center 的 job_id)。不给就跟共享仓库的选中走(和 cli-msglist 联动);都没有 = 新对话。
|
|
27
36
|
* 也可以直接给对话键 "job_id#task_id"(cli-msglist 的 onSelect、job_change 事件的 key 就是它)。
|
|
@@ -120,7 +129,7 @@ function RunningIndicator({ agent, agentName, startedAt, lastActivityRef }: { ag
|
|
|
120
129
|
);
|
|
121
130
|
}
|
|
122
131
|
|
|
123
|
-
function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", color = "#b8613f", readOnly: readOnlyProp, allowClose = true, config, theme = "light", className, style, onEvent, onTurnEnd, onConversationEnd, onCallback, inProvider, fileUrl, fileOpen = "auto", newConversation, createConversation, businessIdFor }: CliChatProps & { inProvider: boolean }) {
|
|
132
|
+
function ChatInner({ slots, passiveContext = true, configurationBusy, onClear, jobId: jobIdProp, taskId: taskIdProp, title = "对话", color = "#b8613f", readOnly: readOnlyProp, allowClose = true, config, theme = "light", className, style, onEvent, onTurnEnd, onConversationEnd, onCallback, inProvider, fileUrl, fileOpen = "auto", newConversation, createConversation, businessIdFor }: CliChatProps & { inProvider: boolean }) {
|
|
124
133
|
useComponentEvents(onEvent, { onTurnEnd, onConversationEnd, onCallback }, inProvider);
|
|
125
134
|
// 配置按内容比较:宿主每次渲染传新对象也不会让消息行全部重渲染。
|
|
126
135
|
const configKey = JSON.stringify(config ?? {});
|
|
@@ -269,6 +278,9 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
269
278
|
// 本组件的新建参数随每次发送带上:放进共享 Provider 时也用自己的,不依赖仓库是谁建的。
|
|
270
279
|
const sendOpts = useRef({ newConversation, createConversation, businessIdFor, select: !controlled });
|
|
271
280
|
sendOpts.current = { newConversation, createConversation, businessIdFor, select: !controlled };
|
|
281
|
+
const [skipPassive,setSkipPassive]=useState(false);
|
|
282
|
+
const skipPassiveRef=useRef(false);skipPassiveRef.current=skipPassive;
|
|
283
|
+
useEffect(()=>setSkipPassive(false),[key]);
|
|
272
284
|
const send = useCallback(async (text: string) => {
|
|
273
285
|
// 一次性任务:发给正在看的那个子任务(接着它聊)。
|
|
274
286
|
const id = latest.current.conv?.batch ? latest.current.runKey : latest.current.key;
|
|
@@ -276,26 +288,33 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
276
288
|
// 看板上正在看的文件缺省也带上一次(已经明确引用了的不重复),单独一块「看板正在看」:转录里按 config.refs.viewing 显示或不显示。
|
|
277
289
|
const refKey = latest.current.key;
|
|
278
290
|
const refs = refKey ? store.takeRefs(refKey) : [];
|
|
279
|
-
const
|
|
291
|
+
const passiveView=refKey?store.getState().boardContext[refKey]:null;
|
|
292
|
+
const passive=passiveContext&&!skipPassiveRef.current&&passiveView?formatBoardContext(passiveView,refKey??undefined):'';
|
|
293
|
+
const viewing = !passive && refKey ? store.takeViewing(refKey) : null;
|
|
280
294
|
const blocks = [
|
|
295
|
+
...(passive ? [{name:"看板正在看",kind:"viewing" as const,text:passive}] : []),
|
|
281
296
|
...(viewing && !refs.some((r) => r.id === viewing.id) ? [viewingBlock(viewing.text)] : []),
|
|
282
297
|
...(refs.length ? [boardContextBlock(refs.map((r) => r.text))] : []),
|
|
283
298
|
];
|
|
284
299
|
const full = blocks.length ? [text, ...blocks.map(serializeBlock)].join("\n\n") : text;
|
|
285
300
|
let target: string;
|
|
286
301
|
try {
|
|
302
|
+
if(charCount(full)>MAX_MESSAGE_CHARS)throw new Error("加上看板背景后消息过长,请精简内容或取消本次自动附带。");
|
|
287
303
|
target = await store.send(id ?? null, full, sendOpts.current);
|
|
288
304
|
} catch (error) {
|
|
289
305
|
if (refKey) { store.restoreRefs(refKey, refs); store.restoreViewing(refKey, viewing); }
|
|
290
306
|
throw error;
|
|
291
307
|
}
|
|
308
|
+
if(latest.current.key===refKey)setSkipPassive(false);
|
|
292
309
|
if (target !== id) go(target);
|
|
293
|
-
}, [store, go]);
|
|
310
|
+
}, [store, go, passiveContext]);
|
|
311
|
+
const passiveView=useCliState(state=>key?state.boardContext[key]:undefined);
|
|
312
|
+
const passiveText=passiveContext&&passiveView?formatBoardContext(passiveView,key??undefined):'';
|
|
294
313
|
const refs = useCliState((s) => (key ? s.refs[key] : undefined));
|
|
295
314
|
const viewingState = useCliState((s) => (key ? s.viewing[key] : undefined));
|
|
296
315
|
const viewingRef = viewingState && !viewingState.off && !refs?.some((r) => r.id === viewingState.ref.id) ? viewingState.ref : null;
|
|
297
316
|
// 输入框上方显示哪些(config.refs):不显示的照样随消息带上。正在看的缺省不显示。
|
|
298
|
-
const viewing = viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
|
|
317
|
+
const viewing = !passiveText && viewingRef && refShown(cfg, "viewing") ? viewingRef : null;
|
|
299
318
|
const shownRefs = refs?.filter((r) => refShown(cfg, r.kind));
|
|
300
319
|
// 业务方补充的背景(store.appendContext):随下一条消息带上,输入框上方显示出来,让用户知道会带上什么。
|
|
301
320
|
const contextItems = useCliState((s) => s.context[key ?? ""]);
|
|
@@ -324,6 +343,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
324
343
|
return () => window.removeEventListener("keydown", onKey);
|
|
325
344
|
}, [popup]);
|
|
326
345
|
const handlers = useMemo<MessageHandlers>(() => ({
|
|
346
|
+
onQuote: readOnly ? undefined : (m,selected) => {const id=latest.current.key;if(id) store.addRef(id,{id:`message:${m.id}`,kind:"message",label:m.role==='user'?'引用你的消息':'引用智能体消息',text:selected||(m.role==='user'?withoutBoardContext(m.content):m.content)});},
|
|
327
347
|
// 与 Operator 的 answerQuestion 一致:先把卡片标成已答,再把回答发出去(center 已结束提问那一轮,回答就是下一条消息)。
|
|
328
348
|
onAnswer: readOnlyProp || (readOnly && !(batch && store.client.answerAsk)) ? undefined : async (askId: string, questions: AskQuestion[], answers: AskAnswers) => {
|
|
329
349
|
const id = latest.current.key;
|
|
@@ -361,7 +381,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
361
381
|
onRetry: readOnly ? undefined : (msgId: string) => {
|
|
362
382
|
const list = latest.current.messages;
|
|
363
383
|
const at = list.findIndex((m) => m.id === msgId);
|
|
364
|
-
for (let j = (at === -1 ? list.length : at) - 1; j >= 0; j--) if (list[j].role === "user") { void report(send(list[j].content), "重试没有发出去").catch(() => undefined); return; }
|
|
384
|
+
for (let j = (at === -1 ? list.length : at) - 1; j >= 0; j--) if (list[j].role === "user") { void report(send(withoutBoardContext(list[j].content)), "重试没有发出去").catch(() => undefined); return; }
|
|
365
385
|
},
|
|
366
386
|
}), [readOnly, readOnlyProp, batch, chat, send, store, go, report, fileOpen]);
|
|
367
387
|
const stop = useCallback(async () => {
|
|
@@ -369,7 +389,19 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
369
389
|
const turn = c ? runningTurn(c) : null;
|
|
370
390
|
if (id && turn) await report(store.cancelTurn(id, turn.turn_id), "停止失败");
|
|
371
391
|
}, [store, report]);
|
|
372
|
-
const [
|
|
392
|
+
const [closeAction, setCloseAction] = useState<{ key: string; stage: "confirm" | "busy" } | null>(null);
|
|
393
|
+
const closing = closeAction?.key === key ? closeAction.stage : "idle";
|
|
394
|
+
useEffect(() => setCloseAction(null), [key]);
|
|
395
|
+
const confirmClose = () => {
|
|
396
|
+
if (!key || closeAction?.key !== key || closeAction.stage !== "confirm") return;
|
|
397
|
+
const pending = { key, stage: "busy" as const };
|
|
398
|
+
setCloseAction(pending);
|
|
399
|
+
void store.close(key).then(() => {
|
|
400
|
+
if (latest.current.key === pending.key) setActionError(null);
|
|
401
|
+
}, (error: unknown) => {
|
|
402
|
+
if (latest.current.key === pending.key) setActionError(`结束对话失败:${error instanceof Error ? error.message : String(error)}`);
|
|
403
|
+
}).finally(() => setCloseAction(current => current === pending ? null : current));
|
|
404
|
+
};
|
|
373
405
|
|
|
374
406
|
// ── 运行计时(与 Operator 一致:本页看着开始的轮次按本机时钟,进来时已在跑的按那条用户消息的时间) ──
|
|
375
407
|
const lastUserTs = useMemo(() => { for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "user") return messages[i].ts ?? null; return null; }, [messages]);
|
|
@@ -614,7 +646,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
614
646
|
{Icon.download()}<span>{older > 0 ? "导出已加载" : "导出"}</span>
|
|
615
647
|
</button>}
|
|
616
648
|
{h.close && key && !isNew && allowClose && !readOnly && !ended && !batch && (
|
|
617
|
-
<button className="btn btn-line btn-sm" onClick={() =>
|
|
649
|
+
<button className="btn btn-line btn-sm" onClick={() => key && setCloseAction({ key, stage: "confirm" })} disabled={closing === "busy"} title={taskId ? "只结束这个对话:之后它不再接收消息,正在跑的一轮会被中止;同一任务里的其他对话不受影响" : "结束这个对话:之后不再接收消息,正在跑的一轮会被中止"}>{Icon.stop()} 结束对话</button>
|
|
618
650
|
)}
|
|
619
651
|
</div>
|
|
620
652
|
</div>}
|
|
@@ -623,8 +655,8 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
623
655
|
<div className="clear-confirm">
|
|
624
656
|
<span>结束这个对话?结束后不能再发消息{running ? ",正在执行的一轮会被中止" : ""}{taskId && manyConvs ? ";同一任务里的其他对话不受影响" : ""}。</span>
|
|
625
657
|
<span className="spacer" />
|
|
626
|
-
<button className="btn btn-ghost btn-sm" onClick={() =>
|
|
627
|
-
<button className="btn btn-accent btn-sm" disabled={closing === "busy"} onClick={
|
|
658
|
+
<button className="btn btn-ghost btn-sm" onClick={() => setCloseAction(null)} disabled={closing === "busy"}>取消</button>
|
|
659
|
+
<button className="btn btn-accent btn-sm" disabled={closing === "busy"} onClick={confirmClose}>{closing === "busy" ? "正在结束…" : "确认结束"}</button>
|
|
628
660
|
</div>
|
|
629
661
|
)}
|
|
630
662
|
{actionError && <div className="sync-banner conflict"><span className="sync-msg">⚠ {actionError}</span><span className="sync-spacer" /><button className="btn btn-ghost btn-sm" onClick={() => setActionError(null)}>知道了</button></div>}
|
|
@@ -634,6 +666,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
634
666
|
<div className="transcript-wrap">
|
|
635
667
|
<div className="transcript" ref={scrollRef} onScroll={onScroll}>
|
|
636
668
|
<div className="tw">
|
|
669
|
+
{slots?.beforeMessages}
|
|
637
670
|
{(!key || isNew) && cfg.empty.show && (
|
|
638
671
|
<div className="hero cli-hero">
|
|
639
672
|
<div className="h-ic">{Icon.bolt()}</div>
|
|
@@ -698,6 +731,7 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
698
731
|
{ended && cfg.transcript.status && (
|
|
699
732
|
<div className="session-label"><span className="ln" />{batch ? statusText : <>{REASON_TEXT[closedReason ?? ""] ?? "对话已结束"}{!finalEnd ? " · 再发消息会开一个新对话接着聊" : ""}</>}<span className="ln" /></div>
|
|
700
733
|
)}
|
|
734
|
+
{slots?.afterMessages}
|
|
701
735
|
{continuedBy && (
|
|
702
736
|
<div className="load-older"><button className="btn btn-line btn-sm" onClick={() => go(continuedBy)}>转到接续的新对话</button></div>
|
|
703
737
|
)}
|
|
@@ -737,15 +771,24 @@ function ChatInner({ jobId: jobIdProp, taskId: taskIdProp, title = "对话", col
|
|
|
737
771
|
))}
|
|
738
772
|
</div>
|
|
739
773
|
)}
|
|
774
|
+
{showComposer && passiveText && passiveView && <div className="cli-board-background" role="note" aria-label="看板浏览背景">
|
|
775
|
+
<details><summary>看板背景 · {(passiveView.current?.path??passiveView.file)?.split('/').pop()??'未选中文件'} · 打开 {(passiveView.opened??passiveView.files).length} · 最近浏览 {passiveView.history?.length??0}</summary>
|
|
776
|
+
<div>{passiveView.visible===false?'看板已收起,将附带上次选中的文件及浏览记录。':'将附带当前文件、打开页签和最近浏览记录,仅发送路径等信息。'}</div>
|
|
777
|
+
<ul>{(passiveView.history??[]).map((file,index)=><li key={`${file.sourceId}:${file.path}:${index}`}>{file.path}{file.sourceId&&file.sourceId!==key?'(来源任务)':''}</li>)}</ul>
|
|
778
|
+
</details>
|
|
779
|
+
<label><input type="checkbox" checked={!skipPassive} onChange={event=>setSkipPassive(!event.target.checked)} />下条消息带入</label>
|
|
780
|
+
</div>}
|
|
781
|
+
{slots?.beforeComposer}
|
|
740
782
|
{showComposer && (
|
|
741
783
|
<Composer
|
|
742
784
|
key={key ?? "new"}
|
|
743
|
-
draft={key ?? `new:${instanceId}`}
|
|
785
|
+
draft={store.draftScope ? `${store.draftScope}:${key ?? `new:${instanceId}`}` : key ?? `new:${instanceId}`}
|
|
744
786
|
placeholder={fillTemplate(key && !isNew ? cfg.composer.placeholder : cfg.composer.placeholderNew, { title: convTitle, agent: agentText })}
|
|
745
|
-
disabled={composerDisabled}
|
|
787
|
+
disabled={composerDisabled || !!configurationBusy}
|
|
746
788
|
disabledHint={cfg.composer.placeholderDisabled ? fillTemplate(cfg.composer.placeholderDisabled, { title: convTitle, agent: agentText }) : disabledHint}
|
|
747
789
|
running={running || batchPending}
|
|
748
|
-
|
|
790
|
+
toolbar={slots?.composerToolbar}
|
|
791
|
+
onSend={async text => { if (text.trim() === "/clear" && onClear) { onClear(); return; } await send(text); }}
|
|
749
792
|
onStop={cfg.composer.stop && key && conv && runningTurn(conv) ? stop : undefined}
|
|
750
793
|
taskControl={cfg.composer.stop && !readOnlyProp && controlTask && jobId && store.client.pauseTask && store.client.resumeTask ? {
|
|
751
794
|
paused: controlTask.status === "paused",
|