@xgspz/cli-ui 0.6.15 → 0.6.16
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 +24 -0
- package/README.md +21 -1
- package/dist/{cli-ui-embed-vIXVNs6k.js → cli-ui-embed-D9xgur2y.js} +18299 -18587
- package/dist/{cli-ui-index-R24x28OX.js → cli-ui-index-WBtxmw23.js} +2 -2
- package/dist/{cli-ui-jszip.min-DUghzByz.js → cli-ui-jszip.min-CU3ZzoXb.js} +1 -1
- package/dist/{cli-ui-jszip.min-DfH6rdQD.js → cli-ui-jszip.min-DdU0X3VJ.js} +2 -2
- package/dist/{cli-ui-xlsx-CP0lJhff.js → cli-ui-xlsx-Dx6T7qhC.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +1 -1
- package/src/board/ChangesPane.tsx +10 -4
- package/src/board/CliBoard.tsx +48 -16
- package/src/board/FileViewer.tsx +34 -1
- package/src/board/diffRefresh.ts +63 -0
- package/src/board/linkage.ts +68 -0
- package/src/board/pathResolve.ts +108 -0
- package/src/chat/CliChat.tsx +76 -181
- package/src/chat/Composer.tsx +4 -3
- package/src/core/conversation.ts +9 -1
- package/src/core/store.ts +112 -38
- package/src/msglist/CliMsgList.tsx +3 -2
- package/src/operator/node/AgentConnect.tsx +19 -15
- package/src/operator/node/AppearancePanel.tsx +1 -1
- package/src/operator/node/BranchNavigator.tsx +1 -1
- package/src/operator/node/BrowserPane.tsx +1 -1
- package/src/operator/node/Chat.tsx +16 -261
- package/src/operator/node/CommandPalette.tsx +1 -1
- package/src/operator/node/Composer.tsx +1 -1
- package/src/operator/node/Layout.tsx +1 -1
- package/src/operator/node/Markdown.tsx +6 -36
- package/src/operator/node/Modal.tsx +1 -1
- package/src/operator/node/OnboardingWizard.tsx +9 -5
- package/src/operator/node/Orchestrator.tsx +1 -1
- package/src/operator/node/ProjectLanding.tsx +1 -1
- package/src/operator/node/ProjectsColumn.tsx +1 -1
- package/src/operator/node/ProviderAuth.tsx +12 -10
- package/src/operator/node/RunSwitch.tsx +1 -1
- package/src/operator/node/Services.tsx +1 -1
- package/src/operator/node/SessionRail.tsx +27 -8
- package/src/operator/node/SessionView.tsx +1 -1
- package/src/operator/node/SettingsView.tsx +1 -1
- package/src/operator/node/TaskBoard.tsx +1 -1
- package/src/operator/node/TaskChanges.tsx +34 -5
- package/src/operator/node/TaskHero.tsx +1 -1
- package/src/operator/node/TasksColumn.tsx +1 -1
- package/src/operator/node/Transcript.tsx +10 -237
- package/src/operator/node/Welcome.tsx +1 -1
- package/src/operator/node/WorktreePrune.tsx +1 -1
- package/src/operator/node/boardInteraction.ts +6 -3
- package/src/operator/node/boardState.ts +12 -13
- package/src/operator/node/fileLinks.ts +4 -38
- package/src/operator/node/format.ts +12 -104
- package/src/operator/node/github.tsx +10 -8
- package/src/operator/node/modals.tsx +15 -13
- package/src/operator/node/recheckOnReturn.ts +28 -0
- package/src/operator/node/shared.tsx +10 -50
- package/src/operator/node/useTaskStream.ts +2 -2
- package/src/styles.css +5 -0
- package/src/ui/Markdown.tsx +36 -21
- package/src/ui/Transcript.tsx +8 -5
- package/src/ui/fileLinks.ts +6 -3
- package/src/ui/format.ts +11 -5
- package/src/ui/shared.tsx +11 -7
- package/src/ui/transcriptModel.ts +40 -9
- package/src/ui/useTranscriptWindow.ts +253 -0
- package/src/operator/node/boundedPoll.ts +0 -47
- package/src/operator/node/icons.tsx +0 -88
- package/src/operator/node/transcriptModel.ts +0 -129
package/dist/cli-ui.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-
|
|
1
|
+
import { C as a, b as i, d as r, e as t, f as o, h as C, i as l, j as n, k as m, m as d, l as u, n as w, o as B, p as f } from "./cli-ui-embed-D9xgur2y.js";
|
|
2
2
|
export {
|
|
3
3
|
a as CliBoard,
|
|
4
4
|
i as CliBrowserView,
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// 改动:移植自 Operator 的 TaskChanges(只读部分:分支、统计、文件列表、逐文件 diff)。
|
|
2
2
|
// 合并 / 解决冲突 / 建 PR 是 Operator 里的操作,经中枢的对话任务不提供。
|
|
3
3
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { useDiffRefreshSignal } from "./diffRefresh";
|
|
4
5
|
import { CenterError, type BoardBackend } from "../core/client";
|
|
5
6
|
import type { DiffFile, DiffResult } from "../core/types";
|
|
6
7
|
import { countChanges, diffLineKinds, hunkLines } from "./diffLines";
|
|
@@ -70,8 +71,13 @@ function transient(error: unknown): boolean {
|
|
|
70
71
|
}
|
|
71
72
|
const clock = (at: number) => new Date(at).toLocaleTimeString("zh-CN", { hour12: false });
|
|
72
73
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
74
|
+
/**
|
|
75
|
+
* 自动重读(见 useDiffRefreshSignal,只看事件,没有定时轮询):
|
|
76
|
+
* settled:轮次收尾计数(store 的 ConvState.settled),变了马上重读;
|
|
77
|
+
* edits:运行中工具执行完的次数(ConvState.edits),refreshKey:文件变化、回到页面等,这两个节流重读(最多每 5 秒一次)。
|
|
78
|
+
*/
|
|
79
|
+
export function ChangesPane({ backend, jobId, refreshKey, settled = 0, edits = 0, onOpen }: { backend: BoardBackend; jobId: string; refreshKey: number; settled?: number; edits?: number; onOpen?: (path: string) => void }) {
|
|
80
|
+
const signal = useDiffRefreshSignal(settled, edits + refreshKey);
|
|
75
81
|
const [data, setData] = useState<DiffResult | null>(null);
|
|
76
82
|
const [error, setError] = useState<string | null>(null);
|
|
77
83
|
const [loading, setLoading] = useState(false);
|
|
@@ -88,7 +94,7 @@ export function ChangesPane({ backend, jobId, refreshKey, settled = 0, onOpen }:
|
|
|
88
94
|
const [retrying, setRetrying] = useState(false);
|
|
89
95
|
const [auto, setAuto] = useState(0);
|
|
90
96
|
const attempts = useRef(0);
|
|
91
|
-
const trigger = `${
|
|
97
|
+
const trigger = `${signal}|${manual}`;
|
|
92
98
|
const lastTrigger = useRef(trigger);
|
|
93
99
|
// 首次读可以和同时打开的其他看板共用请求;之后每次刷新都单独发(fresh),不拿刷新之前就发出的结果。
|
|
94
100
|
const loadedOnce = useRef(false);
|
|
@@ -119,7 +125,7 @@ export function ChangesPane({ backend, jobId, refreshKey, settled = 0, onOpen }:
|
|
|
119
125
|
if (!refresh) { void load(); return () => { alive = false; clearTimeout(retryTimer); }; }
|
|
120
126
|
const timer = setTimeout(load, REFRESH_COALESCE_MS);
|
|
121
127
|
return () => { alive = false; clearTimeout(timer); clearTimeout(retryTimer); };
|
|
122
|
-
}, [backend, jobId,
|
|
128
|
+
}, [backend, jobId, signal, manual, auto]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
123
129
|
|
|
124
130
|
const toggle = useCallback((path: string) => setToggled((s) => { const n = new Set(s); if (n.has(path)) n.delete(path); else n.add(path); return n; }), []);
|
|
125
131
|
const jump = (path: string) => {
|
package/src/board/CliBoard.tsx
CHANGED
|
@@ -9,12 +9,12 @@ import { retainFileScope } from "./fileScope";
|
|
|
9
9
|
//
|
|
10
10
|
// 同步:每轮实时输出结束、恢复页面或手动刷新时检查打开的文件,只读取缺失变化块;没有周期轮询。
|
|
11
11
|
// 和 cli-chat 放在同一个 Provider 里时,对话里点本地文件 / 「查看浏览器」会在这里打开(store.openInBoard)。
|
|
12
|
-
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
12
|
+
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
13
13
|
import { createPortal } from "react-dom";
|
|
14
14
|
import { CliErrorBoundary } from "../ui/ErrorBoundary";
|
|
15
15
|
import type { BoardRef, CliEvent, CliStore, CliStoreOptions, OpenTarget } from "../core/store";
|
|
16
16
|
import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider";
|
|
17
|
-
import { convKey, parseConvKey, isRunning } from "../core/conversation";
|
|
17
|
+
import { convKey, parseConvKey, isRunning, TERMINAL_JOB } from "../core/conversation";
|
|
18
18
|
import type { BoardInfo, FileEntry } from "../core/types";
|
|
19
19
|
import { Icon } from "../ui/icons";
|
|
20
20
|
import { FileViewer, type FileAction } from "./FileViewer";
|
|
@@ -28,6 +28,7 @@ import { useBoardPanels } from "./panels";
|
|
|
28
28
|
import { PanelControls, PanelTab, type InlineBoardLayout } from "./PanelControls";
|
|
29
29
|
import { useBoardVisibility } from "./useBoardVisibility";
|
|
30
30
|
import { useBoardUpload } from "./useBoardUpload";
|
|
31
|
+
import { pathHints, resolveMissingPath } from "./pathResolve";
|
|
31
32
|
|
|
32
33
|
export type BoardTab = "files" | "changes" | "browser";
|
|
33
34
|
|
|
@@ -118,12 +119,13 @@ export interface CliBoardProps extends CliStoreOptions, WorkspaceHandlers {
|
|
|
118
119
|
store?: CliStore;
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
|
|
122
|
+
// memo:嵌在对话里的看板不随对话的每次重画(流式输出时每来一块一次)重画,只在参数变了时才重画。
|
|
123
|
+
export const CliBoard = memo(function CliBoard(props: CliBoardProps) {
|
|
122
124
|
const ctx = useCliStoreContext();
|
|
123
125
|
return <CliErrorBoundary component="cli-board" theme={props.theme}>{withOwnProvider(props, <BoardInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
|
|
124
|
-
}
|
|
126
|
+
});
|
|
125
127
|
|
|
126
|
-
interface OpenFile { name?: string; reloadKey?:number; path: string; size?: number; mtime?: number; version: number }
|
|
128
|
+
interface OpenFile { name?: string; reloadKey?:number; path: string; size?: number; mtime?: number; version: number; /** 由对话里写的这个相对路径补全来的。 */ resolvedFrom?: string }
|
|
127
129
|
|
|
128
130
|
function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs, defaultTab, readOnly, fileActions, acceptOpen = true, quoteToChat, autoQuoteOpenFile, extraTabs, collapsed, onExpand, onNavigate, onEvent, onFilesChange, onFilesWatch, onBoardContextChange, watchFiles = true, fileWatchIntervalMs, theme = "light", className, style, inProvider, inlineLayout, inlineOwner, visible: visibilityOverride, onQuoteToChat }: CliBoardProps & { inProvider: boolean }) {
|
|
129
131
|
useComponentEvents(onEvent, {onFilesChange,onFilesWatch,onBoardContextChange}, inProvider);
|
|
@@ -153,10 +155,12 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
|
|
|
153
155
|
const [tick, setTick] = useState(0);
|
|
154
156
|
|
|
155
157
|
// 换了对话:回到初始状态。
|
|
158
|
+
// 对话里写的相对路径 → 补全后确实存在的路径(这个对话里再点同一个链接直接打开补全后的)。
|
|
159
|
+
const aliases = useRef(new Map<string, string>());
|
|
156
160
|
const lastJob = useRef(jobId);
|
|
157
161
|
if (lastJob.current !== jobId) {
|
|
158
162
|
lastJob.current = jobId;
|
|
159
|
-
setInfo(null); setInfoError(null); setDir(""); setFiles([]); setHistory([]);
|
|
163
|
+
setInfo(null); setInfoError(null); setDir(""); setFiles([]); setHistory([]); aliases.current.clear();
|
|
160
164
|
setTab(defaultTab ?? tabs?.[0] ?? "files");
|
|
161
165
|
}
|
|
162
166
|
// 恢复上次开着的文件页签(存在 sessionStorage):挂上之后、绘制之前做——服务端渲染和首次渲染一致,不会对不上。
|
|
@@ -166,12 +170,18 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
|
|
|
166
170
|
|
|
167
171
|
// 实时流:看板单独用时也要知道任务在不在跑(同一对话只连一次)。
|
|
168
172
|
useEffect(() => (jobId ? store.open(jobId) : undefined), [store, jobId]);
|
|
169
|
-
|
|
170
|
-
const running = conv ? isRunning(conv) : false;
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
:
|
|
173
|
+
// 只订阅看板用得到的几个值(不是整个对话):智能体流式输出时对话每来一块都会变,看板不跟着重画。
|
|
174
|
+
const running = useCliState((s) => { const conv = jobId ? s.convs[jobId] : undefined; return conv ? isRunning(conv) : false; });
|
|
175
|
+
// 看板概况(派发到哪个节点、节点上的会话、有没有文件 / 浏览器)只在这些变了时重查:派发到节点(节点、开始执行过没有)、
|
|
176
|
+
// 任务结束、看的子任务或它在节点上的会话换了(续派、重建)。每一轮的排队 → 执行 → 结束不影响概况,不重查。
|
|
177
|
+
const statusKey = useCliState((s) => {
|
|
178
|
+
const conv = jobId ? s.convs[jobId] : undefined;
|
|
179
|
+
if (!conv) return "";
|
|
180
|
+
const started = conv.turns.some((t) => !!t.started_at || t.status === "running" || t.status === "dispatching") || !!conv.batchTask?.remote_task_id;
|
|
181
|
+
return `${conv.job?.node_id ?? ""}|${started}|${TERMINAL_JOB.has(conv.job?.status ?? "")}|${conv.task?.id ?? ""}:${conv.remoteTaskId ?? conv.task?.remote_task_id ?? ""}`;
|
|
182
|
+
});
|
|
183
|
+
const settled = useCliState((s) => (jobId ? s.convs[jobId]?.settled ?? 0 : 0));
|
|
184
|
+
const edits = useCliState((s) => (jobId ? s.convs[jobId]?.edits ?? 0 : 0));
|
|
175
185
|
const bump = useCallback(() => setTick((n) => n + 1), []);
|
|
176
186
|
const [manual, setManual] = useState(0);
|
|
177
187
|
const refreshAll = useCallback(() => {
|
|
@@ -319,12 +329,14 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
|
|
|
319
329
|
activeRef.current = activeTab;
|
|
320
330
|
/** 打开文件:已经开着就切过去(并重读),否则新开一个页签;超过 MAX_FILE_TABS 关掉最早的(不关正在看的)。 */
|
|
321
331
|
const openPath = useCallback((path: string, entry?: Partial<FileEntry>) => {
|
|
322
|
-
const
|
|
323
|
-
if (!
|
|
332
|
+
const requested = path.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
333
|
+
if (!requested) return;
|
|
334
|
+
const clean = aliases.current.get(requested) ?? requested;
|
|
335
|
+
const resolvedFrom = clean !== requested ? requested : undefined;
|
|
324
336
|
setFiles((list) => {
|
|
325
337
|
const have = list.find((f) => f.path === clean);
|
|
326
338
|
if (have) return list.map((f) => (f.path === clean ? { ...f, name: entry?.name ?? f.name, size: entry?.size ?? f.size, mtime: entry?.mtime ?? f.mtime, version: f.version + 1 } : f));
|
|
327
|
-
const next = [...list, { path: clean, name: entry?.name, size: entry?.size, mtime: entry?.mtime, version: 0 }];
|
|
339
|
+
const next = [...list, { path: clean, name: entry?.name, size: entry?.size, mtime: entry?.mtime, version: 0, ...(resolvedFrom ? { resolvedFrom } : {}) }];
|
|
328
340
|
while (next.length > MAX_FILE_TABS) {
|
|
329
341
|
const i = next.findIndex((f) => fileTabId(f.path) !== activeRef.current && f.path !== clean);
|
|
330
342
|
next.splice(i >= 0 ? i : 0, 1);
|
|
@@ -335,6 +347,25 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
|
|
|
335
347
|
const parent = clean.includes("/") && !clean.startsWith("/") ? clean.split("/").slice(0, -1).join("/") : "";
|
|
336
348
|
setDir(parent);
|
|
337
349
|
}, []);
|
|
350
|
+
/** 打不开的相对路径换成补全后的:原来的页签原地换成新路径(已经开着就切过去),以后再点同一个链接直接打开它。 */
|
|
351
|
+
const replacePath = useCallback((from: string, to: string) => {
|
|
352
|
+
aliases.current.set(from, to);
|
|
353
|
+
setFiles((list) => {
|
|
354
|
+
const old = list.find((f) => f.path === from);
|
|
355
|
+
const resolvedFrom = old?.resolvedFrom ?? from;
|
|
356
|
+
if (list.some((f) => f.path === to)) return list.filter((f) => f.path !== from).map((f) => (f.path === to ? { ...f, resolvedFrom: f.resolvedFrom ?? resolvedFrom } : f));
|
|
357
|
+
return list.map((f) => (f.path === from ? { path: to, version: 0, resolvedFrom } : f));
|
|
358
|
+
});
|
|
359
|
+
setTab(fileTabId(to));
|
|
360
|
+
setDir(to.includes("/") && !to.startsWith("/") ? to.split("/").slice(0, -1).join("/") : "");
|
|
361
|
+
}, []);
|
|
362
|
+
const historyRef = useRef(history); historyRef.current = history;
|
|
363
|
+
const resolveMissing = useCallback((path: string, signal: AbortSignal) => {
|
|
364
|
+
if (!backend || !jobId) return Promise.resolve([]);
|
|
365
|
+
const conv = store.getState().convs[jobId];
|
|
366
|
+
const seen = [...(store.getState().boardContext[jobId]?.history ?? []), ...historyRef.current].map((f) => f.path);
|
|
367
|
+
return resolveMissingPath(backend, jobId, path, pathHints(conv?.msgs ?? [], seen), signal);
|
|
368
|
+
}, [backend, jobId, store]);
|
|
338
369
|
const uploadToWorkspace = useMemo(() => !readOnly && jobId && backend?.uploadFile
|
|
339
370
|
? (file: File, options: import("../core/documentUpload").DocumentUploadOptions) => backend.uploadFile!(jobId, file, options)
|
|
340
371
|
: undefined, [backend, jobId, readOnly]);
|
|
@@ -400,7 +431,7 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
|
|
|
400
431
|
if (!info) return <div className="tc-note">{infoError ? <>读取看板失败:{infoError} <button className="cli-fv-btn" onClick={refreshAll}>重试</button></> : "读取中…"}</div>;
|
|
401
432
|
if (!info.dispatched) return <div className="tc-note">任务还没开始执行({info.detail ?? "等待分配执行节点"})。开始后这里会显示工作区文件。</div>;
|
|
402
433
|
if (!visible) return null;
|
|
403
|
-
if (activeTab === "changes") return <ChangesPane key={jobId} backend={backend} jobId={jobId} refreshKey={tick} settled={
|
|
434
|
+
if (activeTab === "changes") return <ChangesPane key={jobId} backend={backend} jobId={jobId} refreshKey={tick} settled={settled + manual} edits={edits} onOpen={(p) => openPath(p)} />;
|
|
404
435
|
if (activeTab === "browser") {
|
|
405
436
|
if (!visible) return null;
|
|
406
437
|
return <BrowserPane key={`${jobId}:${info.remote_task_id}`} backend={backend} jobId={jobId} readOnly={readOnly}
|
|
@@ -429,6 +460,7 @@ function BoardInner({ jobId: jobIdProp, taskId: taskIdProp, tabs, availableTabs,
|
|
|
429
460
|
onOpenPath={(p) => openPath(p)}
|
|
430
461
|
onQuoteText={canQuote ? (text, context) => quoteText(file.path, text, context) : undefined}
|
|
431
462
|
onBack={() => { setDir(file.path.includes("/") ? file.path.slice(0, file.path.lastIndexOf("/")) : ""); panels.show("files"); setTab("files"); }}
|
|
463
|
+
resolveMissing={resolveMissing} onResolved={(to) => replacePath(file.path, to)} resolvedFrom={file.resolvedFrom}
|
|
432
464
|
/>
|
|
433
465
|
);
|
|
434
466
|
}
|
package/src/board/FileViewer.tsx
CHANGED
|
@@ -149,6 +149,15 @@ export interface FileViewerProps {
|
|
|
149
149
|
toolbarHost?: HTMLElement | null;
|
|
150
150
|
/** 给了就支持「选中文字 → 引用到对话」:在预览里选中一段文字时浮出按钮(Markdown / 代码 / 文本 / 表格 / HTML / Word,及各自的源文件)。 */
|
|
151
151
|
onQuoteText?: (text: string, context?: { revision: string }) => void;
|
|
152
|
+
/**
|
|
153
|
+
* 打不开时(相对路径、后端说文件不存在)按对话里的线索补全:返回确实存在的候选路径(见 board/pathResolve.ts)。
|
|
154
|
+
* 只有一个就调 onResolved 直接换过去;有几个列出来让用户选。不给就只显示读取失败。
|
|
155
|
+
*/
|
|
156
|
+
resolveMissing?: (path: string, signal: AbortSignal) => Promise<string[]>;
|
|
157
|
+
/** 换成补全后的路径打开。 */
|
|
158
|
+
onResolved?: (path: string) => void;
|
|
159
|
+
/** 这个文件是由对话里写的这个路径补全来的(显示一行说明)。 */
|
|
160
|
+
resolvedFrom?: string;
|
|
152
161
|
}
|
|
153
162
|
|
|
154
163
|
/** 选中的文字超过这么多只带开头(引用是给智能体指位置的,不是搬运全文)。 */
|
|
@@ -159,7 +168,7 @@ export function FileViewer(props:FileViewerProps) {
|
|
|
159
168
|
let id=viewerBackendIds.get(props.backend);if(id===undefined){id=nextViewerBackendId++;viewerBackendIds.set(props.backend,id);}
|
|
160
169
|
return <FileViewerInner key={JSON.stringify([id,props.jobId,props.path])} {...props}/>;
|
|
161
170
|
}
|
|
162
|
-
function FileViewerInner({ backend, jobId, path, displayName, size, mtime, version = 0, reloadKey, onOpenPath, onClose, lead, onBack, actions = [], bare, toolbarHost, onQuoteText }: FileViewerProps) {
|
|
171
|
+
function FileViewerInner({ backend, jobId, path, displayName, size, mtime, version = 0, reloadKey, onOpenPath, onClose, lead, onBack, actions = [], bare, toolbarHost, onQuoteText, resolveMissing, onResolved, resolvedFrom }: FileViewerProps) {
|
|
163
172
|
const name = displayName || path.split("/").pop() || path;
|
|
164
173
|
const kind: PreviewKind = previewKind(path);
|
|
165
174
|
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
|
@@ -463,6 +472,24 @@ function FileViewerInner({ backend, jobId, path, displayName, size, mtime, versi
|
|
|
463
472
|
const shownSize = knownSize;
|
|
464
473
|
const text = loaded?.kind === "text" ? loaded : null;
|
|
465
474
|
const loading = !error && !src.error && (needsSrc ? (bridged ? htmlDoc === null : !src.url) : !loaded);
|
|
475
|
+
// 读取失败:相对路径可能是智能体相对它进入的子目录写的,按对话里的线索补全(resolveMissing 由看板 / 对话给)。
|
|
476
|
+
// 只确认存在的才算;找到唯一一个直接换过去,几个就列出来选。
|
|
477
|
+
const failed = !!((error && loaded?.kind !== "text" && !(htmlDoc && typeof htmlDoc === "object")) || src.error);
|
|
478
|
+
const [lookup, setLookup] = useState<{ state: "searching" } | { state: "done"; found: string[] } | null>(null);
|
|
479
|
+
const resolveRef = useRef(resolveMissing); resolveRef.current = resolveMissing;
|
|
480
|
+
const resolvedRef = useRef(onResolved); resolvedRef.current = onResolved;
|
|
481
|
+
useEffect(() => {
|
|
482
|
+
setLookup(null);
|
|
483
|
+
if (!failed || !resolveRef.current) return;
|
|
484
|
+
const ctl = new AbortController();
|
|
485
|
+
setLookup({ state: "searching" });
|
|
486
|
+
resolveRef.current(path, ctl.signal).then((found) => {
|
|
487
|
+
if (ctl.signal.aborted) return;
|
|
488
|
+
if (found.length === 1 && resolvedRef.current) { resolvedRef.current(found[0]); return; }
|
|
489
|
+
setLookup({ state: "done", found });
|
|
490
|
+
}, () => { if (!ctl.signal.aborted) setLookup({ state: "done", found: [] }); });
|
|
491
|
+
return () => ctl.abort();
|
|
492
|
+
}, [failed, path, attempt]);
|
|
466
493
|
const slow = useSlow(loading);
|
|
467
494
|
// 大文件显示读到哪了;很久没读完提示网络慢。
|
|
468
495
|
const progressText = progress > 0 ? `已读取 ${fmtSize(progress)}${shownSize ? ` / ${fmtSize(shownSize)}` : ""}` : "";
|
|
@@ -526,6 +553,7 @@ function FileViewerInner({ backend, jobId, path, displayName, size, mtime, versi
|
|
|
526
553
|
<button className="cli-fv-btn" disabled={busy} onClick={() => void download()}>重试下载</button>
|
|
527
554
|
<button className="cli-fv-btn" onClick={() => setDownloadError(null)} aria-label="关闭下载错误">关闭</button>
|
|
528
555
|
</div>}
|
|
556
|
+
{resolvedFrom && resolvedFrom !== path && <div className="cli-fv-trunc cli-fv-resolved" role="status">对话里写的是 <code>{resolvedFrom}</code>,按对话里提到的目录打开了 <code>{path}</code>。</div>}
|
|
529
557
|
{error && (loaded?.kind === "text" || (htmlDoc && typeof htmlDoc === "object")) && <div className="cli-fv-trunc" role="alert">同步失败:{error}。保留上次完整内容。<button className="cli-fv-btn" onClick={() => setAttempt(value => value + 1)}>重试</button></div>}
|
|
530
558
|
{(syncing || parsing) && <div className="cli-fv-sync" role="status">{!syncing && parsing ? "正在整理正文…" : loaded?.kind === "text" || (htmlDoc && typeof htmlDoc === "object") ? "正在核对更新…" : "正在读取文件…"}<button className="cli-fv-btn" onClick={cancelRead}>暂停加载</button>{kind === "html" && mode === "preview" && !htmlReading && <button className="cli-fv-btn" onClick={() => setHtmlPresentation("reading")}>分段阅读</button>}</div>}
|
|
531
559
|
{cancelled && <div className="cli-fv-trunc" role="status">已暂停加载,已读取的内容仍然保留。<button className="cli-fv-btn" onClick={() => setAttempt(value => value + 1)}>继续加载</button></div>}
|
|
@@ -538,6 +566,11 @@ function FileViewerInner({ backend, jobId, path, displayName, size, mtime, versi
|
|
|
538
566
|
<div className="cli-fv-note err">读取失败:{error ?? src.error}
|
|
539
567
|
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={retry}>重试</button>
|
|
540
568
|
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()} disabled={busy}>{Icon.download()} 下载</button>
|
|
569
|
+
{lookup?.state === "searching" && <div className="cli-fv-resolve" role="status">正在按对话里提到的目录查找这个文件…</div>}
|
|
570
|
+
{lookup?.state === "done" && lookup.found.length > 1 && <div className="cli-fv-resolve" role="group" aria-label="可能的文件">
|
|
571
|
+
对话里写的是相对路径,找到这几个可能的文件:
|
|
572
|
+
{lookup.found.map((candidate) => <button key={candidate} type="button" className="cli-fv-btn cli-fv-candidate" title={candidate} onClick={() => onResolved?.(candidate)}>{candidate}</button>)}
|
|
573
|
+
</div>}
|
|
541
574
|
</div>
|
|
542
575
|
) : kind === "binary" ? (
|
|
543
576
|
<div className="cli-fv-note">这种文件不能在这里预览。
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/** 运行中有工具执行完(可能改了文件)时,改动页签最多每这么久重读一次;最后一次变化之后一定再读一次。 */
|
|
4
|
+
export const DIFF_LIVE_GAP_MS = 5_000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 改动页签(中枢看板 ChangesPane、节点 TaskChanges 共用)什么时候自动重读。只看事件,没有定时轮询:
|
|
8
|
+
* · settled 变了(一轮结束、对话结束、断线重连补齐):马上给信号;
|
|
9
|
+
* · edits 变了(运行中有工具执行完、工作区文件有变化):节流,最多每 DIFF_LIVE_GAP_MS 一次,
|
|
10
|
+
* 最后一次变化之后一定再给一次(只在有新事件时才排一次,没有事件就什么都不做);
|
|
11
|
+
* · 页面在后台时来的信号先记下,回到前台时给一次。
|
|
12
|
+
* 返回一个递增的数字,变了就重读。挂上时不给信号(组件挂上时自己会读一次)。
|
|
13
|
+
*/
|
|
14
|
+
export function useDiffRefreshSignal(settled: number, edits: number): number {
|
|
15
|
+
const [signal, setSignal] = useState(0);
|
|
16
|
+
const seen = useRef({ settled, edits });
|
|
17
|
+
const lastAt = useRef(0);
|
|
18
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
19
|
+
const missed = useRef(false);
|
|
20
|
+
// 同一次提交里 settled 和 edits 一起变了(例如手动刷新同时碰了两个):只读一次。
|
|
21
|
+
const firedThisCommit = useRef(false);
|
|
22
|
+
const fire = useRef(() => {});
|
|
23
|
+
fire.current = () => {
|
|
24
|
+
if (timer.current) { clearTimeout(timer.current); timer.current = null; }
|
|
25
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") { missed.current = true; return; }
|
|
26
|
+
lastAt.current = Date.now();
|
|
27
|
+
setSignal((n) => n + 1);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (seen.current.settled === settled) return;
|
|
32
|
+
seen.current.settled = settled;
|
|
33
|
+
fire.current();
|
|
34
|
+
firedThisCommit.current = true;
|
|
35
|
+
}, [settled]);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (seen.current.edits === edits) return;
|
|
39
|
+
seen.current.edits = edits;
|
|
40
|
+
// 刚因为 settled 读过,或者已经排着一次:那一次读到的会包含这次变化。
|
|
41
|
+
if (firedThisCommit.current || timer.current) return;
|
|
42
|
+
const delay = lastAt.current + DIFF_LIVE_GAP_MS - Date.now();
|
|
43
|
+
// 离上次读已经够久:马上读;否则排到间隔满了再读(只排这一次)。
|
|
44
|
+
if (delay <= 0) { fire.current(); return; }
|
|
45
|
+
timer.current = setTimeout(() => { timer.current = null; fire.current(); }, delay);
|
|
46
|
+
}, [edits]);
|
|
47
|
+
useEffect(() => { firedThisCommit.current = false; });
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const onVisible = () => {
|
|
51
|
+
if (document.visibilityState !== "visible" || !missed.current) return;
|
|
52
|
+
missed.current = false;
|
|
53
|
+
fire.current();
|
|
54
|
+
};
|
|
55
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
56
|
+
return () => {
|
|
57
|
+
document.removeEventListener("visibilitychange", onVisible);
|
|
58
|
+
if (timer.current) { clearTimeout(timer.current); timer.current = null; }
|
|
59
|
+
};
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
return signal;
|
|
63
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// 对话与看板的联动(中枢适配的仓库 store、节点适配的 boardInteraction / boardState 共用)。纯 TS,没有 React。
|
|
2
|
+
//
|
|
3
|
+
// · createBoardSurfaces:「在看板里打开」的接收方登记表——对话里点了文件 / 「查看浏览器」,交给哪个看板;
|
|
4
|
+
// · createBoardOwners:同一个对话(任务)开着几个看板时,各自报告的样子,生效的是哪一个。
|
|
5
|
+
//
|
|
6
|
+
// 每个适配各有一份实例(中枢:每个仓库一份;节点:页面里一份),规则只有这一份。
|
|
7
|
+
import type { BoardView } from "./workspace";
|
|
8
|
+
import { activateBoardOwner, reportBoardOwner, selectedBoardOwner } from "./context";
|
|
9
|
+
|
|
10
|
+
export interface BoardSurfaceEntry<T> {
|
|
11
|
+
/** 接得住(显示的就是这个对话 / 任务)就打开并返回 true。 */
|
|
12
|
+
surface: (target: T) => boolean;
|
|
13
|
+
owner?: string;
|
|
14
|
+
/** 嵌在对话里的看板(只接它自己那个对话组件的请求)。 */
|
|
15
|
+
inline?: boolean;
|
|
16
|
+
/** 越大越先问;缺省 2。 */
|
|
17
|
+
priority?: () => number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createBoardSurfaces<T>() {
|
|
21
|
+
const entries: BoardSurfaceEntry<T>[] = [];
|
|
22
|
+
return {
|
|
23
|
+
/** 登记一个接收方;返回注销函数。后登记的先问(例如弹出的看板盖在常驻的上面)。 */
|
|
24
|
+
register(surface: (target: T) => boolean, options: Omit<BoardSurfaceEntry<T>, "surface"> = {}): () => void {
|
|
25
|
+
const entry = { surface, ...options };
|
|
26
|
+
entries.push(entry);
|
|
27
|
+
return () => { const at = entries.indexOf(entry); if (at >= 0) entries.splice(at, 1); };
|
|
28
|
+
},
|
|
29
|
+
/** 依次问(后登记的先问,再按 priority 从高到低),有一个接住就停;filter 排除不该问的。一个接收方出错不影响别的。 */
|
|
30
|
+
open(target: T, filter?: (entry: BoardSurfaceEntry<T>) => boolean): boolean {
|
|
31
|
+
const available = [...entries].reverse().filter((entry) => !filter || filter(entry))
|
|
32
|
+
.sort((a, b) => (b.priority?.() ?? 2) - (a.priority?.() ?? 2));
|
|
33
|
+
for (const { surface } of available) {
|
|
34
|
+
let handled = false;
|
|
35
|
+
try { handled = surface(target); } catch (error) { console.error("[@xgspz/cli-ui] board surface callback failed", error); }
|
|
36
|
+
if (handled) return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 每个对话(任务)的各个看板(owner)报告的样子;生效的是最近在用、还开着的那个(规则见 board/context)。 */
|
|
44
|
+
export function createBoardOwners() {
|
|
45
|
+
const all = new Map<string, Map<string, BoardView>>();
|
|
46
|
+
const top = (sources: Map<string, BoardView> | undefined) => (sources?.size ? selectedBoardOwner(sources)?.[1] ?? null : null);
|
|
47
|
+
return {
|
|
48
|
+
/** 这个看板上次报告的样子。 */
|
|
49
|
+
previous(key: string, owner: string): BoardView | undefined { return all.get(key)?.get(owner); },
|
|
50
|
+
/** 记下 owner 的报告(null = 这个看板撤了),返回现在生效的样子。 */
|
|
51
|
+
report(key: string, owner: string, view: BoardView | null): BoardView | null {
|
|
52
|
+
const sources = all.get(key) ?? new Map<string, BoardView>();
|
|
53
|
+
if (view) reportBoardOwner(sources, owner, view); else sources.delete(owner);
|
|
54
|
+
if (sources.size) all.set(key, sources); else all.delete(key);
|
|
55
|
+
return top(sources);
|
|
56
|
+
},
|
|
57
|
+
/** 用户在 owner 这个看板上操作了:它变成生效的。没有这个看板时返回 null。 */
|
|
58
|
+
activate(key: string, owner: string): BoardView | null {
|
|
59
|
+
const sources = all.get(key);
|
|
60
|
+
if (!sources) return null;
|
|
61
|
+
return activateBoardOwner(sources, owner)?.[1] ?? null;
|
|
62
|
+
},
|
|
63
|
+
/** 现在生效的样子。 */
|
|
64
|
+
top(key: string): BoardView | null { return top(all.get(key)); },
|
|
65
|
+
/** 这个对话(任务)的全部看板报告(按先后)。 */
|
|
66
|
+
entries(key: string): Array<[string, BoardView]> { return [...all.get(key) ?? []]; },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// 智能体在回复里写的相对路径打不开时(相对它 cd 进去的子目录写的,例如 manual/rules.md,实际在 data/run-1/prepare/manual/rules.md),
|
|
2
|
+
// 按这个对话自己的线索补全:消息里提到过的目录、工具读写过的完整路径、看板打开过的文件。
|
|
3
|
+
// 只用对话里已有的东西,不扫工作区;补出来的地址逐个向后端确认存在才算(中枢和节点适配共用)。
|
|
4
|
+
import type { BoardBackend } from "../core/client";
|
|
5
|
+
import type { Msg } from "../core/types";
|
|
6
|
+
import { localFilePath } from "../ui/fileLinks";
|
|
7
|
+
import { toolDataOf } from "../ui/transcriptModel";
|
|
8
|
+
|
|
9
|
+
/** 最多看最近这么多条消息、留这么多条线索、补出这么多个候选(每个候选要问一次后端)。 */
|
|
10
|
+
const HINT_MESSAGES = 400;
|
|
11
|
+
const HINT_MAX = 600;
|
|
12
|
+
export const CANDIDATE_MAX = 8;
|
|
13
|
+
/** 工具输出很长时只看开头这一段(ls / find 的结果里也有完整路径)。 */
|
|
14
|
+
const TOOL_RESULT_SCAN = 20_000;
|
|
15
|
+
|
|
16
|
+
export interface PathHint {
|
|
17
|
+
/** 规范化后的路径(相对工作目录或绝对路径,不带末尾斜杠)。 */
|
|
18
|
+
path: string;
|
|
19
|
+
/** 写的时候以 / 结尾:是目录。 */
|
|
20
|
+
dir: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 路径样子的一段:至少有一个 /,不含空白、引号、括号和中文标点(路径里的中文本身照常保留)。
|
|
24
|
+
const TOKEN = /[^\s`'"<>()[\]{}|,。;:、!?“”‘’()【】《》]*\/[^\s`'"<>()[\]{}|,。;:、!?“”‘’()【】《》]*/gu;
|
|
25
|
+
const LINK = /\]\(([^)\s]+)\)/g;
|
|
26
|
+
|
|
27
|
+
function hintsFromText(text: string, out: PathHint[]) {
|
|
28
|
+
const raw: string[] = [];
|
|
29
|
+
for (const m of text.matchAll(LINK)) raw.push(m[1]);
|
|
30
|
+
for (const m of text.matchAll(TOKEN)) raw.push(m[0]);
|
|
31
|
+
for (const token of raw) {
|
|
32
|
+
const trimmed = token.replace(/[.,;:!?]+$/, "");
|
|
33
|
+
if (!trimmed || trimmed === "/" || /^\/+$/.test(trimmed)) continue;
|
|
34
|
+
const path = localFilePath(trimmed);
|
|
35
|
+
if (!path || !path.includes("/")) continue;
|
|
36
|
+
out.push({ path, dir: trimmed.endsWith("/") });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 对话里的路径线索,最近的在前(去重)。 */
|
|
41
|
+
export function pathHints(messages: readonly Msg[], extra: readonly string[] = []): PathHint[] {
|
|
42
|
+
const out: PathHint[] = [];
|
|
43
|
+
for (const path of [...extra].reverse()) { const p = localFilePath(path); if (p && p.includes("/")) out.push({ path: p, dir: false }); }
|
|
44
|
+
for (let i = messages.length - 1, seen = 0; i >= 0 && seen < HINT_MESSAGES; i--, seen++) {
|
|
45
|
+
const m = messages[i];
|
|
46
|
+
if (m.role === "tool") {
|
|
47
|
+
const data = toolDataOf(m);
|
|
48
|
+
hintsFromText([data.title, data.detail ?? "", (data.result ?? "").slice(0, TOOL_RESULT_SCAN)].join("\n"), out);
|
|
49
|
+
} else if (m.role !== "queued") hintsFromText(m.content, out);
|
|
50
|
+
if (out.length >= HINT_MAX * 2) break;
|
|
51
|
+
}
|
|
52
|
+
const seenPaths = new Set<string>();
|
|
53
|
+
return out.filter((h) => { const key = `${h.dir ? "d" : "f"}:${h.path}`; if (seenPaths.has(key)) return false; seenPaths.add(key); return true; }).slice(0, HINT_MAX);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const isAbsolute = (p: string) => p.startsWith("/") || p.startsWith("~") || /^[a-z]:\//i.test(p);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 相对路径 requested 可能指的完整路径,最可能的在前,最多 CANDIDATE_MAX 个:
|
|
60
|
+
* · 线索里的文件以 /requested 结尾;
|
|
61
|
+
* · 线索里出现了 requested 的第一段目录:取它前面的部分接上 requested;
|
|
62
|
+
* · 线索是目录:目录接上 requested。
|
|
63
|
+
* 绝对路径不补(照原样就是它)。
|
|
64
|
+
*/
|
|
65
|
+
export function candidatePaths(requested: string, hints: readonly PathHint[]): string[] {
|
|
66
|
+
const want = localFilePath(requested);
|
|
67
|
+
if (!want || isAbsolute(want)) return [];
|
|
68
|
+
const segs = want.split("/");
|
|
69
|
+
const out: string[] = [];
|
|
70
|
+
const add = (p: string) => { if (p !== want && !out.includes(p)) out.push(p); };
|
|
71
|
+
// 线索里直接就有这个文件:最可靠,放最前。
|
|
72
|
+
for (const h of hints) if (!h.dir && h.path.endsWith(`/${want}`)) add(h.path);
|
|
73
|
+
for (const h of hints) {
|
|
74
|
+
if (out.length >= CANDIDATE_MAX) break;
|
|
75
|
+
const parts = h.path.split("/");
|
|
76
|
+
for (let i = parts.length - 1; i > 0; i--) {
|
|
77
|
+
if (parts[i] !== segs[0]) continue;
|
|
78
|
+
add([...parts.slice(0, i), ...segs].join("/"));
|
|
79
|
+
}
|
|
80
|
+
if (h.dir) add(`${h.path}/${want}`);
|
|
81
|
+
}
|
|
82
|
+
return out.slice(0, CANDIDATE_MAX);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 原路径确实不存在(后端回 404)时,按线索补全并逐个确认:返回确实存在的那些(顺序同 candidatePaths)。
|
|
87
|
+
* 原路径存在、或者读失败不是因为不存在(断网、没权限):返回 [],不乱猜。
|
|
88
|
+
*/
|
|
89
|
+
export async function resolveMissingPath(backend: Pick<BoardBackend, "stat">, jobId: string, requested: string, hints: readonly PathHint[], signal?: AbortSignal): Promise<string[]> {
|
|
90
|
+
const candidates = candidatePaths(requested, hints);
|
|
91
|
+
if (!candidates.length) return [];
|
|
92
|
+
try {
|
|
93
|
+
await backend.stat(jobId, requested);
|
|
94
|
+
return [];
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if ((error as { status?: number }).status !== 404) return [];
|
|
97
|
+
}
|
|
98
|
+
const found = new Array<boolean>(candidates.length).fill(false);
|
|
99
|
+
let next = 0;
|
|
100
|
+
const worker = async () => {
|
|
101
|
+
while (next < candidates.length && !signal?.aborted) {
|
|
102
|
+
const at = next++;
|
|
103
|
+
try { const info = await backend.stat(jobId, candidates[at]); found[at] = info.type === "file"; } catch { /* 不存在或读不到:不算 */ }
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
await Promise.all(Array.from({ length: Math.min(4, candidates.length) }, worker));
|
|
107
|
+
return signal?.aborted ? [] : candidates.filter((_, i) => found[i]);
|
|
108
|
+
}
|