@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
|
@@ -5,7 +5,6 @@ import type { Status, Priority, AskQuestion, AskAnswers } from "./protocol/types
|
|
|
5
5
|
import { OperatorBoardContext, OperatorBoardQuoteContext, type OperatorBoardOptions, type OperatorBoardRequest, type OperatorBoardTarget } from "./boardInteraction";
|
|
6
6
|
import { readDraft, writeDraft, type Quote } from "./composerDraft";
|
|
7
7
|
import { clientFeatures } from "./protocol/features";
|
|
8
|
-
import { toolDataOf } from "./transcriptModel";
|
|
9
8
|
import { Icon } from "./icons";
|
|
10
9
|
import { type ResolveResult } from "./TaskChanges";
|
|
11
10
|
import { fmtTokens, fmtCost, fmtJobCost, isAwaiting, buildSessions, usageSplit, costDisplay } from "./format";
|
|
@@ -27,7 +26,9 @@ import { registerOperatorBoard } from "./boardInteraction";
|
|
|
27
26
|
import { useConfigurationBusy } from "../../chat/configuration";
|
|
28
27
|
import type { SessionRail as NodeSessionRail } from "./SessionRail";
|
|
29
28
|
const SessionRail=(props:ComponentProps<typeof NodeSessionRail>)=><CliBoard operator={props}/>;
|
|
30
|
-
import { ColResize
|
|
29
|
+
import { ColResize } from "./Layout";
|
|
30
|
+
import { WorkspaceTabs } from "../../ui/WorkspaceTabs";
|
|
31
|
+
import { WorkspaceDrawer, useWorkspaceDrawer } from "../../ui/WorkspaceDrawer";
|
|
31
32
|
import { jget, downloadExport } from "./api";
|
|
32
33
|
|
|
33
34
|
// Non-blocking banner shown when a reopened task's worktree is behind its base
|
|
@@ -134,21 +135,24 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
|
|
|
134
135
|
return () => observer.disconnect();
|
|
135
136
|
}, [railW]);
|
|
136
137
|
const singlePane = mobile || compact;
|
|
138
|
+
const drawerMode = useWorkspaceDrawer(sessionRef, !!mobile);
|
|
139
|
+
const tabMode = !drawerMode && (singlePane || railCollapsed);
|
|
140
|
+
const [tabsHost, setTabsHost] = useState<HTMLDivElement|null>(null);
|
|
137
141
|
const [statusOpen, setStatusOpen] = useState(false);
|
|
138
142
|
const [moreOpen, setMoreOpen] = useState(false);
|
|
139
143
|
const [view, setView] = useState<"chat" | "changes">("chat");
|
|
140
144
|
const [boardRequest,setBoardRequest]=useState<OperatorBoardRequest|null>(null);
|
|
141
145
|
const boardSequence=useRef(0);
|
|
142
146
|
const acknowledgeBoardRequest=useCallback((sequence:number)=>setBoardRequest(previous=>previous?.sequence===sequence?null:previous),[]);
|
|
143
|
-
const routing=useRef({
|
|
144
|
-
routing.current={
|
|
147
|
+
const routing=useRef({tabMode,drawerMode,task,tasks,board});
|
|
148
|
+
routing.current={tabMode,drawerMode,task,tasks,board};
|
|
145
149
|
const openBoard=useCallback((target:OperatorBoardTarget)=>{
|
|
146
150
|
const context=routing.current;
|
|
147
151
|
if(target.kind==='browser'&&(target.taskId!==context.task.id||!clientFeatures().browser))return false;
|
|
148
152
|
if(target.kind==='file'&&(!target.path||![context.task,...context.tasks].some(t=>t.id===target.taskId&&t.project_id===context.task.project_id)))return false;
|
|
149
153
|
if(context.board?.availableTabs&&!context.board.availableTabs.includes(target.kind==='file'?'files':'browser'))return false;
|
|
150
154
|
setBoardRequest({sequence:++boardSequence.current,target});
|
|
151
|
-
if(context.
|
|
155
|
+
if(context.tabMode||context.drawerMode)setView('changes');
|
|
152
156
|
return true;
|
|
153
157
|
},[]);
|
|
154
158
|
useEffect(()=>registerOperatorBoard(target=>target.taskId===task.id?openBoard(target):false),[task.id,openBoard]);
|
|
@@ -159,10 +163,9 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
|
|
|
159
163
|
const quoteFromBoard=useCallback((quote:Quote)=>{
|
|
160
164
|
const context=routing.current;
|
|
161
165
|
writeDraft(context.task.id,{...readDraft(context.task.id),quote});
|
|
162
|
-
if(context.
|
|
166
|
+
if(context.tabMode||context.drawerMode)setView('chat');
|
|
163
167
|
requestAnimationFrame(()=>sessionRef.current?.querySelector<HTMLTextAreaElement>('.composer textarea')?.focus({preventScroll:true}));
|
|
164
168
|
},[]);
|
|
165
|
-
const activity=useMemo(()=>{const tool=[...messages].reverse().find(m=>m.role==='tool');return `${messages.length}:${tool?.id??''}:${tool?toolDataOf(tool).result!==undefined:false}`;},[messages]);
|
|
166
169
|
const [clearEstimate, setClearEstimate] = useState<InternalUsageEstimate | null>(null);
|
|
167
170
|
const sessions = useMemo(() => buildSessions(messages), [messages]);
|
|
168
171
|
const hasSession = task.started === 1 || messages.length > 0 || !!task.parent_task_id;
|
|
@@ -193,7 +196,7 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
|
|
|
193
196
|
<div className={`session${singlePane ? " compact-session" : ""}`} ref={sessionRef}>
|
|
194
197
|
<div className="sess-head">
|
|
195
198
|
{onBack && <button className="mobile-back" onClick={onBack} title="返回任务列表" aria-label="返回任务列表">{Icon.chevRight({ style: { transform: "rotate(180deg)" } })}</button>}
|
|
196
|
-
<div className="sh-main">
|
|
199
|
+
{hasSession&&tabMode ? <WorkspaceTabs title={task.title} subtitle={project.name} chatActive={view==='chat'} onChat={()=>setView('chat')} onTabsHost={setTabsHost}/> : <div className="sh-main">
|
|
197
200
|
<div className="crumb">
|
|
198
201
|
<span className="pic" style={{ width: 16, height: 16, borderRadius: 5, background: project.color, display: "grid", placeItems: "center", color: "#fff", fontSize: 9, fontWeight: 700 }}>{project.name[0]}</span>
|
|
199
202
|
<span className="session-project-name" title={project.name}>{project.name}</span>
|
|
@@ -203,8 +206,9 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
|
|
|
203
206
|
<span className="sht-text" title={task.title}>{task.title}</span>
|
|
204
207
|
<button className="icon-btn sh-edit" title="查看并编辑任务详情" aria-label="查看并编辑任务详情" onClick={onEdit}>{Icon.edit()}</button>
|
|
205
208
|
</div>
|
|
206
|
-
</div>
|
|
209
|
+
</div>}
|
|
207
210
|
<div className="sh-tools">
|
|
211
|
+
{hasSession&&drawerMode&&<button className="icon-btn workspace-open" title="打开工作区" aria-label="打开工作区" aria-haspopup="dialog" aria-expanded={view==='changes'} onClick={()=>setView('changes')}>{Icon.folder()}</button>}
|
|
208
212
|
<div style={{ position: "relative" }}>
|
|
209
213
|
<button className={`status-ctl ${awaiting ? "awaiting" : ""}`} onClick={(e) => { e.stopPropagation(); setStatusOpen((o) => !o); setMoreOpen(false); }}>
|
|
210
214
|
<StatusDot status={task.status} running={running} awaiting={awaiting} />
|
|
@@ -227,6 +231,7 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
|
|
|
227
231
|
<button className="icon-btn session-more" aria-label="更多任务操作" aria-expanded={moreOpen} onClick={(e) => { e.stopPropagation(); setMoreOpen(!moreOpen); setStatusOpen(false); }}>{Icon.dots()}</button>
|
|
228
232
|
{moreOpen && <Popover onClose={() => setMoreOpen(false)}>
|
|
229
233
|
<div className="pop-sec">{agentLabel(agents, task.agent)} · {fmtTokens(usage.fresh)} tokens{cost.show && task.cost_usd > 0 ? ` · ${cost.approx ? "~" : ""}${fmtCost(task.cost_usd)}` : ""}</div>
|
|
234
|
+
<div className="pop-note session-origin" title={task.work_branch || ""}>{Icon.git()}<span>{task.parent_task_id ? `来自 ${task.fork_source_title || "来源任务"}` : "独立任务分支"}</span></div>
|
|
230
235
|
<button className="pop-item" onClick={() => { setMoreOpen(false); onEdit(); }}>{Icon.edit()} 编辑任务详情</button>
|
|
231
236
|
<button className="pop-item" onClick={() => { setMoreOpen(false); downloadExport(`/api/tasks/${task.id}/export`); }}>{Icon.download()} 导出执行过程</button>
|
|
232
237
|
{task.pr_url && <a className="pop-item" href={task.pr_url} target="_blank" rel="noreferrer">{Icon.github()} 打开 PR{prNum ? ` #${prNum}` : ""}</a>}
|
|
@@ -239,14 +244,6 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
|
|
|
239
244
|
</div>
|
|
240
245
|
</div>
|
|
241
246
|
|
|
242
|
-
{hasSession && <div className="session-viewbar">
|
|
243
|
-
{singlePane ? <div className="viewseg">
|
|
244
|
-
<button className={`viewseg-btn ${view === "chat" ? "on" : ""}`} onClick={() => setView("chat")}>对话</button>
|
|
245
|
-
<button className={`viewseg-btn ${view === "changes" ? "on" : ""}`} onClick={() => setView("changes")}>看板</button>
|
|
246
|
-
</div> : <span className="session-agent-label">{agentLabel(agents, task.agent)}</span>}
|
|
247
|
-
<span className="session-branch" title={task.work_branch || ""}>{Icon.git()} {task.parent_task_id ? `来自 ${task.fork_source_title}` : "独立任务分支"}</span>
|
|
248
|
-
</div>}
|
|
249
|
-
|
|
250
247
|
{hasSession && (
|
|
251
248
|
<SyncBanner taskId={task.id} running={running} pinned={!!task.fork_commit} onResolveWithAI={onResolveWithAI} onSwitchToChat={() => setView("chat")} />
|
|
252
249
|
)}
|
|
@@ -267,19 +264,22 @@ function SessionViewInner({ project, task, tasks, onSelectTask, onFork, agents,
|
|
|
267
264
|
// Keep the chat at the same React position across all widths. Moving
|
|
268
265
|
// it between different parents remounted the composer during resize
|
|
269
266
|
// and could drop a keystroke, an upload, or an in-flight setting save.
|
|
270
|
-
<div className={
|
|
271
|
-
<div className={`sess-main${
|
|
267
|
+
<div className={tabMode||drawerMode ? "sess-responsive" : "sess-split"} style={tabMode||drawerMode ? undefined : { gridTemplateColumns: `minmax(0,1fr) 0px ${railW}px` }}>
|
|
268
|
+
<div className={`sess-main${tabMode||drawerMode ? " compact-chat" : ""}`} data-chat-layout={tabMode||drawerMode?"centered":"wide"} hidden={tabMode && view !== "chat"}><Chat project={project} task={task} tasks={tasks} agents={agents} messages={messages}
|
|
272
269
|
running={running} blockedBy={blockedBy} transcriptLoading={transcriptLoading} older={older} onLoadOlder={onLoadOlder}
|
|
273
|
-
controls={controls} configBusy={configBusy} active={!
|
|
270
|
+
controls={controls} configBusy={configBusy} active={!tabMode&&!drawerMode || view === "chat"}
|
|
274
271
|
onSelectTask={onSelectTask} onFork={onFork} onSend={onSend} onStop={onStop}
|
|
275
272
|
onStart={onStart} onEdit={onEdit} onClear={onClear} onSetSendContext={onSetSendContext}
|
|
276
273
|
onReconnect={onReconnect} onAnswer={onAnswer} onCancelQueued={onCancelQueued} /></div>
|
|
277
|
-
{!
|
|
278
|
-
|
|
279
|
-
<SessionRail {...board} openRequest={boardRequest} onRequestHandled={acknowledgeBoardRequest}
|
|
274
|
+
{!tabMode&&!drawerMode && <ColResize side="right" min={RAIL_W.min} max={RAIL_W.max} onWidth={onRailWidth} onReset={onRailReset} />}
|
|
275
|
+
<WorkspaceDrawer enabled={drawerMode} open={view==='changes'} onClose={()=>setView('chat')}>
|
|
276
|
+
<SessionRail {...board} openRequest={boardRequest} onRequestHandled={acknowledgeBoardRequest} project={project} task={task} sessions={sessions} running={running}
|
|
280
277
|
onResolveWithAI={onResolveWithAI} onMerged={onMerged} onPrCreated={onPrCreated} onClear={onClear}
|
|
281
|
-
|
|
282
|
-
|
|
278
|
+
inlineLayout={tabMode?{tabsHost,active:view==='changes',onActivate:()=>setView('changes'),onChat:()=>setView('chat')}:undefined}
|
|
279
|
+
visible={!drawerMode||view==='changes'}
|
|
280
|
+
onExpand={tabMode&&!singlePane?onRailExpand:undefined}
|
|
281
|
+
onCollapse={tabMode||drawerMode ? () => setView("chat") : onRailCollapse} onSwitchToChat={() => setView("chat")} />
|
|
282
|
+
</WorkspaceDrawer>
|
|
283
283
|
</div>
|
|
284
284
|
)}
|
|
285
285
|
</div>
|
|
@@ -3,7 +3,7 @@ import { useContext } from "react";
|
|
|
3
3
|
import { OperatorBoardContext } from "./boardInteraction";
|
|
4
4
|
import { FileLinkContext } from "./fileLinks";
|
|
5
5
|
|
|
6
|
-
import { memo, useState } from "react";
|
|
6
|
+
import { memo, useId, useState } from "react";
|
|
7
7
|
import type { ToolData, ToolPeek, AskQuestion, AskAnswers, InputRequest } from "./protocol/types";
|
|
8
8
|
import { Icon } from "./icons";
|
|
9
9
|
import { Markdown } from "./Markdown";
|
|
@@ -18,6 +18,8 @@ import type { Msg } from "./types";
|
|
|
18
18
|
import { clientFeatures } from "./protocol/features";
|
|
19
19
|
import { Avatar } from "./shared";
|
|
20
20
|
import { toolDataOf } from "./transcriptModel";
|
|
21
|
+
import { ReferenceCard } from "../../ui/ReferenceCard";
|
|
22
|
+
import { splitQuotedMessage } from "../../ui/reference";
|
|
21
23
|
|
|
22
24
|
// The always-visible "peek" tier — Claude Code's `⎿` line. Counts show no
|
|
23
25
|
// content; diffs/snippets show a capped hunk with a clickable "+N more" that
|
|
@@ -64,21 +66,22 @@ function PeekView({ peek, expandable, onExpand }: { peek: ToolPeek; expandable:
|
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
function ToolView({ data }: { data: ToolData }) {
|
|
67
|
-
const [open, setOpen] = useState(
|
|
69
|
+
const [open, setOpen] = useState<boolean | null>(null);
|
|
70
|
+
const bodyId = useId();
|
|
68
71
|
const hasDiff = !!data.diff?.length;
|
|
69
72
|
const expandable = !!(data.detail || hasDiff || data.result !== undefined);
|
|
70
73
|
// Failures surface their output automatically, like Claude Code.
|
|
71
|
-
const showBody = open
|
|
74
|
+
const showBody = open ?? (!!data.isError && data.result !== undefined);
|
|
72
75
|
return (
|
|
73
76
|
<div className="tool">
|
|
74
|
-
<button className="tool-h" style={{ cursor: expandable ? "pointer" : "default" }} onClick={() => expandable && setOpen(
|
|
77
|
+
<button className="tool-h" title={data.title} aria-expanded={expandable ? showBody : undefined} aria-controls={expandable ? bodyId : undefined} style={{ cursor: expandable ? "pointer" : "default" }} onClick={() => expandable && setOpen(!showBody)}>
|
|
75
78
|
{expandable && <span className={`tchev ${showBody ? "open" : ""}`}>{Icon.chevRight()}</span>}
|
|
76
79
|
<span className="tg">{data.title}</span>
|
|
77
80
|
{data.result !== undefined && <span className={data.isError ? "tx" : "tcheck"}>{data.isError ? Icon.x() : Icon.check()}</span>}
|
|
78
81
|
</button>
|
|
79
82
|
{data.peek && !showBody && <PeekView peek={data.peek} expandable={expandable} onExpand={() => setOpen(true)} />}
|
|
80
83
|
{showBody && (
|
|
81
|
-
<div className="tool-body">
|
|
84
|
+
<div className="tool-body" id={bodyId}>
|
|
82
85
|
{data.detail && <pre className="tool-pre">{data.detail}</pre>}
|
|
83
86
|
{hasDiff && (
|
|
84
87
|
<pre className="tool-pre diff">{data.diff!.map((l, i) => <div className={`dl ${diffCls(l.sign)}`} key={i}>{l.sign} {l.text}</div>)}</pre>
|
|
@@ -280,6 +283,7 @@ function HandoffLine({ text, onBrowser, onShowBrowser, readOnly }: { readOnly?:b
|
|
|
280
283
|
// (click opens full size) and text-file chips (a big paste diverted to a file;
|
|
281
284
|
// click opens it). Both are served from the task's uploads dir.
|
|
282
285
|
function AttachmentStrip({ items }: { items: MsgAttachment[] }) {
|
|
286
|
+
const open = useContext(OperatorBoardContext), scope = useContext(FileLinkContext);
|
|
283
287
|
if (!items.length) return null;
|
|
284
288
|
return (
|
|
285
289
|
<div className="msg-attachments">
|
|
@@ -290,8 +294,11 @@ function AttachmentStrip({ items }: { items: MsgAttachment[] }) {
|
|
|
290
294
|
<img src={a.url} alt="附件图片" loading="lazy" />
|
|
291
295
|
</a>
|
|
292
296
|
) : (
|
|
293
|
-
<a key={i} href={a.url} target="_blank" rel="noreferrer" className="file-chip" title={`打开 ${a.name}`}
|
|
294
|
-
|
|
297
|
+
<a key={i} href={a.url} target="_blank" rel="noreferrer" className="file-chip" title={`打开 ${a.name}`} onClick={event => {
|
|
298
|
+
const taskId = /\/api\/tasks\/([^/]+)\/uploads\//.exec(a.url)?.[1] ?? scope?.taskId;
|
|
299
|
+
if (taskId && event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey && open?.({ kind: "file", taskId: decodeURIComponent(taskId), path: a.path })) event.preventDefault();
|
|
300
|
+
}}>
|
|
301
|
+
{Icon.clip()} <span>{a.name}</span>
|
|
295
302
|
</a>
|
|
296
303
|
)
|
|
297
304
|
)}
|
|
@@ -310,6 +317,11 @@ function AutomaticBoardBackground({content}:{content:string}) {
|
|
|
310
317
|
return <details className="cli-auto-background"><summary>自动看板背景</summary>{blocks.map((part,index)=>part.kind==='block'?<pre key={index}>{part.block.text}</pre>:null)}</details>;
|
|
311
318
|
}
|
|
312
319
|
|
|
320
|
+
function QuotedUserText({ text }: { text: string }) {
|
|
321
|
+
const quoted = splitQuotedMessage(text);
|
|
322
|
+
return quoted ? <><ReferenceCard title={quoted.quote.author} text={quoted.quote.text} renderContent={value => <Markdown>{value}</Markdown>} />{quoted.text && <Markdown>{quoted.text}</Markdown>}</> : <Markdown>{text}</Markdown>;
|
|
323
|
+
}
|
|
324
|
+
|
|
313
325
|
export const MessageView = memo(function MessageView({ m, initial, hideWho, running, agent, agentLabel = "智能体", onAnswer, onInput, onBrowser, onCancelQueued, onClear, onReconnect, onRetry, onQuote, readOnly }: { readOnly?: boolean; onQuote?: (m: Msg, selected?: string) => void; m: Msg; initial: boolean; hideWho: boolean; running?: boolean; agent?: string | null; agentLabel?: string; onAnswer?: (askId: string, questions: AskQuestion[], answers: AskAnswers) => void; onInput?: (inputId: string, values: string[]) => Promise<string | null>; onBrowser?: (action: "handback") => Promise<string | null>; onCancelQueued?: (pendingId: string) => void; onClear?: () => void; onReconnect?: () => void; onRetry?: (msgId: string) => void }) {
|
|
314
326
|
const openBoard=useContext(OperatorBoardContext);
|
|
315
327
|
const fileScope=useContext(FileLinkContext);
|
|
@@ -323,7 +335,7 @@ export const MessageView = memo(function MessageView({ m, initial, hideWho, runn
|
|
|
323
335
|
<div className="msg user queued">
|
|
324
336
|
<div className="who"><Avatar who="user" /> 你<span className="badge queued-badge">排队中</span>{m.ts != null && <span className="msg-time">{clockTime(m.ts)}</span>}</div>
|
|
325
337
|
<div className="msg-body">
|
|
326
|
-
{text && <
|
|
338
|
+
{text && <QuotedUserText text={text} />}
|
|
327
339
|
<AutomaticBoardBackground content={m.content}/>
|
|
328
340
|
<AttachmentStrip items={attachments} />
|
|
329
341
|
{onCancelQueued && <button className="queued-x" title="从队列中移除" aria-label="从队列中移除" onClick={() => onCancelQueued(m.id)}>{Icon.x()}</button>}
|
|
@@ -440,7 +452,7 @@ export const MessageView = memo(function MessageView({ m, initial, hideWho, runn
|
|
|
440
452
|
)}
|
|
441
453
|
<div className="msg-body">
|
|
442
454
|
{initial && <div className="initial-tag">{Icon.spark()} 已随项目上下文发送</div>}
|
|
443
|
-
{text && <Markdown>{text}</Markdown>}
|
|
455
|
+
{text && (isUser ? <QuotedUserText text={text} /> : <Markdown>{text}</Markdown>)}
|
|
444
456
|
{isUser&&<AutomaticBoardBackground content={m.content}/>}
|
|
445
457
|
<AttachmentStrip items={attachments} />
|
|
446
458
|
</div>
|
|
@@ -24,25 +24,6 @@ export function createOperatorBoardBackend(fetcher: typeof fetch = (...args) =>
|
|
|
24
24
|
},
|
|
25
25
|
fileUrl(id, path, options) { const url = taskFileApiUrl(id, path); return options?.download ? `${url}${url.includes('?') ? '&' : '?'}download=1` : url; },
|
|
26
26
|
fileLinkBase: async id => `/api/tasks/${encodeURIComponent(id)}/files`,
|
|
27
|
-
watchFiles(id,handlers) {
|
|
28
|
-
let stopped=false,ended=false;
|
|
29
|
-
const disrupted=(reason:string)=>{if(!stopped&&!ended)handlers.onEvent({type:'resync',reason,complete:false});};
|
|
30
|
-
const subscription=openLive(`/api/tasks/${encodeURIComponent(id)}/files/events`,{
|
|
31
|
-
onMessage:data=>{
|
|
32
|
-
if(stopped||ended)return;
|
|
33
|
-
try{const event=JSON.parse(data);if(['snapshot','changes','resync'].includes(event.type))handlers.onEvent(event);}catch{/* Ignore invalid event. */}
|
|
34
|
-
},
|
|
35
|
-
onEnd:code=>{
|
|
36
|
-
if(stopped||ended)return;
|
|
37
|
-
if(code===0||code===408||code===429||(code>=500&&code!==501)){disrupted(code===429?'文件监听暂时限流,正在等待恢复':'文件监听连接中断,正在重连');return;}
|
|
38
|
-
ended=true;
|
|
39
|
-
const message=code===401?'文件监听登录已过期,请重新登录':code===403?'文件监听被拒绝,请检查任务权限或登录':code===404?'未找到任务或节点不支持文件事件接口':`文件监听请求失败(HTTP ${code})`;
|
|
40
|
-
handlers.onClose(new CenterError(message,`HTTP_${code}`,code));
|
|
41
|
-
},
|
|
42
|
-
onError:error=>disrupted(error.message),
|
|
43
|
-
});
|
|
44
|
-
return()=>{stopped=true;subscription.close();};
|
|
45
|
-
},
|
|
46
27
|
browserEvents(id,handlers) {
|
|
47
28
|
let stopped=false,ended=false;
|
|
48
29
|
const disrupted=(error:CenterError)=>{if(!stopped&&!ended)handlers.onError?.(error);};
|
|
@@ -4,16 +4,34 @@ import type { WorkspaceHandlers } from '../../core/provider';
|
|
|
4
4
|
import type { WorkspaceChangeEvent } from '../../core/types';
|
|
5
5
|
import { cloneBoardView } from '../../board/context';
|
|
6
6
|
import type { CliEvent } from '../../core/store';
|
|
7
|
-
import { cloneWorkspaceChange, subscribeWorkspaceFiles } from '../../board/fileWatch';
|
|
7
|
+
import { cloneWorkspaceChange, subscribeWorkspaceFiles, retryWorkspaceFiles, type WorkspaceRefreshOptions } from '../../board/fileWatch';
|
|
8
8
|
import { getOperatorBoardBackend } from './boardBackend';
|
|
9
|
-
import { readBoardState, subscribeBoardState } from './boardState';
|
|
9
|
+
import { readBoardState, readVisibleBoardSources, subscribeBoardState } from './boardState';
|
|
10
|
+
import { observeLiveMessages } from './liveStream';
|
|
11
|
+
export function refreshOperatorWorkspaceFiles(taskId:string,options?:WorkspaceRefreshOptions):void {
|
|
12
|
+
const backend=getOperatorBoardBackend(),sources=new Set([taskId,...readVisibleBoardSources(taskId)]);
|
|
13
|
+
for(const source of sources)options?retryWorkspaceFiles(backend,source,options):retryWorkspaceFiles(backend,source);
|
|
14
|
+
}
|
|
15
|
+
const observedTasks=new Map<string,{refs:number;stop:()=>void}>();
|
|
16
|
+
function observeTaskFiles(taskId:string):()=>void {
|
|
17
|
+
let entry=observedTasks.get(taskId);
|
|
18
|
+
if(!entry){
|
|
19
|
+
entry={refs:0,stop:observeLiveMessages(`/api/tasks/${taskId}/messages`,data=>{
|
|
20
|
+
try{const event=JSON.parse(data);if(event.type==='turn_end'||event.type==='snapshot')refreshOperatorWorkspaceFiles(taskId);}catch{/* Ignore unrelated/malformed stream payloads. */}
|
|
21
|
+
})};observedTasks.set(taskId,entry);
|
|
22
|
+
}
|
|
23
|
+
const owned=entry;owned.refs++;let active=true;
|
|
24
|
+
return()=>{if(!active)return;active=false;if(--owned.refs===0){observedTasks.delete(taskId);owned.stop();}};
|
|
25
|
+
}
|
|
10
26
|
export type OperatorBoardHandlers=WorkspaceHandlers&{onEvent?:(event:CliEvent)=>void};
|
|
11
27
|
export function useOperatorFileWatch(taskId:string,enabled:boolean,handlers:OperatorBoardHandlers={},onChange?:(event:WorkspaceChangeEvent)=>void,intervalMs?:number){
|
|
12
28
|
const current=useRef({handlers,onChange});current.current={handlers,onChange};
|
|
13
29
|
useEffect(()=>{
|
|
14
30
|
if(!enabled)return;
|
|
15
|
-
|
|
31
|
+
const unobserve=observeTaskFiles(taskId);
|
|
32
|
+
const unsubscribe=subscribeWorkspaceFiles(getOperatorBoardBackend(),taskId,event=>{current.current.onChange?.(event);notifyCallback(current.current.handlers.onFilesChange,cloneWorkspaceChange(event));notifyCallback(current.current.handlers.onEvent,cloneWorkspaceChange(event));},
|
|
16
33
|
{intervalMs,onStatus:status=>{notifyCallback(current.current.handlers.onFilesWatch,{...status});notifyCallback(current.current.handlers.onEvent,{...status});}});
|
|
34
|
+
return()=>{unobserve();unsubscribe();};
|
|
17
35
|
},[taskId,enabled,intervalMs]);
|
|
18
36
|
}
|
|
19
37
|
export function useOperatorContextEvents(taskId:string,enabled:boolean,handlers:OperatorBoardHandlers={}){
|
|
@@ -5,6 +5,8 @@ import { notifyCallback } from '../../core/callbacks';
|
|
|
5
5
|
const cache=new Map<string,{stamp:string;view:BoardView|null}>();
|
|
6
6
|
const listeners=new Map<string,Set<()=>void>>();
|
|
7
7
|
const owners=new Map<string,Map<string,BoardView>>();
|
|
8
|
+
// Mounted directory identity is transient; it must not become retained browsing history.
|
|
9
|
+
const directorySources=new Map<string,Map<string,string>>();
|
|
8
10
|
const key=(id:string)=>`cli-ui:operator-board:${id}`;
|
|
9
11
|
const stamp=(id:string)=>{try{return sessionStorage.getItem(key(id))??'';}catch{return 'blocked';}};
|
|
10
12
|
const validFile=(value:unknown):value is BoardFile=>!!value&&typeof value==='object'&&typeof (value as BoardFile).path==='string'&&!!(value as BoardFile).path&&((value as BoardFile).sourceId===undefined||typeof (value as BoardFile).sourceId==='string');
|
|
@@ -20,6 +22,18 @@ export function readBoardState(taskId:string):BoardView|null {
|
|
|
20
22
|
}catch{/* Broken preferences cannot stop the workspace. */}
|
|
21
23
|
cache.set(taskId,{stamp:saved,view});return view;
|
|
22
24
|
}
|
|
25
|
+
/** Internal: background refresh covers every visible owner, not just the selected board. */
|
|
26
|
+
export function readVisibleBoardSources(taskId:string):string[] {
|
|
27
|
+
const active=owners.get(taskId);
|
|
28
|
+
const views=active?.size?[...active]:[['',readBoardState(taskId)] as const];
|
|
29
|
+
const sources=new Set<string>();
|
|
30
|
+
for(const [owner,view] of views){
|
|
31
|
+
if(!view||view.visible===false)continue;
|
|
32
|
+
for(const file of view.opened??[])sources.add(file.sourceId??taskId);
|
|
33
|
+
const directory=directorySources.get(taskId)?.get(owner);if(directory)sources.add(directory);
|
|
34
|
+
}
|
|
35
|
+
return [...sources];
|
|
36
|
+
}
|
|
23
37
|
export function subscribeBoardState(taskId:string,listener:()=>void):()=>void {
|
|
24
38
|
const set=listeners.get(taskId)??new Set<()=>void>();set.add(listener);listeners.set(taskId,set);return()=>{set.delete(listener);if(!set.size)listeners.delete(taskId);};
|
|
25
39
|
}
|
|
@@ -36,8 +50,10 @@ export function visitBoardFile(taskId:string,file:BoardFile):void {
|
|
|
36
50
|
const current=readBoardState(taskId)??{tab:'files',dir:'',file:null,files:[]};
|
|
37
51
|
publishBoardState(taskId,{...current,history:recent([...(current.history??[]),file])});
|
|
38
52
|
}
|
|
39
|
-
export function reportBoardState(taskId:string,owner:string,view:BoardView):void {
|
|
53
|
+
export function reportBoardState(taskId:string,owner:string,view:BoardView,directorySource?:string):void {
|
|
40
54
|
const sources=owners.get(taskId)??new Map<string,BoardView>();
|
|
55
|
+
if(directorySource){const directories=directorySources.get(taskId)??new Map<string,string>();directories.set(owner,directorySource);directorySources.set(taskId,directories);}
|
|
56
|
+
else{const directories=directorySources.get(taskId);directories?.delete(owner);if(!directories?.size)directorySources.delete(taskId);}
|
|
41
57
|
const history=readBoardState(taskId)?.history??view.history??[];
|
|
42
58
|
const selected=reportBoardOwner(sources,owner,{...view,history});owners.set(taskId,sources);
|
|
43
59
|
if(selected)publishBoardState(taskId,{...selected[1],history});
|
|
@@ -51,6 +67,7 @@ export function activateBoardState(taskId:string,owner:string):void {
|
|
|
51
67
|
}
|
|
52
68
|
export function releaseBoardState(taskId:string,owner:string):void {
|
|
53
69
|
const sources=owners.get(taskId);sources?.delete(owner);if(!sources?.size)owners.delete(taskId);
|
|
70
|
+
const directories=directorySources.get(taskId);directories?.delete(owner);if(!directories?.size)directorySources.delete(taskId);
|
|
54
71
|
const previous=readBoardState(taskId);if(!previous)return;
|
|
55
72
|
const remaining=sources?.size?selectedBoardOwner(sources)?.[1]:null;
|
|
56
73
|
publishBoardState(taskId,remaining?{...remaining,history:previous.history,visible:remaining.visible!==false}:{...previous,visible:false});
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import type { WorkspaceRefreshOptions } from '../../board/fileWatch';
|
|
1
2
|
import { createWorkspace } from '../../board/workspace';
|
|
2
3
|
import { cloneBoardView } from '../../board/context';
|
|
3
4
|
import { getOperatorBoardBackend } from './boardBackend';
|
|
4
5
|
import { readBoardState } from './boardState';
|
|
5
6
|
import { openOperatorBoard } from './boardInteraction';
|
|
7
|
+
import { refreshOperatorWorkspaceFiles } from './boardEvents';
|
|
6
8
|
/** Host API for the node workspace. Uses the same cloud-facing file API as the board. */
|
|
7
9
|
export function createOperatorWorkspace(taskId:string){
|
|
8
|
-
|
|
10
|
+
const workspace=createWorkspace({backend:getOperatorBoardBackend(),key:taskId,
|
|
9
11
|
view:()=>{const view=readBoardState(taskId);return view?.visible?cloneBoardView(view):undefined;},
|
|
10
12
|
context:()=>{const view=readBoardState(taskId);return view?cloneBoardView(view):null;},
|
|
11
13
|
open:path=>openOperatorBoard({kind:'file',taskId,path})});
|
|
14
|
+
return {...workspace,refresh:(options?:WorkspaceRefreshOptions)=>refreshOperatorWorkspaceFiles(taskId,options)};
|
|
12
15
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
import type { UploadPhase } from "../../core/documentUpload";
|
|
2
|
+
|
|
3
|
+
export type Quote = { id: string; author: string; text: string; sourceTaskId?: string };
|
|
2
4
|
export type Attachment = {
|
|
5
|
+
progress?: number;
|
|
6
|
+
phase?: UploadPhase; bytes?: number; size?: number;
|
|
3
7
|
key: string; kind: "image" | "file"; name: string; preview: string; path: string;
|
|
4
8
|
status: "uploading" | "ready" | "error"; error?: string;
|
|
5
9
|
};
|
|
@@ -15,7 +19,7 @@ export function readDraft(id: string): ComposerDraft {
|
|
|
15
19
|
const raw = localStorage.getItem(key(id));
|
|
16
20
|
if (raw) {
|
|
17
21
|
const d = JSON.parse(raw) as ComposerDraft;
|
|
18
|
-
if (typeof d.text === "string" && Array.isArray(d.attachments)) return { ...d, attachments: d.attachments.map((a) => a.status === "uploading" ? { ...a, preview: "", status: "error", error: "
|
|
22
|
+
if (typeof d.text === "string" && Array.isArray(d.attachments)) return { ...d, attachments: d.attachments.map((a) => a.status === "uploading" ? { ...a, preview: "", status: "error", error: "上传已中断,请重新选择文件。" } : a) };
|
|
19
23
|
}
|
|
20
24
|
return { ...emptyDraft(), text: localStorage.getItem(`orch:draft:${id}`) ?? "" };
|
|
21
25
|
} catch { return emptyDraft(); }
|
|
@@ -279,9 +279,11 @@ export function splitAttachments(content: string): { text: string; attachments:
|
|
|
279
279
|
for (const line of content.split("\n")) {
|
|
280
280
|
const m = ATTACHMENT_RE.exec(line.trim());
|
|
281
281
|
const parts = m ? m[2].split(/[\\/]/).filter(Boolean) : [];
|
|
282
|
-
|
|
282
|
+
const upload = parts.length >= 3 && parts[parts.length - 3] === "uploads";
|
|
283
|
+
const managed = parts.length >= 4 && parts[parts.length - 4] === ".workspace" && parts[parts.length - 3] === "attachments";
|
|
284
|
+
if (m && (upload || managed) && parts.slice(-2).every(part => part !== "." && part !== "..")) {
|
|
283
285
|
const [taskId, file] = parts.slice(-2);
|
|
284
|
-
attachments.push({ path: m[2], url: `/api/tasks/${taskId}/uploads/${file}`, kind: m[1] === "image" ? "image" : "file", name: file });
|
|
286
|
+
attachments.push({ path: m[2], url: `/api/tasks/${encodeURIComponent(taskId)}/uploads/${encodeURIComponent(file)}`, kind: m[1] === "image" ? "image" : "file", name: file });
|
|
285
287
|
} else {
|
|
286
288
|
kept.push(line);
|
|
287
289
|
}
|
|
@@ -45,6 +45,18 @@ const BACKOFF_MS = [500, 1000, 2000, 4000, 8000];
|
|
|
45
45
|
const PROBE_MS = 30_000;
|
|
46
46
|
|
|
47
47
|
const subs = new Map<number, Sub>();
|
|
48
|
+
const observers = new Map<string, Set<(data: string) => void>>();
|
|
49
|
+
/** Observe an existing route without opening a connection or server subscription. */
|
|
50
|
+
export function observeLiveMessages(path: string, listener: (data: string) => void): () => void {
|
|
51
|
+
const key = path.split("?")[0];
|
|
52
|
+
const listeners = observers.get(key) ?? new Set(); observers.set(key, listeners); listeners.add(listener);
|
|
53
|
+
return () => { listeners.delete(listener); if (!listeners.size) observers.delete(key); };
|
|
54
|
+
}
|
|
55
|
+
function deliver(s: Sub, data: string) {
|
|
56
|
+
if (subs.get(s.id) !== s) return;
|
|
57
|
+
for (const listener of [...observers.get(s.url.split("?")[0]) ?? []]) notifyCallback(listener, data);
|
|
58
|
+
if (subs.get(s.id) === s) s.h.onMessage(data);
|
|
59
|
+
}
|
|
48
60
|
let nextId = 1;
|
|
49
61
|
let mode: "ws" | "sse" | null = null;
|
|
50
62
|
let ws: WebSocket | null = null;
|
|
@@ -91,7 +103,7 @@ function openEventSource(s: Sub) {
|
|
|
91
103
|
const es = new EventSource(s.url);
|
|
92
104
|
const active = () => subs.get(s.id) === s && s.es === es;
|
|
93
105
|
es.onopen = () => { if (!active()) return; if (s.retry) { clearTimeout(s.retry); s.retry = undefined; } s.fails = 0; s.h.onOpen?.(); };
|
|
94
|
-
es.onmessage = (event) => { if (active()) s
|
|
106
|
+
es.onmessage = (event) => { if (active()) deliver(s, event.data); };
|
|
95
107
|
es.onerror = () => {
|
|
96
108
|
if (!active()) return;
|
|
97
109
|
notifyCallback(s.h.onError, new Error("实时事件连接中断,正在等待恢复"));
|
|
@@ -203,7 +215,7 @@ function onFrame(text: string) {
|
|
|
203
215
|
const nl = text.indexOf("\n");
|
|
204
216
|
if (nl <= 0) return;
|
|
205
217
|
const s = subs.get(Number(text.slice(0, nl)));
|
|
206
|
-
if (s) s
|
|
218
|
+
if (s) deliver(s, text.slice(nl + 1));
|
|
207
219
|
}
|
|
208
220
|
|
|
209
221
|
function connect() {
|
|
@@ -232,12 +232,14 @@ export function useTaskStream({ selTask, selProjRef, agentsRef, setTaskRunning,
|
|
|
232
232
|
setOlderByTask(drop);
|
|
233
233
|
for (const t of evicted) { delete openAsksRef.current[t]; delete loadingOlderRef.current[t]; }
|
|
234
234
|
}
|
|
235
|
+
let active = true;
|
|
235
236
|
const sub = openLive(`/api/tasks/${id}/messages?tail=${SNAPSHOT_TAIL}`, {
|
|
236
237
|
onMessage: (data) => {
|
|
238
|
+
if (!active) return;
|
|
237
239
|
try { handleStreamEventRef.current(id, JSON.parse(data) as TaskStreamEvent); } catch {}
|
|
238
240
|
},
|
|
239
241
|
});
|
|
240
|
-
return () => sub.close();
|
|
242
|
+
return () => { active = false; sub.close(); };
|
|
241
243
|
}, [selTask]);
|
|
242
244
|
|
|
243
245
|
// Page in the next batch of earlier messages for a task (the transcript calls
|