@spzxg/cli-ui 0.6.6 → 0.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -1
- package/README.md +79 -20
- package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
- package/dist/{cli-ui-index-b8wF7wrN.js → cli-ui-index-BmwOLWFI.js} +2 -2
- package/dist/{cli-ui-jszip.min-ggP8OokP.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
- package/dist/{cli-ui-jszip.min-B0uLrCHg.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
- package/dist/{cli-ui-xlsx-DEDdHrJJ.js → cli-ui-xlsx-Sl7ypvSe.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +3 -1
- package/src/board/CliBoard.tsx +128 -100
- package/src/board/FileViewer.tsx +259 -132
- package/src/board/FilesPane.tsx +129 -40
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +19 -7
- package/src/board/fileKind.ts +138 -0
- package/src/board/fileOps.ts +9 -2
- package/src/board/fileScope.ts +29 -0
- package/src/board/fileSync.ts +343 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +71 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +177 -76
- package/src/board/folderDownload.ts +39 -5
- package/src/board/frameQuote.ts +10 -8
- package/src/board/htmlReading.ts +148 -0
- package/src/board/textLoad.ts +14 -39
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +38 -6
- package/src/chat/CliChat.tsx +86 -46
- package/src/chat/Composer.tsx +52 -20
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +132 -42
- package/src/chat/draft.ts +5 -2
- package/src/core/blocks.ts +2 -1
- package/src/core/client.ts +44 -57
- package/src/core/documentUpload.ts +196 -0
- package/src/core/store.ts +71 -14
- package/src/core/types.ts +1 -1
- package/src/index.ts +3 -0
- package/src/msglist/CliMsgList.tsx +9 -2
- package/src/operator/node/Chat.tsx +7 -8
- package/src/operator/node/Composer.tsx +99 -72
- package/src/operator/node/Orchestrator.tsx +1 -1
- package/src/operator/node/SessionRail.tsx +71 -32
- package/src/operator/node/SessionView.tsx +25 -25
- package/src/operator/node/Transcript.tsx +21 -9
- package/src/operator/node/boardBackend.ts +0 -19
- package/src/operator/node/boardEvents.ts +21 -3
- package/src/operator/node/boardState.ts +18 -1
- package/src/operator/node/boardWorkspace.ts +4 -1
- package/src/operator/node/composerDraft.ts +6 -2
- package/src/operator/node/format.ts +4 -2
- package/src/operator/node/liveStream.ts +14 -2
- package/src/operator/node/useTaskStream.ts +3 -1
- package/src/operator.base.css +141 -38
- package/src/operator.css +141 -38
- package/src/styles.css +248 -34
- package/src/ui/Markdown.tsx +2 -2
- package/src/ui/Popover.tsx +25 -12
- package/src/ui/QueuedMessages.tsx +69 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/TextBlock.tsx +6 -4
- package/src/ui/Transcript.tsx +20 -10
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/format.ts +3 -1
- package/src/ui/reference.ts +40 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
- package/dist/cli-ui-embed-BAqonuoD.js +0 -45952
package/src/board/fileWatch.ts
CHANGED
|
@@ -1,94 +1,195 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type BoardBackend } from '../core/client';
|
|
2
2
|
import { notifyCallback } from '../core/callbacks';
|
|
3
3
|
import type { FileEntry, WorkspaceChangeEvent, WorkspaceFileBatch, WorkspaceFileChange, WorkspaceWatchStatus } from '../core/types';
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import { denyFileScope, onFileScope, readFileScope, retainFileScope } from './fileScope';
|
|
5
|
+
import { onFilePreviewScope, refreshFilePreviews } from './fileSync';
|
|
6
|
+
import { createFileWorkQueue } from './fileSyncQueue';
|
|
7
|
+
export interface WorkspaceWatchOptions { /** @deprecated Retained for compatibility; checks are event-driven, never periodic. */ intervalMs?:number; paths?:readonly string[]; dirs?:readonly string[]; onStatus?:(status:WorkspaceWatchStatus)=>void }
|
|
6
8
|
type Listener={change:(event:WorkspaceChangeEvent)=>void;status?:WorkspaceWatchOptions['onStatus']};
|
|
7
|
-
|
|
9
|
+
export interface WorkspaceRefreshOptions { manual?:boolean }
|
|
10
|
+
type Entry={listeners:Set<Listener>;stop:()=>void;retry:(manual?:boolean)=>void;status:WorkspaceWatchStatus};
|
|
8
11
|
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
12
|
const registries=new WeakMap<BoardBackend,Map<string,Entry>>();
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
const metadataQueues=new WeakMap<BoardBackend,ReturnType<typeof createFileWorkQueue>>();
|
|
14
|
+
function metadataQueue(backend:BoardBackend){
|
|
15
|
+
let queue=metadataQueues.get(backend);if(!queue){queue=createFileWorkQueue(4,128);metadataQueues.set(backend,queue);}return queue;
|
|
16
|
+
}
|
|
17
|
+
export function retryWorkspaceFiles(backend:BoardBackend,jobId:string,options:WorkspaceRefreshOptions={}):void {registries.get(backend)?.get(jobId)?.retry(options.manual);}
|
|
18
|
+
const fingerprint=(file:FileEntry)=>`${file.type}:${file.size}:${file.mtime}:${file.changeToken??''}`;
|
|
19
|
+
const validFile=(file:FileEntry)=>!!file&&typeof file.path==='string'&&typeof file.name==='string'&&['file','dir'].includes(file.type)&&Number.isFinite(file.size)&&Number.isFinite(file.mtime);
|
|
20
|
+
const invalidResponse=(message:string)=>Object.assign(new Error(message),{status:502,code:'invalid_response'});
|
|
21
|
+
/** Shared event-driven HTTP checks. No recurring file timer or file event connection. */
|
|
14
22
|
export function subscribeWorkspaceFiles(backend:BoardBackend,jobId:string,listener:(event:WorkspaceChangeEvent)=>void,options:WorkspaceWatchOptions={}):()=>void {
|
|
15
|
-
|
|
16
|
-
let
|
|
23
|
+
const releaseOwnedScope=options.paths?.length||options.dirs?.length?retainFileScope(backend,jobId,{paths:[...options.paths??[]],dirs:[...options.dirs??[]]}):()=>{};
|
|
24
|
+
let jobs=registries.get(backend);if(!jobs){jobs=new Map();registries.set(backend,jobs);}
|
|
25
|
+
let entry=jobs.get(jobId);
|
|
17
26
|
if(!entry){
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
+
// Keep the public transport value for compatibility; it denotes bounded HTTP, not a timer.
|
|
28
|
+
const statusBase={type:'files_watch' as const,jobId,mode:'poll' as const,coverage:'listed-files' as const,complete:false};
|
|
29
|
+
entry={listeners:new Set(),stop:()=>{},retry:()=>{},status:{...statusBase,state:'connecting'}};jobs.set(jobId,entry);
|
|
30
|
+
const owned=entry;
|
|
31
|
+
let alive=true,controller:AbortController|null=null,timer:ReturnType<typeof setTimeout>|undefined,failed=0,generation=0,dirty=false;
|
|
32
|
+
let scopeKey=JSON.stringify(readFileScope(backend,jobId));
|
|
33
|
+
const retryDelays=[1000,2000,4000];
|
|
34
|
+
let serverNotBefore=0,retryAt=0,exhaustedUntil=0;
|
|
35
|
+
const requests=metadataQueue(backend);
|
|
36
|
+
let baseline=new Map<string,FileEntry>(),directories=new Map<string,string|null>(),cursors=new Map<string,string>();
|
|
37
|
+
let known=new Set<string>();
|
|
38
|
+
type ScanState={baseline:Map<string,FileEntry>;known:Set<string>};
|
|
39
|
+
const status=(state:WorkspaceWatchStatus['state'],error?:string)=>{owned.status={...statusBase,state,...(error?{error}:{})};for(const item of [...owned.listeners])notifyCallback(item.status,{...owned.status});};
|
|
40
|
+
const publish=(changes:WorkspaceFileChange[],revision?:string)=>{
|
|
27
41
|
if(!alive||!changes.length)return;
|
|
28
|
-
const event:WorkspaceChangeEvent={type:'files_changed',jobId,mode,coverage
|
|
42
|
+
const event:WorkspaceChangeEvent={type:'files_changed',jobId,mode:'poll',coverage:'listed-files',complete:false,changes,revision};
|
|
29
43
|
for(const item of [...owned.listeners])notifyCallback(item.change,cloneWorkspaceChange(event));
|
|
30
44
|
};
|
|
31
|
-
const
|
|
32
|
-
if(!
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if(
|
|
45
|
+
const snapshot=(files:FileEntry[],paths:string[],state:ScanState):WorkspaceFileChange[]=>{
|
|
46
|
+
if(!Array.isArray(files)||!files.every(validFile))throw invalidResponse('文件变化响应不完整,请重试');
|
|
47
|
+
const current=new Map(files.map(file=>[file.path,file])),changes:WorkspaceFileChange[]=[];
|
|
48
|
+
for(const path of paths){
|
|
49
|
+
const file=current.get(path),previous=state.baseline.get(path);
|
|
50
|
+
if(state.known.has(path)){
|
|
51
|
+
if(file&&!previous)changes.push({kind:'created',path,file});
|
|
52
|
+
else if(!file&&previous)changes.push({kind:'deleted',path,previous});
|
|
53
|
+
else if(file&&previous&&fingerprint(file)!==fingerprint(previous))changes.push({kind:'modified',path,file,previous});
|
|
40
54
|
}
|
|
41
|
-
|
|
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);
|
|
55
|
+
state.known.add(path);if(file)state.baseline.set(path,{...file});else state.baseline.delete(path);
|
|
50
56
|
}
|
|
51
|
-
|
|
57
|
+
return changes;
|
|
52
58
|
};
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
if(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
const accept=(batch:WorkspaceFileBatch,paths:string[],state:ScanState)=>{
|
|
60
|
+
if(batch.type==='resync')return [];
|
|
61
|
+
if(batch.type==='snapshot')return snapshot(batch.files,paths,state);
|
|
62
|
+
if(batch.type!=='changes'||!Array.isArray(batch.changes)||batch.changes.some(change=>!change||!paths.includes(change.path)||!['created','modified','deleted'].includes(change.kind)||(change.file&&!validFile(change.file))))throw invalidResponse('文件变化响应不完整,请重试');
|
|
63
|
+
for(const change of batch.changes){state.known.add(change.path);if(change.kind==='deleted')state.baseline.delete(change.path);else if(change.file)state.baseline.set(change.path,{...change.file});}
|
|
64
|
+
return batch.changes;
|
|
59
65
|
};
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
66
|
+
const hidden=()=>typeof document!=='undefined'&&document.visibilityState==='hidden';
|
|
67
|
+
const scan=async()=>{
|
|
68
|
+
if(!alive||controller||hidden())return;
|
|
69
|
+
// A real event retained during cooldown starts one new bounded attempt wave.
|
|
70
|
+
if(dirty&&exhaustedUntil&&Date.now()>=exhaustedUntil){failed=0;exhaustedUntil=0;}
|
|
71
|
+
clearTimer();dirty=false;
|
|
72
|
+
const scope=readFileScope(backend,jobId);
|
|
73
|
+
// No open document/list: no idle root scan and no network subscription.
|
|
74
|
+
if(!scope.paths.length&&!scope.dirs.length){status('ready');return;}
|
|
75
|
+
const ctl=new AbortController();controller=ctl;const token=++generation;
|
|
76
|
+
let retryable=false;
|
|
77
|
+
const current=()=>alive&&!ctl.signal.aborted&&token===generation;
|
|
78
|
+
const state={baseline:new Map(baseline),known:new Set(known)},nextDirectories=new Map(directories),nextCursors=new Map(cursors);
|
|
79
|
+
try{
|
|
80
|
+
const changes:WorkspaceFileChange[]=[];
|
|
81
|
+
if(backend.pollFiles){
|
|
82
|
+
const activeKeys=new Set<string>();
|
|
83
|
+
const count=Math.max(Math.ceil(scope.paths.length/200),Math.ceil(scope.dirs.length/32));
|
|
84
|
+
for(let index=0;index<count;index++){
|
|
85
|
+
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
|
+
const batch=await requests.run(()=>backend.pollFiles!(jobId,{paths,dirs,cursor:nextCursors.get(key)},ctl.signal),ctl.signal);
|
|
87
|
+
if(!current())return;
|
|
88
|
+
if(!batch||typeof batch!=='object')throw invalidResponse('文件变化响应不完整,请重试');
|
|
89
|
+
if(batch.type==='resync'){cursors.delete(key);throw new Error('文件修订已变化,请重新核对');}
|
|
90
|
+
if(batch.cursor)nextCursors.set(key,batch.cursor);
|
|
91
|
+
changes.push(...accept(batch,paths,state));
|
|
92
|
+
if(!Array.isArray(batch.directories))throw invalidResponse('目录变化响应不完整,请重试');
|
|
93
|
+
for(const dir of batch.directories){
|
|
94
|
+
if(!dir||!dirs.includes(dir.path)||(dir.revision!==null&&typeof dir.revision!=='string'))throw invalidResponse('目录变化响应范围错误');
|
|
95
|
+
if(nextDirectories.has(dir.path)&&nextDirectories.get(dir.path)!==dir.revision)changes.push({kind:dir.revision===null?'deleted':'modified',path:dir.path});
|
|
96
|
+
nextDirectories.set(dir.path,dir.revision);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for(const key of nextCursors.keys())if(!activeKeys.has(key))nextCursors.delete(key);
|
|
100
|
+
}else{
|
|
101
|
+
// Compatibility hosts: inspect requested entries, never recurse a workspace.
|
|
102
|
+
const listed=new Map<string,FileEntry>();
|
|
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));
|
|
119
|
+
}
|
|
120
|
+
if(!current())return;
|
|
121
|
+
const previews=backend.fileSync?await refreshFilePreviews(backend.fileSync,jobId,scope.paths,ctl.signal):undefined;
|
|
122
|
+
if(!current())return;
|
|
123
|
+
const changedPaths=new Set(changes.map(change=>change.path));
|
|
124
|
+
for(const path of previews?.changed??[])if(!changedPaths.has(path)){changes.push({kind:'modified',path});changedPaths.add(path);}
|
|
125
|
+
const active=new Set(scope.paths);for(const path of state.known)if(!active.has(path)){state.known.delete(path);state.baseline.delete(path);}
|
|
126
|
+
for(const dir of nextDirectories.keys())if(!scope.dirs.includes(dir))nextDirectories.delete(dir);
|
|
127
|
+
baseline=state.baseline;known=state.known;directories=nextDirectories;cursors=nextCursors;
|
|
128
|
+
publish(changes);
|
|
129
|
+
if(previews?.error)throw previews.error;
|
|
130
|
+
failed=0;serverNotBefore=0;retryAt=0;exhaustedUntil=0;if(current())status('ready');
|
|
131
|
+
}catch(error){if(current()){
|
|
132
|
+
failed++;const code=(error as {status?:number}).status??0;
|
|
133
|
+
const failureCode=(error as {code?:string}).code;
|
|
134
|
+
retryable=!['invalid_manifest','invalid_response','integrity','response_too_large','client_queue_timeout'].includes(failureCode??'')&&(!code||[408,429,500,502,503,504].includes(code));
|
|
135
|
+
if(retryable){
|
|
136
|
+
const hint=Number((error as {retryAfterMs?:number}).retryAfterMs);
|
|
137
|
+
if(Number.isFinite(hint)&&hint>0)serverNotBefore=Math.max(serverNotBefore,Date.now()+hint);
|
|
138
|
+
const base=retryDelays[Math.min(failed-1,retryDelays.length-1)];
|
|
139
|
+
retryAt=Math.max(Date.now()+base,serverNotBefore)+Math.floor(Math.random()*base/2);
|
|
140
|
+
if(failed>retryDelays.length)exhaustedUntil=Math.max(Date.now()+30000,serverNotBefore);
|
|
141
|
+
}
|
|
142
|
+
if([401,403].includes(code))denyFileScope(backend,jobId);
|
|
143
|
+
status('error',error instanceof Error?error.message:String(error));
|
|
144
|
+
}}
|
|
145
|
+
finally{if(controller===ctl){
|
|
146
|
+
controller=null;
|
|
147
|
+
if(alive&&!hidden()){
|
|
148
|
+
if(retryable){
|
|
149
|
+
// A new round during an outage cannot skip backoff or replenish this retry budget.
|
|
150
|
+
if(failed<=retryDelays.length){const pending=dirty;dirty=false;schedule(Math.max(0,retryAt-Date.now()),pending);}
|
|
151
|
+
else if(dirty)schedule(Math.max(0,exhaustedUntil-Date.now(),serverNotBefore-Date.now()),true);
|
|
152
|
+
}else if(dirty)schedule(Math.max(100,serverNotBefore-Date.now()),true);
|
|
75
153
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
154
|
+
}}
|
|
155
|
+
};
|
|
156
|
+
function clearTimer(){if(timer!==undefined)clearTimeout(timer);timer=undefined;}
|
|
157
|
+
function schedule(delay:number,pending=false){
|
|
158
|
+
clearTimer();
|
|
159
|
+
// Long server cooldowns defer a real event once, never extend idle retries.
|
|
160
|
+
// Do not let setTimeout overflow and retry before the server's deadline.
|
|
161
|
+
if(delay>2147483647||(!pending&&delay>180000))return;
|
|
162
|
+
timer=setTimeout(()=>{timer=undefined;void scan();},delay);
|
|
163
|
+
}
|
|
164
|
+
const wake=(manual=false)=>{
|
|
165
|
+
if(!alive)return;
|
|
166
|
+
const now=Date.now();
|
|
167
|
+
if(manual){failed=0;exhaustedUntil=0;clearTimer();}
|
|
168
|
+
else if(exhaustedUntil&&now>=exhaustedUntil){
|
|
169
|
+
// Exhaustion alone never restarts work; a later event can restore its budget.
|
|
170
|
+
failed=0;exhaustedUntil=0;
|
|
171
|
+
}
|
|
172
|
+
dirty=true;
|
|
173
|
+
// Pending events coalesce, and automatic wakes cannot bypass an existing retry delay.
|
|
174
|
+
if(!controller&&!hidden()&&timer===undefined)schedule(Math.max(100,serverNotBefore-now,manual?0:Math.max(retryAt,exhaustedUntil)-now),true);
|
|
175
|
+
};
|
|
176
|
+
const recover=()=>wake();
|
|
177
|
+
const scopeChanged=()=>{
|
|
178
|
+
if(!alive)return;
|
|
179
|
+
const next=JSON.stringify(readFileScope(backend,jobId));if(next===scopeKey)return;scopeKey=next;
|
|
180
|
+
generation++;const old=controller;controller=null;old?.abort();wake(true);
|
|
181
|
+
};
|
|
182
|
+
const visibility=()=>{
|
|
183
|
+
if(hidden()){generation++;const old=controller;controller=null;old?.abort();clearTimer();dirty=true;}
|
|
184
|
+
else recover();
|
|
84
185
|
};
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
if(typeof window!=='undefined'){window.addEventListener('online',
|
|
88
|
-
owned.
|
|
89
|
-
|
|
90
|
-
queueMicrotask(()=>{if(alive)
|
|
186
|
+
const releaseScope=onFileScope(backend,jobId,scopeChanged);
|
|
187
|
+
const releasePreviews=backend.fileSync?onFilePreviewScope(backend.fileSync,jobId,()=>wake()):()=>{};
|
|
188
|
+
if(typeof window!=='undefined'){window.addEventListener('online',recover);document.addEventListener('visibilitychange',visibility);}
|
|
189
|
+
owned.retry=wake;
|
|
190
|
+
owned.stop=()=>{alive=false;clearTimer();controller?.abort();releaseScope();releasePreviews();if(typeof window!=='undefined'){window.removeEventListener('online',recover);document.removeEventListener('visibilitychange',visibility);}};
|
|
191
|
+
queueMicrotask(()=>{if(alive)void scan();});
|
|
91
192
|
}
|
|
92
|
-
const owned=entry;owned.listeners.add(subscription);notifyCallback(subscription.status,{...owned.status});
|
|
93
|
-
let active=true;return()=>{if(!active)return;active=false;owned.listeners.delete(subscription);if(!owned.listeners.size){if(
|
|
193
|
+
const owned=entry,subscription={change:listener,status:options.onStatus};owned.listeners.add(subscription);notifyCallback(subscription.status,{...owned.status});
|
|
194
|
+
let active=true;return()=>{if(!active)return;active=false;owned.listeners.delete(subscription);if(!owned.listeners.size){if(jobs!.get(jobId)===owned)jobs!.delete(jobId);owned.stop();}releaseOwnedScope();};
|
|
94
195
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// 弱网 / 节点忙:每个请求失败了按退避重试几次;某个文件一直读不到(或读的时候被任务删了)就跳过,
|
|
4
4
|
// 照样把其余的打包,失败的清单放进压缩包里的 FAILED_LIST_NAME,并告诉调用方。取消会中止正在传的请求。
|
|
5
5
|
import { CenterError, type BoardBackend } from "../core/client";
|
|
6
|
+
import type { DirListing, FileEntry } from "../core/types";
|
|
6
7
|
import { fmtSize } from "./fileKind";
|
|
7
8
|
import { saveBlob, readBoundedBuffer, FileReadLimitError } from "./fileOps";
|
|
8
9
|
import { buildZip, type ZipEntry } from "./zip";
|
|
@@ -68,17 +69,50 @@ export async function listFolder(backend: BoardBackend, jobId: string, dir: stri
|
|
|
68
69
|
while (queue.length) {
|
|
69
70
|
if (signal?.aborted) throw abortError();
|
|
70
71
|
const cur = queue.shift()!;
|
|
71
|
-
|
|
72
|
+
const entries = new Map<string, FileEntry>();
|
|
73
|
+
const cursors = new Set<string>();
|
|
74
|
+
let cursor: string | undefined, revision: string | undefined;
|
|
75
|
+
let directoryBytes = 0, directoryFiles = 0;
|
|
72
76
|
try {
|
|
73
|
-
|
|
77
|
+
do {
|
|
78
|
+
const res = await withRetry(async () => {
|
|
79
|
+
try { return backend.listFiles ? await backend.listFiles(jobId, cur, { cursor, limit: 200, signal }) : await backend.stat(jobId, cur); }
|
|
80
|
+
catch (error) {
|
|
81
|
+
// New short-HTTP clients preserve status on ordinary Errors too.
|
|
82
|
+
const http = error as { status?: number; code?: string; message?: string };
|
|
83
|
+
if (!(error instanceof CenterError) && typeof http.status === "number") throw new CenterError(http.message ?? "列出目录失败", http.code ?? `HTTP_${http.status}`, http.status);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}, signal, undefined, backoff);
|
|
87
|
+
if (res.type !== "dir") {
|
|
88
|
+
if (cursor) throw new Error("目录在读取时发生变化,请重新下载");
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
const page: DirListing = res;
|
|
92
|
+
if (cursor && (!revision || page.revision !== revision)) throw new Error("目录在读取时发生变化,请重新下载");
|
|
93
|
+
revision ??= page.revision;
|
|
94
|
+
for (const entry of page.entries) {
|
|
95
|
+
if (entries.has(entry.path)) throw new Error("目录分页数据重复,请重新下载");
|
|
96
|
+
entries.set(entry.path, entry);
|
|
97
|
+
if (entry.type === "file") {
|
|
98
|
+
directoryFiles++; directoryBytes += entry.size;
|
|
99
|
+
if (out.length + directoryFiles > maxFiles) throw new Error(`文件超过 ${maxFiles} 个,请进到更小的目录再下载`);
|
|
100
|
+
if (bytes + directoryBytes > maxBytes) throw new Error(`总大小超过 ${fmtSize(maxBytes)},请进到更小的目录或单个下载`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
cursor = page.nextCursor;
|
|
104
|
+
if (page.truncated && (!cursor || !backend.listFiles)) throw new Error(`「${cur === "." ? "工作区" : cur}」里文件太多,节点只列出了一部分;请进到更小的目录再下载`);
|
|
105
|
+
if (cursor && (!revision || cursors.has(cursor))) throw new Error("目录分页游标无效,请重新下载");
|
|
106
|
+
if (cursor) cursors.add(cursor);
|
|
107
|
+
} while (cursor);
|
|
74
108
|
} catch (error) {
|
|
75
109
|
// 子目录在列的时候被任务删了:跳过它;要下载的目录本身没了照常报错。
|
|
76
110
|
if (cur !== (dir || ".") && error instanceof CenterError && error.status === 404) continue;
|
|
111
|
+
if (error instanceof CenterError && error.status === 409) throw new Error("目录在读取时发生变化,请重新下载");
|
|
77
112
|
throw error;
|
|
78
113
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
for (const e of res.entries) {
|
|
114
|
+
// Commit a directory only after every page has the same revision.
|
|
115
|
+
for (const e of entries.values()) {
|
|
82
116
|
if (e.type === "dir") { queue.push(e.path); continue; }
|
|
83
117
|
out.push({ path: e.path, size: e.size, mtime: e.mtime });
|
|
84
118
|
bytes += e.size;
|
package/src/board/frameQuote.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// · 相对路径的样式 / 图片按文件原地址解析(<base>),文档里的 #锚点 在页内滚动(不然会按 <base> 整页跳走)。
|
|
7
7
|
|
|
8
8
|
/** iframe → 宿主:选区(text 为空 = 取消了选择)或滚动了(收起按钮)。 */
|
|
9
|
-
export interface FrameSelMessage { cliUiSel: string; text?: string; rect?: { left: number; top: number; width: number } | null; scroll?: boolean }
|
|
9
|
+
export interface FrameSelMessage { cliUiSel: string; text?: string; rect?: { left: number; top: number; width: number } | null; scroll?: boolean; top?: number; left?: number; ready?: boolean }
|
|
10
10
|
|
|
11
11
|
const randomId = () => Math.random().toString(36).slice(2, 12) + Math.random().toString(36).slice(2, 12);
|
|
12
12
|
|
|
@@ -16,13 +16,15 @@ export const frameToken = randomId;
|
|
|
16
16
|
function bridgeScript(token: string) {
|
|
17
17
|
const t = JSON.stringify(token);
|
|
18
18
|
return `(function(){var t=${t};function post(m){m.cliUiSel=t;try{parent.postMessage(m,"*")}catch(e){}}
|
|
19
|
+
function ready(){requestAnimationFrame(function(){requestAnimationFrame(function(){post({ready:true})})})}if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',ready,{once:true});else ready();
|
|
19
20
|
function send(){var s=getSelection(),text=s&&!s.isCollapsed?String(s).trim():"",rect=null;
|
|
20
21
|
if(text&&s.rangeCount){var b=s.getRangeAt(0).getBoundingClientRect();rect={left:b.left,top:b.top,width:b.width}}post({text:text,rect:rect})}
|
|
21
22
|
var timer;function later(){clearTimeout(timer);timer=setTimeout(send,0)}
|
|
22
23
|
document.addEventListener("mouseup",later);document.addEventListener("keyup",later);
|
|
23
24
|
document.addEventListener("selectionchange",later);
|
|
24
|
-
addEventListener("scroll",function(){post({scroll:true})},true);
|
|
25
|
+
addEventListener("scroll",function(){post({scroll:true,top:scrollY,left:scrollX})},true);
|
|
25
26
|
addEventListener("message",function(e){if(e.source===parent&&e.data&&e.data.cliUiClear===t){var s=getSelection();s&&s.removeAllRanges()}});
|
|
27
|
+
addEventListener("message",function(e){if(e.source===parent&&e.data&&e.data.cliUiRestore===t)scrollTo(Number(e.data.left)||0,Number(e.data.top)||0)});
|
|
26
28
|
document.addEventListener("click",function(e){var a=e.target&&e.target.closest&&e.target.closest('a[href^="#"]');if(!a)return;e.preventDefault();
|
|
27
29
|
var id=decodeURIComponent(a.getAttribute("href").slice(1)),el=id?document.getElementById(id)||document.getElementsByName(id)[0]:document.body;el&&el.scrollIntoView()});})();`;
|
|
28
30
|
}
|
|
@@ -41,10 +43,10 @@ export function bridgeDoc(html: string, opts: { token: string; base?: string })
|
|
|
41
43
|
const head = `<meta http-equiv="Content-Security-Policy" content="${escAttr(csp)}">`
|
|
42
44
|
+ (opts.base ? `<base href="${escAttr(opts.base)}">` : "")
|
|
43
45
|
+ `<script nonce="${opts.token}">${bridgeScript(opts.token)}</script>`;
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const doctype =
|
|
48
|
-
|
|
49
|
-
return head + html;
|
|
46
|
+
// 只保留首部完整、常见的 doctype;其他原件内容必须全部位于 CSP 和桥之后。
|
|
47
|
+
// 不搜索原件中的 <head>:它可能在注释 / 脚本字符串中,未闭合标签也会令全文正则反复回溯。
|
|
48
|
+
// 有界首部匹配同时避开带引号的异常 doctype 把策略吞进声明里的情况。
|
|
49
|
+
const doctype = /^(?:\uFEFF)?[ \t\r\n\f]*(<!doctype[ \t\r\n\f]+html(?:[ \t\r\n\f]+(?:public[ \t\r\n\f]+(?:"[^"<>\0]*"|'[^'<>\0]*')(?:[ \t\r\n\f]+(?:"[^"<>\0]*"|'[^'<>\0]*'))?|system[ \t\r\n\f]+(?:"[^"<>\0]*"|'[^'<>\0]*')))?[ \t\r\n\f]*>)/i.exec(html.slice(0, 4096));
|
|
50
|
+
// 解析器会自动建立 head,再读取原件的 html/head/body,保留文档内容及 html 属性。
|
|
51
|
+
return doctype ? doctype[1] + head + html.slice(doctype[0].length) : "<!doctype html>" + head + html;
|
|
50
52
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Parser } from "htmlparser2";
|
|
2
|
+
|
|
3
|
+
export interface HtmlReadingBlock {
|
|
4
|
+
kind: "heading" | "paragraph" | "list-item" | "code" | "table-row" | "image";
|
|
5
|
+
text: string;
|
|
6
|
+
level?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface HtmlReadingPage { blocks: HtmlReadingBlock[]; textLength: number }
|
|
9
|
+
export interface HtmlReadingResult {
|
|
10
|
+
pages: HtmlReadingPage[];
|
|
11
|
+
pageCount: number;
|
|
12
|
+
pageOffset: number;
|
|
13
|
+
limited: boolean;
|
|
14
|
+
reason?: "depth" | "markup";
|
|
15
|
+
complete: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const BATCH = 8 * 1024, BLOCK_TEXT = 8 * 1024, PAGE_TEXT = 16 * 1024;
|
|
19
|
+
const TOKEN_GAP = 64 * 1024, EMPTY_TAGS = 100_000;
|
|
20
|
+
const ignored = new Set(["head", "script", "style", "iframe", "object", "embed", "svg", "math", "canvas", "template", "video", "audio"]);
|
|
21
|
+
const boundaries = new Set(["p", "div", "section", "article", "main", "header", "footer", "nav", "aside", "blockquote", "figure", "figcaption", "address", "details", "summary", "dl", "dt", "dd", "ul", "ol", "li", "pre", "table", "tr", "hr", "h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
22
|
+
interface Frame { tag: string; skipped: boolean; kind?: HtmlReadingBlock["kind"]; level?: number; count?: number; marker?: string; alt?: string }
|
|
23
|
+
|
|
24
|
+
/** Static reader only: outputs text, never markup, attributes, CSS or resource URLs.
|
|
25
|
+
* Reparse each verified prefix. Only call end() at actual EOF, so partial tags/entities
|
|
26
|
+
* remain pending instead of being mistaken for completed source content.
|
|
27
|
+
*/
|
|
28
|
+
export async function readHtmlSections(text: string, options: { signal?: AbortSignal; eof?: boolean; page?: number } = {}): Promise<HtmlReadingResult> {
|
|
29
|
+
const pages: HtmlReadingPage[] = [], stack: Frame[] = [];
|
|
30
|
+
const target = options.page === undefined ? undefined : Math.max(0, Number.isFinite(options.page) ? Math.floor(options.page) : 0);
|
|
31
|
+
let currentPage: HtmlReadingPage | undefined, selectedPage: HtmlReadingPage | undefined, pageCount = 0;
|
|
32
|
+
let active: HtmlReadingBlock | undefined, reason: HtmlReadingResult["reason"], emptyTags = 0;
|
|
33
|
+
const check = () => { if (options.signal?.aborted) throw new DOMException("已取消", "AbortError"); };
|
|
34
|
+
const emit = () => {
|
|
35
|
+
if (!active) return;
|
|
36
|
+
const block = active; active = undefined;
|
|
37
|
+
if (block.kind !== "code") block.text = block.text.trim();
|
|
38
|
+
if (!block.text.trim()) return;
|
|
39
|
+
if (!currentPage || currentPage.blocks.length >= 64 || currentPage.textLength + block.text.length > PAGE_TEXT) {
|
|
40
|
+
currentPage = { blocks: [], textLength: 0 }; pageCount++;
|
|
41
|
+
if (target === undefined) pages.push(currentPage);
|
|
42
|
+
else if (pageCount - 1 === target) selectedPage = currentPage;
|
|
43
|
+
}
|
|
44
|
+
// With a requested page, all other completed pages are released immediately.
|
|
45
|
+
// Keep the last page too, so a shortened revision can fall back without another scan.
|
|
46
|
+
currentPage.blocks.push(block); currentPage.textLength += block.text.length;
|
|
47
|
+
};
|
|
48
|
+
const context = (): Frame | undefined => {
|
|
49
|
+
// A paragraph inside a cell/code block must not break its enclosing row/code.
|
|
50
|
+
const fixed = stack.find(frame => frame.kind === "code" || frame.kind === "table-row");
|
|
51
|
+
const closest = [...stack].reverse();
|
|
52
|
+
return fixed ?? closest.find(frame => frame.kind && frame.kind !== "paragraph") ?? closest.find(frame => frame.kind);
|
|
53
|
+
};
|
|
54
|
+
const append = (data: string) => {
|
|
55
|
+
if (!data || stack[stack.length - 1]?.skipped || reason) return;
|
|
56
|
+
const frame = context(), kind = frame?.kind ?? "paragraph", level = frame?.level;
|
|
57
|
+
if (kind !== "code") data = data.replace(/[ \t\r\n\f]+/g, " ");
|
|
58
|
+
if (kind !== "code" && !data.trim() && !active) return;
|
|
59
|
+
if (data.trim()) emptyTags = 0;
|
|
60
|
+
if (active && (active.kind !== kind || active.level !== level)) emit();
|
|
61
|
+
if (frame?.marker && data.trim()) { data = frame.marker + data.trimStart(); frame.marker = undefined; }
|
|
62
|
+
let offset = 0;
|
|
63
|
+
while (offset < data.length) {
|
|
64
|
+
active ??= { kind, text: "", ...(level === undefined ? {} : { level }) };
|
|
65
|
+
if (kind !== "code" && data[offset] === " " && (!active.text || active.text.endsWith(" "))) offset++;
|
|
66
|
+
if (offset >= data.length) break;
|
|
67
|
+
let take = Math.min(BLOCK_TEXT - active.text.length, data.length - offset);
|
|
68
|
+
// Do not cut an emoji/supplementary character into two React text blocks.
|
|
69
|
+
const last = data.charCodeAt(offset + take - 1), next = data.charCodeAt(offset + take);
|
|
70
|
+
if (last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) take--;
|
|
71
|
+
if (!take) { emit(); continue; }
|
|
72
|
+
active.text += data.slice(offset, offset + take); offset += take;
|
|
73
|
+
if (active.text.length >= BLOCK_TEXT) emit();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const stop = (why: NonNullable<HtmlReadingResult["reason"]>) => { reason = why; parser.pause(); };
|
|
77
|
+
const parser = new Parser({
|
|
78
|
+
onopentagname(tag) {
|
|
79
|
+
if (reason) return;
|
|
80
|
+
if (stack.length >= 32) { stop("depth"); return; }
|
|
81
|
+
if (++emptyTags > EMPTY_TAGS) { stop("markup"); return; }
|
|
82
|
+
const skipped = !!stack[stack.length - 1]?.skipped || ignored.has(tag);
|
|
83
|
+
const fixed = context();
|
|
84
|
+
if (!skipped && (boundaries.has(tag) || tag === "img") && fixed?.kind !== "code" && fixed?.kind !== "table-row") emit();
|
|
85
|
+
const frame: Frame = { tag, skipped };
|
|
86
|
+
if (tag.length === 2 && tag[0] === "h" && tag[1] >= "1" && tag[1] <= "6") { frame.kind = "heading"; frame.level = Number(tag[1]); }
|
|
87
|
+
else if (tag === "pre") frame.kind = "code";
|
|
88
|
+
else if (tag === "tr") { frame.kind = "table-row"; frame.count = 0; }
|
|
89
|
+
else if (tag === "li") {
|
|
90
|
+
const list = [...stack].reverse().find(parent => parent.tag === "ol" || parent.tag === "ul");
|
|
91
|
+
frame.kind = "list-item";
|
|
92
|
+
frame.marker = list?.tag === "ol" ? `${list.count = (list.count ?? 0) + 1}. ` : "• ";
|
|
93
|
+
}
|
|
94
|
+
else if (boundaries.has(tag)) frame.kind = "paragraph";
|
|
95
|
+
if (!skipped && (tag === "td" || tag === "th")) {
|
|
96
|
+
const row = stack.find(parent => parent.tag === "tr");
|
|
97
|
+
if (row && (row.count = (row.count ?? 0) + 1) > 1) append(" | ");
|
|
98
|
+
}
|
|
99
|
+
stack.push(frame);
|
|
100
|
+
if (!skipped && tag === "br") append(fixed?.kind === "code" ? "\n" : " ");
|
|
101
|
+
},
|
|
102
|
+
// No onopentag callback: htmlparser2 does not build an attributes object.
|
|
103
|
+
// The one permitted attribute is copied as bounded, inert text.
|
|
104
|
+
onattribute(name, value) {
|
|
105
|
+
const frame = stack[stack.length - 1];
|
|
106
|
+
if (!reason && frame?.tag === "img" && !frame.skipped && name === "alt") frame.alt = value.slice(0, BLOCK_TEXT - 6);
|
|
107
|
+
},
|
|
108
|
+
ontext: append,
|
|
109
|
+
onclosetag(tag) {
|
|
110
|
+
if (reason) return;
|
|
111
|
+
const frame = stack[stack.length - 1];
|
|
112
|
+
if (!frame || frame.tag !== tag) return;
|
|
113
|
+
if (!frame.skipped) {
|
|
114
|
+
if (tag === "img") {
|
|
115
|
+
emit(); active = { kind: "image", text: frame.alt?.trim() ? `[图片:${frame.alt.trim()}]` : "[图片]" }; emit();
|
|
116
|
+
}
|
|
117
|
+
const fixed = context();
|
|
118
|
+
if (boundaries.has(tag) && (fixed?.kind !== "code" && fixed?.kind !== "table-row" || fixed === frame)) emit();
|
|
119
|
+
}
|
|
120
|
+
stack.pop();
|
|
121
|
+
},
|
|
122
|
+
}, { decodeEntities: true });
|
|
123
|
+
check();
|
|
124
|
+
let yieldedAt = performance.now();
|
|
125
|
+
try {
|
|
126
|
+
for (let offset = 0; offset < text.length && !reason;) {
|
|
127
|
+
check(); let end = Math.min(text.length, offset + BATCH);
|
|
128
|
+
const last = text.charCodeAt(end - 1), next = text.charCodeAt(end);
|
|
129
|
+
if (last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) end--;
|
|
130
|
+
parser.write(text.slice(offset, end));
|
|
131
|
+
offset = end;
|
|
132
|
+
// Bound parser retention for a single unfinished comment/tag/attribute.
|
|
133
|
+
if (!reason && end - Math.max(parser.startIndex, parser.endIndex) > TOKEN_GAP) stop("markup");
|
|
134
|
+
if (performance.now() - yieldedAt >= 8) {
|
|
135
|
+
await new Promise<void>(resolve => setTimeout(resolve, 0)); check(); yieldedAt = performance.now();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
check();
|
|
139
|
+
if (options.eof && !reason) parser.end();
|
|
140
|
+
emit();
|
|
141
|
+
const selected = selectedPage ?? currentPage;
|
|
142
|
+
return {
|
|
143
|
+
pages: target === undefined ? pages : selected ? [selected] : [], pageCount,
|
|
144
|
+
pageOffset: target === undefined ? 0 : selectedPage ? target : Math.max(0, pageCount - 1),
|
|
145
|
+
limited: !!reason, ...(reason ? { reason } : {}), complete: !!options.eof && !reason,
|
|
146
|
+
};
|
|
147
|
+
} finally { parser.reset(); }
|
|
148
|
+
}
|