@spzxg/cli-ui 0.3.1 → 0.6.0
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/README.md +101 -18
- package/dist/cli-ui-embed-CXdmrwo1.js +40896 -0
- package/dist/cli-ui-index-BZppwn-v.js +13528 -0
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +2 -1
- package/src/board/BrowserPane.tsx +396 -96
- package/src/board/ChangesPane.tsx +13 -4
- package/src/board/CliBoard.tsx +219 -37
- package/src/board/FileViewer.tsx +244 -63
- package/src/board/FilesPane.tsx +51 -4
- package/src/board/blocks.tsx +3 -2
- package/src/board/fileOps.ts +55 -0
- package/src/board/folderDownload.ts +161 -0
- package/src/board/workspace.ts +129 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +126 -29
- package/src/core/blocks.ts +21 -2
- package/src/core/client.ts +270 -41
- package/src/core/config.ts +28 -11
- package/src/core/conversation.ts +35 -12
- package/src/core/provider.tsx +27 -6
- package/src/core/store.ts +354 -69
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +28 -6
- package/src/elements.tsx +113 -9
- package/src/index.ts +7 -2
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +214 -0
- package/src/msglist/CliMsgList.tsx +64 -26
- package/src/styles.css +101 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/TextBlock.tsx +1 -1
- package/src/ui/transcriptModel.ts +66 -9
- package/dist/cli-ui-embed-C0F64Ln7.js +0 -37922
- package/dist/cli-ui-index--RVPCGIc.js +0 -13516
package/src/core/types.ts
CHANGED
|
@@ -224,7 +224,8 @@ export type NodeEvent =
|
|
|
224
224
|
/** center 实时流 chunk.events 里的一项。 */
|
|
225
225
|
export type StreamItem =
|
|
226
226
|
/** remote_task_id:一次性任务的流里才有(大任务里可能同时有几个节点对话)。 */
|
|
227
|
-
|
|
227
|
+
/** older:快照只有最新一段时,更早还有几条(往上翻时经 history 分页取)。旧版中枢没有 = 全量。 */
|
|
228
|
+
| { type: "transcript"; remote_task_id?: string; messages: Message[] | null; pending: PendingMessage[] | null; running: boolean | null; older?: number }
|
|
228
229
|
| { type: "node"; remote_task_id?: string; event: NodeEvent }
|
|
229
230
|
| { type: "turn"; turn: CenterTurn }
|
|
230
231
|
| { type: "job"; job: { id: string; status: JobStatus; closed_reason?: string | null; node_id: string | null } }
|
|
@@ -258,6 +259,13 @@ export interface BatchStreamTask {
|
|
|
258
259
|
last_error: string | null;
|
|
259
260
|
prompt?: string;
|
|
260
261
|
opener_prompt?: string;
|
|
262
|
+
/**
|
|
263
|
+
* 对话式子任务(续派时 mode=conversation):指定 ?task= 看它时,它和续派接着它聊的子任务算一个对话——
|
|
264
|
+
* 这条是其中最新的;conversation_remote_task_id 是它们共用的节点对话,queued 是还没派发的消息。旧版中枢没有。
|
|
265
|
+
*/
|
|
266
|
+
conversation?: boolean;
|
|
267
|
+
conversation_remote_task_id?: string | null;
|
|
268
|
+
queued?: Array<{ id: string; prompt: string; created_at?: string }>;
|
|
261
269
|
}
|
|
262
270
|
|
|
263
271
|
/** 一次性任务(含续派,mode 不是 conversation)的实时流首个事件:没有轮次,只有子任务。 */
|
|
@@ -361,8 +369,8 @@ export interface BoardInfo {
|
|
|
361
369
|
remote_task_id?: string;
|
|
362
370
|
node?: { id: string; health: string };
|
|
363
371
|
files: boolean;
|
|
364
|
-
/** 节点开了浏览器功能。 */
|
|
365
|
-
browser: boolean;
|
|
372
|
+
/** 节点开了浏览器功能。null = 这次没探测出来(节点慢 / 暂时连不上),照常显示浏览器页签。 */
|
|
373
|
+
browser: boolean | null;
|
|
366
374
|
}
|
|
367
375
|
|
|
368
376
|
export interface FileEntry {
|
|
@@ -374,10 +382,14 @@ export interface FileEntry {
|
|
|
374
382
|
mtime: number;
|
|
375
383
|
}
|
|
376
384
|
|
|
377
|
-
export interface DirListing { type: "dir"; path: string; entries: FileEntry[]; truncated: boolean }
|
|
385
|
+
export interface DirListing { type: "dir"; path: string; entries: FileEntry[]; truncated: boolean; /** 目录里的条目总数(新版节点给)。 */ total?: number }
|
|
378
386
|
export interface FileInfo { type: "file"; path: string; name: string; size: number; mtime: number; contentType: string; inline: boolean }
|
|
379
387
|
|
|
380
|
-
export interface DiffFile {
|
|
388
|
+
export interface DiffFile {
|
|
389
|
+
path: string; status: string; additions: number; deletions: number; binary: boolean; patch: string; truncated?: boolean;
|
|
390
|
+
/** 改动太多,这个文件的 diff 没有带上(节点对整个 diff 有总量上限);文件本身照常可以打开。 */
|
|
391
|
+
omitted?: boolean;
|
|
392
|
+
}
|
|
381
393
|
/** Operator 的 /api/tasks/:id/diff(经 center 原样转发)。 */
|
|
382
394
|
export interface DiffResult {
|
|
383
395
|
isolated?: boolean;
|
|
@@ -388,6 +400,8 @@ export interface DiffResult {
|
|
|
388
400
|
isDirty?: boolean;
|
|
389
401
|
ahead?: number;
|
|
390
402
|
alreadyMerged?: boolean;
|
|
403
|
+
/** 因为总量上限没带 diff 的文件数。 */
|
|
404
|
+
patchesOmitted?: number;
|
|
391
405
|
error?: string;
|
|
392
406
|
/** 有的节点只给一整段 unified diff。 */
|
|
393
407
|
diff?: string;
|
|
@@ -413,4 +427,12 @@ export interface BrowserStatus {
|
|
|
413
427
|
export type BrowserMessage =
|
|
414
428
|
| { type: "frame"; data: string; metadata?: { deviceWidth: number; deviceHeight: number } }
|
|
415
429
|
| { type: "page"; url?: string; title?: string }
|
|
416
|
-
| { type: "error"; message?: string }
|
|
430
|
+
| { type: "error"; message?: string }
|
|
431
|
+
/** 按了复制 / 剪切:选中的文字(只发给按键的人),写进本机剪贴板。 */
|
|
432
|
+
| { type: "clipboard"; text: string }
|
|
433
|
+
/** 网页弹出了 alert / confirm / prompt(画面里看不到,单独显示)。 */
|
|
434
|
+
| { type: "dialog"; dialogType: "alert" | "confirm" | "prompt" | "beforeunload"; message: string; defaultPrompt?: string }
|
|
435
|
+
| { type: "dialog_closed" };
|
|
436
|
+
|
|
437
|
+
/** 浏览器实时连接(GET /browser/live)的画面连接状况。 */
|
|
438
|
+
export type BrowserFramesState = { state: "idle" | "connecting" | "live" | "retrying"; detail?: string };
|
package/src/elements.tsx
CHANGED
|
@@ -10,8 +10,12 @@
|
|
|
10
10
|
* <cli-board group="a"></cli-board> (右侧看板:文件 / 改动 / 浏览器;对话里点文件会在这里打开)
|
|
11
11
|
* <cli-file-preview job-id="job_x" path="outputs/报告.md"></cli-file-preview> (单独的文件预览 + 下载)
|
|
12
12
|
* <cli-browser job-id="job_x"></cli-browser> (单独的浏览器实时画面)
|
|
13
|
+
* <cli-split storage-key="a"> <cli-msglist …/> <cli-chat …/> <cli-board …/> </cli-split>
|
|
14
|
+
* (可拖动分栏:子元素每个是一栏,栏之间左右拖动改宽度;panes='[{"size":300},{},{"size":"42%"}]')
|
|
13
15
|
*
|
|
14
|
-
* 同一个 group
|
|
16
|
+
* 同一个 group 的元素共享一个仓库,切换时一起变:列表点哪个,对话和看板就显示哪个;不写 group 各自独立。
|
|
17
|
+
* 要一起切换,group 里的元素就不要写 job-id(写了的只看自己那个);从外面打开某个对话,给任意一个元素写
|
|
18
|
+
* selected-id="job_x" 或 "job_x#task_y"(改它就一起切过去),选中变化在 cli-event 的 select 事件里。
|
|
15
19
|
* 属性:base-url、token、line、namespace、group、job-id、task-id(对话任务里的哪个对话,"new" = 新开一个;对话 / 看板)、
|
|
16
20
|
* title、color、theme、context、read-only、
|
|
17
21
|
* tabs(看板页签,逗号分隔:files,changes,browser)、default-tab、file-open(对话:auto / board / modal / link)、
|
|
@@ -29,9 +33,15 @@ import { CliChat } from "./chat/CliChat.js";
|
|
|
29
33
|
import { CliBoard, type BoardTab } from "./board/CliBoard.js";
|
|
30
34
|
import { CliBrowserView, CliFilePreview } from "./board/blocks.js";
|
|
31
35
|
import { createCliStore, type CliStore } from "./core/store.js";
|
|
36
|
+
import { convKey } from "./core/conversation.js";
|
|
37
|
+
import type { CliWorkspace } from "./board/workspace.js";
|
|
38
|
+
import { attachSplit, type SplitHandle } from "./layout/split.js";
|
|
32
39
|
|
|
33
40
|
const groups = new Map<string, CliStore>();
|
|
34
41
|
|
|
42
|
+
// 服务端渲染(Next.js 等)时没有 HTMLElement:模块照样能导入,defineCliElements() 在服务端什么都不做。
|
|
43
|
+
const Base = (typeof HTMLElement === "undefined" ? class {} : HTMLElement) as typeof HTMLElement;
|
|
44
|
+
|
|
35
45
|
function storeFor(el: HTMLElement): CliStore | undefined {
|
|
36
46
|
const group = el.getAttribute("group");
|
|
37
47
|
if (!group) return undefined;
|
|
@@ -45,7 +55,7 @@ function storeFor(el: HTMLElement): CliStore | undefined {
|
|
|
45
55
|
return store;
|
|
46
56
|
}
|
|
47
57
|
|
|
48
|
-
const ATTRS = ["base-url", "token", "line", "namespace", "group", "job-id", "task-id", "title", "color", "theme", "context", "read-only", "config", "new-conversation", "tabs", "default-tab", "file-open", "path"];
|
|
58
|
+
const ATTRS = ["base-url", "token", "line", "namespace", "group", "selected-id", "job-id", "task-id", "title", "color", "theme", "context", "read-only", "config", "new-conversation", "tabs", "default-tab", "file-open", "path", "no-quote", "hidden"];
|
|
49
59
|
|
|
50
60
|
function jsonAttr(el: HTMLElement, name: string): object | undefined {
|
|
51
61
|
const raw = el.getAttribute(name);
|
|
@@ -63,7 +73,7 @@ function storeOptions(el: HTMLElement) {
|
|
|
63
73
|
};
|
|
64
74
|
}
|
|
65
75
|
|
|
66
|
-
abstract class CliElement extends
|
|
76
|
+
abstract class CliElement extends Base {
|
|
67
77
|
static observedAttributes = ATTRS;
|
|
68
78
|
private root: Root | null = null;
|
|
69
79
|
private ownStore: CliStore | undefined;
|
|
@@ -78,9 +88,40 @@ abstract class CliElement extends HTMLElement {
|
|
|
78
88
|
if (this.root) this.render();
|
|
79
89
|
}
|
|
80
90
|
|
|
91
|
+
/** 这个元素用的仓库(同一个 group 的元素共用一个)。高级用法:store.on(...)、store.select(...)。 */
|
|
92
|
+
get store(): CliStore {
|
|
93
|
+
return storeFor(this) ?? (this.ownStore ??= createCliStore(storeOptions(this)));
|
|
94
|
+
}
|
|
95
|
+
/** 元素自己写的对话(job-id + task-id);没写时 null(跟着这一组的选中走)。 */
|
|
96
|
+
private ownKey(): string | null {
|
|
97
|
+
const job = this.getAttribute("job-id");
|
|
98
|
+
if (!job) return null;
|
|
99
|
+
const task = this.getAttribute("task-id");
|
|
100
|
+
return task ? convKey(job, task) : job;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 工作区文件接口:列文件、读文件、下载、打包、当前打开的文件。不传 jobId = 这个元素看的对话(没写 job-id 时是这一组选中的)。
|
|
104
|
+
* 看板可以隐藏(<cli-board hidden>)或者干脆不放,接口照样能用。
|
|
105
|
+
*/
|
|
106
|
+
workspace(jobId?: string): CliWorkspace {
|
|
107
|
+
return this.store.workspace(jobId ?? this.ownKey() ?? undefined);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 追加项目背景:下一条消息发出时一起带给智能体。opts.jobId 不传 = 这个元素看的对话(还没有对话时,随新对话的第一条消息带上)。
|
|
111
|
+
* 返回这条背景的 id(同一个 id 再追加会替换)。
|
|
112
|
+
*/
|
|
113
|
+
appendContext(text: string, opts: { title?: string; id?: string; jobId?: string } = {}): string {
|
|
114
|
+
const key = opts.jobId ?? this.ownKey() ?? this.store.getState().selected;
|
|
115
|
+
return this.store.appendContext(key, text, { title: opts.title, id: opts.id });
|
|
116
|
+
}
|
|
117
|
+
/** 去掉还没发出的补充背景(不传 id = 全部)。 */
|
|
118
|
+
removeContext(id?: string, jobId?: string) {
|
|
119
|
+
this.store.removeContext(jobId ?? this.ownKey() ?? this.store.getState().selected, id);
|
|
120
|
+
}
|
|
121
|
+
|
|
81
122
|
connectedCallback() {
|
|
82
|
-
// 缺省撑满父容器;组件自己内部滚动(宿主给定高度即可)。
|
|
83
|
-
this.
|
|
123
|
+
// 缺省撑满父容器;组件自己内部滚动(宿主给定高度即可)。hidden:不显示、不连接(接口照样能用)。
|
|
124
|
+
this.syncHidden();
|
|
84
125
|
this.style.height ||= "100%";
|
|
85
126
|
this.style.minHeight ||= "0";
|
|
86
127
|
this.root ??= createRoot(this);
|
|
@@ -90,10 +131,23 @@ abstract class CliElement extends HTMLElement {
|
|
|
90
131
|
// 移动节点时会先断开再连上:推到下一轮再卸载。
|
|
91
132
|
queueMicrotask(() => { if (!this.isConnected) { this.root?.unmount(); this.root = null; } });
|
|
92
133
|
}
|
|
93
|
-
attributeChangedCallback() { if (this.root) this.render(); }
|
|
134
|
+
attributeChangedCallback() { this.syncHidden(); if (this.root) this.render(); }
|
|
135
|
+
private syncHidden() {
|
|
136
|
+
if (this.hasAttribute("hidden")) this.style.display = "none";
|
|
137
|
+
else if (!this.style.display || this.style.display === "none") this.style.display = "block";
|
|
138
|
+
}
|
|
94
139
|
|
|
140
|
+
private appliedSelection: string | null = null;
|
|
141
|
+
/** selected-id:这一组一起看哪个对话。属性变了才切(列表点击换了选中,不会被属性拉回去)。隐藏的元素也生效。 */
|
|
142
|
+
private applySelection() {
|
|
143
|
+
const selection = this.getAttribute("selected-id");
|
|
144
|
+
if (selection !== null && selection !== this.appliedSelection) {
|
|
145
|
+
this.appliedSelection = selection;
|
|
146
|
+
this.store.select(selection || null);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
95
149
|
protected common() {
|
|
96
|
-
const store =
|
|
150
|
+
const store = this.store;
|
|
97
151
|
return {
|
|
98
152
|
store,
|
|
99
153
|
title: this.getAttribute("title") ?? undefined,
|
|
@@ -108,7 +162,11 @@ abstract class CliElement extends HTMLElement {
|
|
|
108
162
|
};
|
|
109
163
|
}
|
|
110
164
|
protected abstract element(): React.ReactNode;
|
|
111
|
-
|
|
165
|
+
// 隐藏的元素什么都不渲染:不轮询、不占连接;workspace() / appendContext() 照样能用。
|
|
166
|
+
private render() {
|
|
167
|
+
this.applySelection();
|
|
168
|
+
this.root?.render(this.hasAttribute("hidden") ? null : this.element());
|
|
169
|
+
}
|
|
112
170
|
}
|
|
113
171
|
|
|
114
172
|
class CliMsgListElement extends CliElement {
|
|
@@ -137,7 +195,9 @@ class CliBoardElement extends CliElement {
|
|
|
137
195
|
// 结果事件只由 <cli-chat> 发(同上);看板只发 cli-event。
|
|
138
196
|
const { onTurnEnd: _a, onConversationEnd: _b, onCallback: _c, title: _t, color: _k, config: _g, ...common } = this.common();
|
|
139
197
|
const taskId = this.getAttribute("task-id");
|
|
140
|
-
return <CliBoard {...common} {...(jobId !== null ? { jobId: jobId || null } : {})} {...(taskId ? { taskId } : {})} tabs={tabs?.length ? tabs : undefined} defaultTab={defaultTab} readOnly={this.hasAttribute("read-only")}
|
|
198
|
+
return <CliBoard {...common} {...(jobId !== null ? { jobId: jobId || null } : {})} {...(taskId ? { taskId } : {})} tabs={tabs?.length ? tabs : undefined} defaultTab={defaultTab} readOnly={this.hasAttribute("read-only")}
|
|
199
|
+
// 同一个 group 里有对话:看板里「引用到对话」带进那个对话的下一条消息;no-quote 关掉。
|
|
200
|
+
quoteToChat={this.hasAttribute("group") && !this.hasAttribute("no-quote")} />;
|
|
141
201
|
}
|
|
142
202
|
}
|
|
143
203
|
|
|
@@ -157,10 +217,54 @@ class CliBrowserElement extends CliElement {
|
|
|
157
217
|
}
|
|
158
218
|
}
|
|
159
219
|
|
|
220
|
+
/** <cli-split>:可拖动分栏(规则见 layout/split.ts)。属性 panes(JSON,每栏 size / min / max)、storage-key(记住宽度)。 */
|
|
221
|
+
class CliSplitElement extends Base {
|
|
222
|
+
static observedAttributes = ["panes", "storage-key"];
|
|
223
|
+
private handle: SplitHandle | null = null;
|
|
224
|
+
private options() {
|
|
225
|
+
let panes: unknown;
|
|
226
|
+
try { panes = JSON.parse(this.getAttribute("panes") ?? "null"); } catch { panes = null; }
|
|
227
|
+
return {
|
|
228
|
+
...(Array.isArray(panes) ? { panes } : {}),
|
|
229
|
+
storageKey: this.getAttribute("storage-key") ?? undefined,
|
|
230
|
+
onResize: (sizes: (number | null)[]) => this.dispatchEvent(new CustomEvent("cli-resize", { detail: { sizes } })),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
connectedCallback() {
|
|
234
|
+
this.style.height ||= "100%";
|
|
235
|
+
this.handle ??= attachSplit(this, this.options());
|
|
236
|
+
}
|
|
237
|
+
disconnectedCallback() {
|
|
238
|
+
queueMicrotask(() => { if (!this.isConnected) { this.handle?.destroy(); this.handle = null; } });
|
|
239
|
+
}
|
|
240
|
+
attributeChangedCallback() { this.handle?.update(this.options()); }
|
|
241
|
+
}
|
|
242
|
+
|
|
160
243
|
export function defineCliElements() {
|
|
244
|
+
if (typeof customElements === "undefined") return;
|
|
161
245
|
if (!customElements.get("cli-msglist")) customElements.define("cli-msglist", CliMsgListElement);
|
|
162
246
|
if (!customElements.get("cli-chat")) customElements.define("cli-chat", CliChatElement);
|
|
163
247
|
if (!customElements.get("cli-board")) customElements.define("cli-board", CliBoardElement);
|
|
164
248
|
if (!customElements.get("cli-file-preview")) customElements.define("cli-file-preview", CliFilePreviewElement);
|
|
165
249
|
if (!customElements.get("cli-browser")) customElements.define("cli-browser", CliBrowserElement);
|
|
250
|
+
if (!customElements.get("cli-split")) customElements.define("cli-split", CliSplitElement);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 元素上给业务方用的方法(<cli-msglist> / <cli-chat> / <cli-board> / <cli-file-preview> / <cli-browser> 都有)。 */
|
|
254
|
+
export interface CliElementApi extends HTMLElement {
|
|
255
|
+
readonly store: CliStore;
|
|
256
|
+
config: object | undefined;
|
|
257
|
+
workspace(jobId?: string): CliWorkspace;
|
|
258
|
+
appendContext(text: string, opts?: { title?: string; id?: string; jobId?: string }): string;
|
|
259
|
+
removeContext(id?: string, jobId?: string): void;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
declare global {
|
|
263
|
+
interface HTMLElementTagNameMap {
|
|
264
|
+
"cli-msglist": CliElementApi;
|
|
265
|
+
"cli-chat": CliElementApi;
|
|
266
|
+
"cli-board": CliElementApi;
|
|
267
|
+
"cli-file-preview": CliElementApi;
|
|
268
|
+
"cli-browser": CliElementApi;
|
|
269
|
+
}
|
|
166
270
|
}
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* cli-chat 对话(CliChat)
|
|
6
6
|
* cli-board 右侧看板:工作区文件(预览 / 下载)、改动、任务浏览器(CliBoard)
|
|
7
7
|
* 其中两个核心也能单独用:CliFilePreview(文件预览 + 下载)、CliBrowserView(浏览器实时画面)
|
|
8
|
+
* CliSplit 可拖动分栏:把上面几个横着排,栏之间左右拖动改宽度(Web Component:<cli-split>)
|
|
8
9
|
*
|
|
9
10
|
* 单独用:
|
|
10
11
|
* <CliChat baseUrl="/api/center" jobId="job_xxx" />
|
|
@@ -20,17 +21,21 @@
|
|
|
20
21
|
*/
|
|
21
22
|
export { CliMsgList, type CliMsgListProps } from "./msglist/CliMsgList.js";
|
|
22
23
|
export { CliChat, type CliChatProps } from "./chat/CliChat.js";
|
|
23
|
-
export { CliBoard, type CliBoardProps, type BoardTab } from "./board/CliBoard.js";
|
|
24
|
+
export { CliBoard, type CliBoardProps, type BoardTab, type BoardExtraTab } from "./board/CliBoard.js";
|
|
25
|
+
export { downloadFolder, zipFolder, listFolder, FOLDER_MAX_FILES, FOLDER_MAX_BYTES, type FolderResult, type FolderZip, type FolderProgress } from "./board/folderDownload.js";
|
|
26
|
+
export { createWorkspace, WORKSPACE_TEXT_MAX, WORKSPACE_LIST_MAX, WORKSPACE_FILE_MAX, type CliWorkspace, type BoardView } from "./board/workspace.js";
|
|
24
27
|
export { CliFilePreview, CliBrowserView, type CliFilePreviewProps, type CliBrowserViewProps } from "./board/blocks.js";
|
|
25
28
|
export { FileViewer, downloadFile, type FileViewerProps, type FileAction } from "./board/FileViewer.js";
|
|
26
29
|
export { BrowserPane } from "./board/BrowserPane.js";
|
|
30
|
+
export { CliSplit, type CliSplitProps } from "./layout/CliSplit.js";
|
|
31
|
+
export { attachSplit, type SplitPane, type SplitOptions, type SplitHandle } from "./layout/split.js";
|
|
27
32
|
export { CliCenterProvider, useCliStore, useCliState, type CliCenterProviderProps, type ResultHandlers } from "./core/provider.js";
|
|
28
33
|
|
|
29
34
|
export {
|
|
30
35
|
resolveChatConfig, resolveMsgListConfig, DEFAULT_CHAT_CONFIG, DEFAULT_MSGLIST_CONFIG,
|
|
31
36
|
type CliChatConfig, type CliMsgListConfig, type ResolvedChatConfig, type ResolvedMsgListConfig,
|
|
32
37
|
} from "./core/config.js";
|
|
33
|
-
export { createCliStore, type CliStore, type CliStoreOptions, type CliState, type CliEvent, type JobEntry, type OpenTarget } from "./core/store.js";
|
|
38
|
+
export { createCliStore, CONTEXT_ITEM_MAX, type CliStore, type CliStoreOptions, type CliState, type CliEvent, type JobEntry, type OpenTarget, type BoardRef, type ContextItem } from "./core/store.js";
|
|
34
39
|
export { createCenterClient, CenterError, boardFileUrl, type CenterClient, type CenterClientOptions, type StreamHandlers, type BoardBackend, type ConversationBackend } from "./core/client.js";
|
|
35
40
|
export { viewMessages, isRunning, runningTurn, convKey, parseConvKey, NEW_TASK, type ConvState } from "./core/conversation.js";
|
|
36
41
|
export {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 可拖动分栏(React 版)。每个子元素是一栏,栏之间可以左右拖动改宽度(规则见 split.ts)。
|
|
3
|
+
*
|
|
4
|
+
* <CliCenterProvider baseUrl="/api/center" namespace="strategy">
|
|
5
|
+
* <CliSplit storageKey="strategy" panes={[{ size: 300, min: 220, max: 480 }, { min: 360 }, { size: "42%", min: 320 }]}>
|
|
6
|
+
* <CliMsgList />
|
|
7
|
+
* <CliChat />
|
|
8
|
+
* <CliBoard />
|
|
9
|
+
* </CliSplit>
|
|
10
|
+
* </CliCenterProvider>
|
|
11
|
+
*
|
|
12
|
+
* 三个组件在同一个 Provider 里照常联动(列表选中 → 对话和看板跟着切;对话里点文件 → 看板打开),分栏只管宽度。
|
|
13
|
+
*/
|
|
14
|
+
import { Children, useEffect, useRef, type CSSProperties, type ReactNode } from "react";
|
|
15
|
+
import { attachSplit, type SplitHandle, type SplitPane } from "./split.js";
|
|
16
|
+
|
|
17
|
+
export type CliSplitProps = {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
/** 每栏的初始宽度 / 最小 / 最大。写了 size 的是定宽栏,没写的占剩余宽度。缺省:第一栏 300px、最后一栏 42%、中间占剩余。 */
|
|
20
|
+
panes?: SplitPane[];
|
|
21
|
+
/** 记住拖好的宽度(localStorage)。 */
|
|
22
|
+
storageKey?: string;
|
|
23
|
+
onResize?: (sizes: (number | null)[]) => void;
|
|
24
|
+
className?: string;
|
|
25
|
+
style?: CSSProperties;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const defaultPanes = (n: number): SplitPane[] =>
|
|
29
|
+
n <= 1 ? [{}] : n === 2 ? [{ size: 300, min: 220, max: 520 }, { min: 360 }]
|
|
30
|
+
: Array.from({ length: n }, (_, i) => (i === 0 ? { size: 300, min: 220, max: 520 } : i === n - 1 ? { size: "42%", min: 320 } : { min: 360 }));
|
|
31
|
+
|
|
32
|
+
export function CliSplit({ children, panes, storageKey, onResize, className, style }: CliSplitProps) {
|
|
33
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
34
|
+
const handle = useRef<SplitHandle | null>(null);
|
|
35
|
+
const items = Children.toArray(children).filter(Boolean);
|
|
36
|
+
const onResizeRef = useRef(onResize);
|
|
37
|
+
onResizeRef.current = onResize;
|
|
38
|
+
const options = { panes: panes ?? defaultPanes(items.length), storageKey, onResize: (s: (number | null)[]) => onResizeRef.current?.(s) };
|
|
39
|
+
const sig = JSON.stringify([options.panes, storageKey, items.length]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!ref.current) return;
|
|
43
|
+
handle.current = attachSplit(ref.current, options);
|
|
44
|
+
return () => { handle.current?.destroy(); handle.current = null; };
|
|
45
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
46
|
+
useEffect(() => { handle.current?.update(options); }, [sig]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div ref={ref} className={`cli-split ${className ?? ""}`} style={style}>
|
|
50
|
+
{items.map((child, i) => <div key={(child as { key?: string | null }).key ?? i} className="cli-split-pane">{child}</div>)}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 可拖动分栏:把一个容器里横向排的几栏(列表 / 对话 / 看板,或宿主自己的栏)之间加上拖动条,左右拖改宽度。
|
|
3
|
+
* 不依赖 React:React 版 CliSplit 和 Web Component 版 <cli-split> 都用它。
|
|
4
|
+
*
|
|
5
|
+
* attachSplit(container, { panes: [{ size: 300, min: 220, max: 480 }, { min: 360 }, { size: "42%", min: 320 }], storageKey: "strategy" })
|
|
6
|
+
*
|
|
7
|
+
* 规则:
|
|
8
|
+
* · 写了 size 的栏是定宽(px 或百分比,百分比按容器宽度换算成 px);没写 size 的栏占剩下的宽度(只能有一个,没有就是最后一栏)。
|
|
9
|
+
* · 拖动条改它旁边定宽的那一栏;任何一栏都不会小于自己的 min,占剩余宽度的那栏也一样。
|
|
10
|
+
* · 双击拖动条:那一栏回到初始宽度。键盘:拖动条能聚焦,←/→ 每次 16px(按住 Shift 64px)。
|
|
11
|
+
* · 子元素根节点带 collapsed 类(cli-board 收起时):那一栏变成 40px 窄条(--cli-split-collapsed),旁边的拖动条隐藏;展开后回到原来的宽度。
|
|
12
|
+
* · 写了 storageKey:拖好的宽度记在 localStorage(cli-ui:split:<storageKey>),下次打开还是这个宽度。
|
|
13
|
+
* · 窗口变窄时定宽栏按比例让出宽度,保证占剩余宽度的那栏不小于它的 min。
|
|
14
|
+
*/
|
|
15
|
+
export type SplitPane = { size?: number | `${number}%`; min?: number; max?: number };
|
|
16
|
+
export type SplitOptions = {
|
|
17
|
+
panes?: SplitPane[];
|
|
18
|
+
storageKey?: string;
|
|
19
|
+
/** 拖完(松手 / 键盘 / 双击)后的各栏宽度,占剩余宽度的那栏是 null。 */
|
|
20
|
+
onResize?: (sizes: (number | null)[]) => void;
|
|
21
|
+
};
|
|
22
|
+
export type SplitHandle = { update: (options: SplitOptions) => void; reset: () => void; destroy: () => void };
|
|
23
|
+
|
|
24
|
+
const DEFAULT_MIN = 160;
|
|
25
|
+
const STEP = 16;
|
|
26
|
+
|
|
27
|
+
export function attachSplit(container: HTMLElement, initial: SplitOptions = {}): SplitHandle {
|
|
28
|
+
let options = initial;
|
|
29
|
+
let sizes: (number | null)[] = [];
|
|
30
|
+
let gutters: HTMLElement[] = [];
|
|
31
|
+
let panes: HTMLElement[] = [];
|
|
32
|
+
let dragging = false;
|
|
33
|
+
|
|
34
|
+
container.classList.add("cli-split");
|
|
35
|
+
|
|
36
|
+
const key = () => (options.storageKey ? `cli-ui:split:${options.storageKey}` : null);
|
|
37
|
+
const load = (): (number | null)[] | null => {
|
|
38
|
+
const k = key();
|
|
39
|
+
if (!k) return null;
|
|
40
|
+
try { const v = JSON.parse(localStorage.getItem(k) ?? "null"); return Array.isArray(v) ? v : null; } catch { return null; }
|
|
41
|
+
};
|
|
42
|
+
const save = () => {
|
|
43
|
+
const k = key();
|
|
44
|
+
if (k) try { localStorage.setItem(k, JSON.stringify(sizes)); } catch { /* 存不下就不记 */ }
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const spec = (i: number): SplitPane => options.panes?.[i] ?? {};
|
|
48
|
+
const flexIndex = () => {
|
|
49
|
+
const i = panes.findIndex((_, n) => spec(n).size === undefined);
|
|
50
|
+
return i >= 0 ? i : panes.length - 1;
|
|
51
|
+
};
|
|
52
|
+
const minOf = (i: number) => spec(i).min ?? DEFAULT_MIN;
|
|
53
|
+
const initialSize = (i: number): number | null => {
|
|
54
|
+
if (i === flexIndex()) return null;
|
|
55
|
+
const s = spec(i).size;
|
|
56
|
+
if (typeof s === "string") return Math.round((parseFloat(s) / 100) * (container.clientWidth || 1200));
|
|
57
|
+
return s ?? 320;
|
|
58
|
+
};
|
|
59
|
+
const isCollapsed = (pane: HTMLElement) => !!pane.firstElementChild?.classList.contains("collapsed");
|
|
60
|
+
const clamp = (i: number, w: number) => Math.round(Math.min(spec(i).max ?? Infinity, Math.max(minOf(i), w)));
|
|
61
|
+
|
|
62
|
+
/** 定宽栏放进容器:总宽超了就按比例收,给占剩余宽度的那栏留出它的 min。 */
|
|
63
|
+
function fitted(): (number | null)[] {
|
|
64
|
+
const total = container.clientWidth;
|
|
65
|
+
const flex = flexIndex();
|
|
66
|
+
const fixed = sizes.map((s, i) => (i === flex || isCollapsed(panes[i]) ? 0 : s ?? 0));
|
|
67
|
+
const collapsedW = panes.reduce((sum, p, i) => sum + (i !== flex && isCollapsed(p) ? p.getBoundingClientRect().width : 0), 0);
|
|
68
|
+
const room = total - minOf(flex) - collapsedW;
|
|
69
|
+
const sum = fixed.reduce((a, b) => a + b, 0);
|
|
70
|
+
if (!total || sum <= room) return sizes;
|
|
71
|
+
const ratio = Math.max(0, room) / sum;
|
|
72
|
+
return sizes.map((s, i) => (i === flex || s === null ? null : Math.max(minOf(i), Math.floor(s * ratio))));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function apply() {
|
|
76
|
+
const flex = flexIndex();
|
|
77
|
+
const shown = fitted();
|
|
78
|
+
panes.forEach((pane, i) => {
|
|
79
|
+
pane.classList.add("cli-split-pane");
|
|
80
|
+
if (i === flex) { pane.style.flex = "1 1 0"; pane.style.width = ""; pane.style.minWidth = `${minOf(i)}px`; return; }
|
|
81
|
+
// 收起的栏(cli-board 的窄条宽度是 100%,没有自身宽度):固定窄条宽,宿主可用 --cli-split-collapsed 改。
|
|
82
|
+
if (isCollapsed(pane)) { pane.style.flex = "0 0 var(--cli-split-collapsed, 40px)"; pane.style.width = ""; pane.style.minWidth = ""; return; }
|
|
83
|
+
pane.style.flex = `0 0 ${shown[i]}px`;
|
|
84
|
+
pane.style.width = `${shown[i]}px`;
|
|
85
|
+
pane.style.minWidth = "0";
|
|
86
|
+
});
|
|
87
|
+
gutters.forEach((g, i) => {
|
|
88
|
+
const hidden = isCollapsed(panes[i]) || isCollapsed(panes[i + 1]);
|
|
89
|
+
g.hidden = hidden;
|
|
90
|
+
g.setAttribute("aria-valuenow", String(Math.round(panes[sideOf(i)]?.getBoundingClientRect().width ?? 0)));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 拖动条 i(在第 i 栏和第 i+1 栏之间)改的是哪一栏:左边定宽改左边,否则改右边。 */
|
|
95
|
+
const sideOf = (gutter: number) => (gutter === flexIndex() ? gutter + 1 : gutter);
|
|
96
|
+
|
|
97
|
+
function resize(gutter: number, dx: number) {
|
|
98
|
+
const target = sideOf(gutter);
|
|
99
|
+
const sign = target === gutter ? 1 : -1;
|
|
100
|
+
const flex = flexIndex();
|
|
101
|
+
const current = panes[target].getBoundingClientRect().width;
|
|
102
|
+
// 占剩余宽度的那栏不能被挤到 min 以下。
|
|
103
|
+
const flexW = panes[flex].getBoundingClientRect().width;
|
|
104
|
+
const grow = Math.min(sign * dx, flexW - minOf(flex));
|
|
105
|
+
sizes[target] = clamp(target, current + grow);
|
|
106
|
+
apply();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function finish() { save(); options.onResize?.([...sizes]); }
|
|
110
|
+
|
|
111
|
+
function makeGutter(index: number) {
|
|
112
|
+
const g = document.createElement("div");
|
|
113
|
+
g.className = "cli-split-gutter";
|
|
114
|
+
g.tabIndex = 0;
|
|
115
|
+
g.setAttribute("role", "separator");
|
|
116
|
+
g.setAttribute("aria-orientation", "vertical");
|
|
117
|
+
g.setAttribute("aria-label", "拖动调整宽度");
|
|
118
|
+
g.title = "拖动调整宽度,双击恢复";
|
|
119
|
+
g.addEventListener("pointerdown", (e) => {
|
|
120
|
+
if (e.button !== 0) return;
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
g.setPointerCapture(e.pointerId);
|
|
123
|
+
dragging = true;
|
|
124
|
+
container.classList.add("cli-split-dragging");
|
|
125
|
+
let last = e.clientX;
|
|
126
|
+
const move = (ev: PointerEvent) => { const dx = ev.clientX - last; if (!dx) return; last = ev.clientX; resize(index, dx); };
|
|
127
|
+
const up = () => {
|
|
128
|
+
dragging = false;
|
|
129
|
+
container.classList.remove("cli-split-dragging");
|
|
130
|
+
g.removeEventListener("pointermove", move);
|
|
131
|
+
g.removeEventListener("pointerup", up);
|
|
132
|
+
g.removeEventListener("pointercancel", up);
|
|
133
|
+
finish();
|
|
134
|
+
};
|
|
135
|
+
g.addEventListener("pointermove", move);
|
|
136
|
+
g.addEventListener("pointerup", up);
|
|
137
|
+
g.addEventListener("pointercancel", up);
|
|
138
|
+
});
|
|
139
|
+
g.addEventListener("dblclick", () => {
|
|
140
|
+
const target = sideOf(index);
|
|
141
|
+
sizes[target] = initialSize(target);
|
|
142
|
+
apply();
|
|
143
|
+
finish();
|
|
144
|
+
});
|
|
145
|
+
g.addEventListener("keydown", (e) => {
|
|
146
|
+
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
resize(index, (e.key === "ArrowLeft" ? -1 : 1) * (e.shiftKey ? STEP * 4 : STEP));
|
|
149
|
+
finish();
|
|
150
|
+
});
|
|
151
|
+
return g;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 子元素变了(宿主增删栏):重新找栏、重新插拖动条。 */
|
|
155
|
+
function rebuild() {
|
|
156
|
+
for (const g of gutters) g.remove();
|
|
157
|
+
panes = [...container.children].filter((c): c is HTMLElement => c instanceof HTMLElement && !c.classList.contains("cli-split-gutter"));
|
|
158
|
+
const saved = load();
|
|
159
|
+
const flex = flexIndex();
|
|
160
|
+
sizes = panes.map((_, i) => {
|
|
161
|
+
if (i === flex) return null;
|
|
162
|
+
const keep = sizes[i];
|
|
163
|
+
if (typeof keep === "number") return keep;
|
|
164
|
+
const s = saved && saved.length === panes.length ? saved[i] : null;
|
|
165
|
+
return typeof s === "number" ? clamp(i, s) : initialSize(i);
|
|
166
|
+
});
|
|
167
|
+
gutters = panes.slice(0, -1).map((_, i) => makeGutter(i));
|
|
168
|
+
gutters.forEach((g, i) => panes[i].after(g));
|
|
169
|
+
apply();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 只看栏的增删、栏的根节点换了、根节点的 class 变了(收起 / 展开);栏里面的内容(流式输出)不看,不然每个字都要重排。
|
|
173
|
+
const observer = new MutationObserver((records) => {
|
|
174
|
+
if (dragging) return;
|
|
175
|
+
const structural = records.some((r) => r.type === "childList" && r.target === container
|
|
176
|
+
&& [...r.addedNodes, ...r.removedNodes].some((n) => !(n instanceof HTMLElement && n.classList.contains("cli-split-gutter"))));
|
|
177
|
+
observer.disconnect();
|
|
178
|
+
if (structural) rebuild(); else apply();
|
|
179
|
+
observe();
|
|
180
|
+
});
|
|
181
|
+
const observe = () => {
|
|
182
|
+
observer.observe(container, { childList: true });
|
|
183
|
+
for (const p of panes) {
|
|
184
|
+
observer.observe(p, { childList: true });
|
|
185
|
+
if (p.firstElementChild) observer.observe(p.firstElementChild, { attributes: true, attributeFilter: ["class"] });
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => { if (!dragging) apply(); }) : null;
|
|
189
|
+
|
|
190
|
+
rebuild();
|
|
191
|
+
observe();
|
|
192
|
+
ro?.observe(container);
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
update(next) {
|
|
196
|
+
const keyChanged = next.storageKey !== options.storageKey;
|
|
197
|
+
options = next;
|
|
198
|
+
if (keyChanged) sizes = [];
|
|
199
|
+
observer.disconnect(); rebuild(); observe();
|
|
200
|
+
},
|
|
201
|
+
reset() {
|
|
202
|
+
sizes = panes.map((_, i) => initialSize(i));
|
|
203
|
+
apply();
|
|
204
|
+
finish();
|
|
205
|
+
},
|
|
206
|
+
destroy() {
|
|
207
|
+
observer.disconnect();
|
|
208
|
+
ro?.disconnect();
|
|
209
|
+
for (const g of gutters) g.remove();
|
|
210
|
+
for (const p of panes) { p.style.flex = ""; p.style.width = ""; p.style.minWidth = ""; p.classList.remove("cli-split-pane"); }
|
|
211
|
+
container.classList.remove("cli-split", "cli-split-dragging");
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|