@spzxg/cli-ui 0.6.2 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +137 -11
- package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
- package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-D4qfPHMd.js} +2 -2
- package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
- package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
- package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
- package/dist/cli-ui.css +32 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +14 -8
- package/src/board/BrowserPane.tsx +160 -74
- package/src/board/ChangesPane.tsx +2 -2
- package/src/board/CliBoard.tsx +79 -50
- package/src/board/FileViewer.tsx +32 -22
- package/src/board/FilesPane.tsx +32 -15
- package/src/board/PanelControls.tsx +28 -0
- package/src/board/blocks.tsx +5 -5
- package/src/board/context.ts +65 -0
- package/src/board/fileOps.ts +26 -3
- package/src/board/fileWatch.ts +88 -0
- package/src/board/folderDownload.ts +30 -14
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +2 -2
- package/src/board/workspace.ts +38 -9
- package/src/chat/CliChat.tsx +73 -30
- package/src/chat/Composer.tsx +41 -41
- package/src/chat/RunControls.tsx +99 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +41 -0
- package/src/chat/export.ts +2 -2
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +2 -2
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +8 -6
- package/src/core/conversation.ts +3 -3
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +33 -0
- package/src/core/provider.tsx +24 -14
- package/src/core/store.ts +83 -26
- package/src/core/types.ts +34 -0
- package/src/elements.tsx +135 -29
- package/src/embed.ts +6 -8
- package/src/index.ts +24 -17
- package/src/layout/CliSplit.tsx +1 -1
- package/src/msglist/CliMsgList.tsx +10 -10
- package/src/operator/branchFlow.ts +35 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/liveStream.ts +1 -0
- package/src/operator/node/AgentConnect.tsx +624 -0
- package/src/operator/node/AppearancePanel.tsx +35 -0
- package/src/operator/node/BranchHistory.tsx +67 -0
- package/src/operator/node/BranchNavigator.tsx +36 -0
- package/src/operator/node/BrowserPane.tsx +128 -0
- package/src/operator/node/BrowserPopup.tsx +22 -0
- package/src/operator/node/Chat.tsx +517 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +287 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +190 -0
- package/src/operator/node/Markdown.tsx +61 -0
- package/src/operator/node/Modal.tsx +179 -0
- package/src/operator/node/NeedsYouMenu.tsx +54 -0
- package/src/operator/node/OnboardingWizard.tsx +250 -0
- package/src/operator/node/Orchestrator.tsx +634 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +561 -0
- package/src/operator/node/RunControls.tsx +2 -0
- package/src/operator/node/RunSwitch.tsx +113 -0
- package/src/operator/node/Services.tsx +245 -0
- package/src/operator/node/SessionRail.tsx +260 -0
- package/src/operator/node/SessionView.tsx +288 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +530 -0
- package/src/operator/node/TaskHero.tsx +50 -0
- package/src/operator/node/TasksColumn.tsx +200 -0
- package/src/operator/node/Terminal.tsx +143 -0
- package/src/operator/node/Transcript.tsx +463 -0
- package/src/operator/node/ViewFile.tsx +79 -0
- package/src/operator/node/Welcome.tsx +101 -0
- package/src/operator/node/WorktreePrune.tsx +333 -0
- package/src/operator/node/agents.ts +58 -0
- package/src/operator/node/api.ts +40 -0
- package/src/operator/node/boardBackend.ts +80 -0
- package/src/operator/node/boardEvents.ts +22 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +57 -0
- package/src/operator/node/boardWorkspace.ts +12 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +52 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +290 -0
- package/src/operator/node/github.tsx +234 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +270 -0
- package/src/operator/node/modals.tsx +720 -0
- package/src/operator/node/navHistory.ts +70 -0
- package/src/operator/node/persist.ts +26 -0
- package/src/operator/node/protocol/agents/optIn.ts +9 -0
- package/src/operator/node/protocol/agents/types.ts +394 -0
- package/src/operator/node/protocol/approvalFailure.ts +77 -0
- package/src/operator/node/protocol/authFailure.ts +66 -0
- package/src/operator/node/protocol/customModels.ts +23 -0
- package/src/operator/node/protocol/events.ts +13 -0
- package/src/operator/node/protocol/features.ts +70 -0
- package/src/operator/node/protocol/internalUsage.ts +1 -0
- package/src/operator/node/protocol/promptLimits.ts +52 -0
- package/src/operator/node/protocol/storageRetention.ts +13 -0
- package/src/operator/node/protocol/types.ts +332 -0
- package/src/operator/node/protocol/usageLimit.ts +72 -0
- package/src/operator/node/shared.tsx +89 -0
- package/src/operator/node/transcriptModel.ts +129 -0
- package/src/operator/node/types.ts +296 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +696 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +91 -0
- package/src/operator/node/useTaskStream.ts +279 -0
- package/src/operator.base.css +1798 -0
- package/src/operator.css +186 -16
- package/src/styles.css +20 -0
- package/src/ui/Markdown.tsx +7 -2
- package/src/ui/Popover.tsx +77 -0
- package/src/ui/TextBlock.tsx +3 -3
- package/src/ui/Transcript.tsx +10 -8
- package/src/ui/fileLinks.ts +6 -0
- package/src/ui/format.ts +1 -1
- package/src/ui/shared.tsx +1 -1
- package/src/ui/transcriptModel.ts +2 -2
- package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
package/src/chat/Composer.tsx
CHANGED
|
@@ -1,24 +1,16 @@
|
|
|
1
1
|
// 输入框。结构与 Operator 的 Composer.tsx 一致(comp-box / comp-area / send-group / comp-foot),
|
|
2
2
|
// 但只发文本:不收图片和其他附件。文件只收文档(Word、Excel、Markdown、TXT、HTML、CSV),在前端解析成纯文本,
|
|
3
3
|
// 和长粘贴一样变成可折叠的文本块,发送时按统一格式拼进正文(见 core/blocks.ts)。
|
|
4
|
-
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
5
5
|
import {
|
|
6
6
|
ACCEPT_ATTR, ACCEPTED_LABEL, MAX_MESSAGE_CHARS, PASTE_NAME, charCount, composeMessage, isLongText, readFileText, type TextBlock,
|
|
7
|
-
} from "../core/blocks
|
|
8
|
-
import { Icon } from "../ui/icons
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
7
|
+
} from "../core/blocks";
|
|
8
|
+
import { Icon } from "../ui/icons";
|
|
9
|
+
import { clientId } from "../core/id";
|
|
10
|
+
import { TextBlockCard } from "../ui/TextBlock";
|
|
11
|
+
import { fmtCount } from "../ui/format";
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
const loadDraft = (key: string) => { try { return localStorage.getItem(draftKey(key)) ?? ""; } catch { return ""; } };
|
|
14
|
-
const saveDraft = (key: string, value: string) => {
|
|
15
|
-
try {
|
|
16
|
-
if (value) localStorage.setItem(draftKey(key), value);
|
|
17
|
-
else localStorage.removeItem(draftKey(key));
|
|
18
|
-
} catch { /* 隐私模式 / 配额:草稿就不保存 */ }
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
type Pending = { key: string; name: string; status: "reading" | "error"; error?: string };
|
|
13
|
+
import { clearComposerDraft, readComposerDraft, updateComposerDraft, useComposerDraft, type PendingFile } from "./draft";
|
|
22
14
|
|
|
23
15
|
/** 输入框的可配置项(来自 CliChat 的 config.composer)。 */
|
|
24
16
|
export interface ComposerOptions {
|
|
@@ -30,7 +22,7 @@ export interface ComposerOptions {
|
|
|
30
22
|
queue: boolean;
|
|
31
23
|
}
|
|
32
24
|
|
|
33
|
-
export function Composer({ draft, placeholder, disabled, disabledHint, running, onSend, onStop, taskControl, options }: {
|
|
25
|
+
export function Composer({ draft, placeholder, disabled, disabledHint, running, onSend, onStop, taskControl, options, toolbar }: {
|
|
34
26
|
/** 草稿按这个键存在 localStorage(一般是对话 id)。 */
|
|
35
27
|
draft: string;
|
|
36
28
|
placeholder: string;
|
|
@@ -41,38 +33,41 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
41
33
|
onStop?: () => Promise<void> | void;
|
|
42
34
|
taskControl?: { paused: boolean; onToggle: () => Promise<void> };
|
|
43
35
|
options: ComposerOptions;
|
|
36
|
+
toolbar?: ReactNode;
|
|
44
37
|
}) {
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const [
|
|
38
|
+
const state = useComposerDraft(draft);
|
|
39
|
+
const { text: val, blocks, pending, error, sending } = state;
|
|
40
|
+
const setVal = (text: string) => updateComposerDraft(draft, s => ({ ...s, text }));
|
|
41
|
+
const setBlocks = (fn: (blocks: typeof state.blocks) => typeof state.blocks) => updateComposerDraft(draft, s => ({ ...s, blocks: fn(s.blocks) }));
|
|
42
|
+
const setPending = (fn: (pending: PendingFile[]) => PendingFile[]) => updateComposerDraft(draft, s => ({ ...s, pending: fn(s.pending) }));
|
|
43
|
+
const setError = (error: string | null) => updateComposerDraft(draft, s => ({ ...s, error }));
|
|
44
|
+
const setSending = (sending: boolean) => updateComposerDraft(draft, s => ({ ...s, sending }));
|
|
50
45
|
const [stopping, setStopping] = useState(false);
|
|
51
46
|
const [controlling, setControlling] = useState(false);
|
|
52
47
|
const [dragging, setDragging] = useState(false);
|
|
53
48
|
const dragDepth = useRef(0);
|
|
54
|
-
const
|
|
49
|
+
const stopLock = useRef(false);
|
|
50
|
+
const controlLock = useRef(false);
|
|
51
|
+
const composing = useRef(false);
|
|
55
52
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
56
53
|
const ref = useRef<HTMLTextAreaElement>(null);
|
|
57
54
|
|
|
58
|
-
useEffect(() => { if (!running) setStopping(false); }, [running]);
|
|
59
|
-
useEffect(() => { saveDraft(draft, val); }, [draft, val]);
|
|
55
|
+
useEffect(() => { if (!running) { stopLock.current = false; setStopping(false); } }, [running]);
|
|
60
56
|
const autosize = (el: HTMLTextAreaElement) => { el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 160) + "px"; };
|
|
61
57
|
useEffect(() => { if (ref.current) autosize(ref.current); }, [draft, disabled]);
|
|
62
58
|
|
|
63
|
-
const nextKey = () => `blk-${
|
|
59
|
+
const nextKey = () => `blk-${clientId()}`;
|
|
64
60
|
const addBlock = (block: TextBlock) => setBlocks((prev) => [...prev, { ...block, key: nextKey() }]);
|
|
65
61
|
|
|
66
62
|
const addFiles = (files: File[]) => {
|
|
67
|
-
if (disabled || !options.fileUpload) return;
|
|
63
|
+
if (disabled || sending || !options.fileUpload) return;
|
|
68
64
|
for (const file of files) {
|
|
69
65
|
const key = nextKey();
|
|
70
66
|
setPending((prev) => [...prev, { key, name: file.name || "文件", status: "reading" }]);
|
|
71
67
|
readFileText(file)
|
|
72
68
|
.then((text) => {
|
|
73
|
-
setPending((prev) => prev.filter((p) => p.key !== key));
|
|
74
69
|
if (!text.trim()) throw new Error(`「${file.name}」里没有可读的文字`);
|
|
75
|
-
|
|
70
|
+
updateComposerDraft(draft, s => ({ ...s, pending: s.pending.filter(p => p.key !== key), blocks: [...s.blocks, { key, name: file.name, text, kind: "file" }] }));
|
|
76
71
|
})
|
|
77
72
|
.catch((err: unknown) => {
|
|
78
73
|
setPending((prev) => prev.map((p) => (p.key === key ? { ...p, status: "error", error: err instanceof Error ? err.message : String(err) } : p)));
|
|
@@ -85,20 +80,19 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
85
80
|
const tooLong = total > MAX_MESSAGE_CHARS;
|
|
86
81
|
const reading = pending.some((p) => p.status === "reading");
|
|
87
82
|
// 运行中不允许排队时,等这一轮结束再发。
|
|
88
|
-
const canSend = !!message.trim() && !reading && !tooLong && !sending && (!running || options.queue);
|
|
83
|
+
const canSend = !!message.trim() && !pending.length && !reading && !tooLong && !sending && (!running || options.queue);
|
|
89
84
|
|
|
90
85
|
const submit = async () => {
|
|
91
|
-
if (!canSend || disabled) return;
|
|
86
|
+
if (!canSend || disabled || readComposerDraft(draft).sending) return;
|
|
87
|
+
const submitted = JSON.stringify([val, blocks]);
|
|
92
88
|
setSending(true);
|
|
93
89
|
setError(null);
|
|
94
90
|
const key = draft;
|
|
95
91
|
try {
|
|
96
92
|
await onSend(message);
|
|
97
93
|
// 新建对话时发送完成前组件就换成了新对话的输入框(key 变了),这里直接清掉发出时那份草稿。
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
setBlocks([]);
|
|
101
|
-
setPending([]);
|
|
94
|
+
const current = readComposerDraft(key);
|
|
95
|
+
if (JSON.stringify([current.text, current.blocks]) === submitted) clearComposerDraft(key);
|
|
102
96
|
if (ref.current) ref.current.style.height = "auto";
|
|
103
97
|
} catch (err) {
|
|
104
98
|
// 发送失败:内容留在输入框里,可以直接再发。
|
|
@@ -111,7 +105,11 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
111
105
|
const isFileDrag = (e: React.DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files");
|
|
112
106
|
const hasFileDrag = (e: React.DragEvent) => options.fileUpload && isFileDrag(e);
|
|
113
107
|
const stopButton = running && onStop && (
|
|
114
|
-
<button type="button" className="send stop" onClick={() => {
|
|
108
|
+
<button type="button" className="send stop" onClick={async () => {
|
|
109
|
+
if (stopLock.current) return;
|
|
110
|
+
stopLock.current = true; setStopping(true); setError(null);
|
|
111
|
+
try { await onStop(); } catch (error) { stopLock.current = false; setStopping(false); setError(error instanceof Error ? error.message : "停止失败,请重试"); }
|
|
112
|
+
}}
|
|
115
113
|
disabled={stopping} aria-label={stopping ? "正在停止" : "停止当前轮次"} title={stopping ? "正在停止…" : "停止当前轮次"}>{Icon.stop()}</button>
|
|
116
114
|
);
|
|
117
115
|
|
|
@@ -119,7 +117,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
119
117
|
<div className="composer">
|
|
120
118
|
<div className="composer-inner">
|
|
121
119
|
{(error || tooLong) && (
|
|
122
|
-
<div className="cli-comp-err">⚠ {tooLong ? `内容共 ${fmtCount(total)} 字,超过单条消息上限 ${fmtCount(MAX_MESSAGE_CHARS)} 字,请删减或分几条发送` : error}</div>
|
|
120
|
+
<div className="cli-comp-err" role="alert">⚠ {tooLong ? `内容共 ${fmtCount(total)} 字,超过单条消息上限 ${fmtCount(MAX_MESSAGE_CHARS)} 字,请删减或分几条发送` : error}</div>
|
|
123
121
|
)}
|
|
124
122
|
<div
|
|
125
123
|
className={`comp-box${dragging ? " dropping" : ""}`}
|
|
@@ -151,10 +149,11 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
151
149
|
)}
|
|
152
150
|
<div className="comp-area">
|
|
153
151
|
<textarea
|
|
154
|
-
ref={ref} rows={2} value={val} disabled={disabled} aria-label="消息内容"
|
|
152
|
+
ref={ref} rows={2} value={val} disabled={disabled || sending} aria-label="消息内容"
|
|
155
153
|
placeholder={disabled ? disabledHint ?? "暂时不能发送" : taskControl?.paused ? "任务已暂停,可补充消息,继续后依次执行…" : running && options.queue ? options.placeholderRunning : placeholder}
|
|
156
154
|
onChange={(e) => { setVal(e.target.value); autosize(e.target); }}
|
|
157
|
-
|
|
155
|
+
onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }}
|
|
156
|
+
onKeyDown={(e) => { if (e.nativeEvent.isComposing || composing.current || e.keyCode === 229) return; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); } }}
|
|
158
157
|
onPaste={(e) => {
|
|
159
158
|
const files = Array.from(e.clipboardData?.files ?? []);
|
|
160
159
|
if (files.length) {
|
|
@@ -176,9 +175,10 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
176
175
|
<div className="comp-foot">
|
|
177
176
|
{options.fileUpload && !disabled && <>
|
|
178
177
|
<input ref={fileRef} type="file" accept={ACCEPT_ATTR} multiple hidden onChange={(e) => { addFiles(Array.from(e.target.files ?? [])); e.target.value = ""; }} />
|
|
179
|
-
<button type="button" className="cli-attach" title={`添加文档:${ACCEPTED_LABEL}(也可拖拽进来),内容会转成文字发送`}
|
|
180
|
-
onClick={() => fileRef.current?.click()}>{Icon.
|
|
178
|
+
<button type="button" className="cli-attach" disabled={sending} aria-label="添加文档" title={`添加文档:${ACCEPTED_LABEL}(也可拖拽进来),内容会转成文字发送`}
|
|
179
|
+
onClick={() => fileRef.current?.click()}>{Icon.plus()} <span>{options.fileButtonText}</span></button>
|
|
181
180
|
</>}
|
|
181
|
+
{toolbar}
|
|
182
182
|
{options.hints && !disabled && <span className="hint comp-hint-extra">Shift + Enter 换行</span>}
|
|
183
183
|
<span className="spacer" />
|
|
184
184
|
{options.charCount && total > 0 && <span className={`hint cli-count ${tooLong ? "over" : ""}`}>{fmtCount(total)} 字</span>}
|
|
@@ -187,7 +187,7 @@ export function Composer({ draft, placeholder, disabled, disabledHint, running,
|
|
|
187
187
|
{taskControl && <button type="button" className="btn btn-line cli-task-control" disabled={controlling}
|
|
188
188
|
aria-label={taskControl.paused ? "继续任务" : "暂停任务"}
|
|
189
189
|
title={taskControl.paused ? "重新派发当前子任务,排队消息随后执行" : "暂停当前子任务,继续时重新派发"}
|
|
190
|
-
onClick={async () => { setControlling(true); try { await taskControl.onToggle(); } catch {
|
|
190
|
+
onClick={async () => { if (controlLock.current) return; controlLock.current = true; setControlling(true); try { await taskControl.onToggle(); } catch (error) { setError(error instanceof Error ? error.message : "执行控制失败,请重试"); } finally { controlLock.current = false; setControlling(false); } }}>
|
|
191
191
|
{taskControl.paused ? Icon.play() : Icon.pause()}<span>{controlling ? "处理中…" : taskControl.paused ? "继续" : "暂停"}</span>
|
|
192
192
|
</button>}
|
|
193
193
|
{(!running || options.queue) && <button type="button" className={`send cli-send${running ? " queue" : ""}`} disabled={disabled || !canSend}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Fragment, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { Icon } from "../ui/icons";
|
|
5
|
+
import { Popover } from "../ui/Popover";
|
|
6
|
+
import { modelLabel } from "../ui/format";
|
|
7
|
+
import { acquireConfiguration, useConfigurationBusy } from "./configuration";
|
|
8
|
+
export interface RunOption { value: string; label: string; sub: string; group?: string }
|
|
9
|
+
export interface RunCapabilities { models: RunOption[]; reasoningOptions: RunOption[]; permissionModes: RunOption[]; supportsCustomModels?: boolean }
|
|
10
|
+
export interface RunTask { id: string; agent?: string; model?: string | null; reasoning?: string | null; permission_mode?: string | null; resolved_model?: string | null }
|
|
11
|
+
const withDefault = (options?: RunOption[]) => [{ value: null as string | null, label: "默认", sub: "沿用智能体的默认设置", group: undefined as string | undefined }, ...(options ?? [])];
|
|
12
|
+
const modelOptions = (caps?: RunCapabilities) => withDefault(caps?.models);
|
|
13
|
+
const permissionOptions = (caps?: RunCapabilities) => withDefault(caps?.permissionModes);
|
|
14
|
+
const reasoningOptions = (caps?: RunCapabilities) => withDefault(caps?.reasoningOptions);
|
|
15
|
+
|
|
16
|
+
export type RunControlProps = {
|
|
17
|
+
task: RunTask;
|
|
18
|
+
agents?: {id:string;label:string;disabled?:boolean}[];
|
|
19
|
+
onSetAgent?: (id:string)=>Promise<void>;
|
|
20
|
+
caps?: RunCapabilities;
|
|
21
|
+
defaults?: { model?: string | null; reasoning?: string | null; permission_mode?: string | null };
|
|
22
|
+
disabled: boolean;
|
|
23
|
+
onSetModel: (value: string | null) => Promise<void>;
|
|
24
|
+
onSetReasoning: (value: string | null) => Promise<void>;
|
|
25
|
+
onSetPermission: (value: string | null) => Promise<void>;
|
|
26
|
+
onBusyChange?: (busy: boolean) => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Both the pre-start view and composer use the same capability-driven controls.
|
|
30
|
+
// Await persistence before allowing Send so the next turn uses the chosen value.
|
|
31
|
+
export function RunControls({ task, agents, onSetAgent, caps, defaults, disabled, onSetModel, onSetReasoning, onSetPermission, onBusyChange }: RunControlProps) {
|
|
32
|
+
const [open, setOpen] = useState<"model" | "permission" | null>(null);
|
|
33
|
+
const [custom, setCustom] = useState("");
|
|
34
|
+
const busy = useConfigurationBusy(task.id);
|
|
35
|
+
const currentTask = useRef(task.id); currentTask.current = task.id;
|
|
36
|
+
useEffect(() => { setOpen(null); setError(""); }, [task.id]);
|
|
37
|
+
const [error, setError] = useState("");
|
|
38
|
+
const models = modelOptions(caps);
|
|
39
|
+
const reasoning = reasoningOptions(caps);
|
|
40
|
+
const permissions = permissionOptions(caps);
|
|
41
|
+
const effectiveModel = task.model ?? defaults?.model ?? null;
|
|
42
|
+
const effectiveReasoning = task.reasoning ?? defaults?.reasoning ?? null;
|
|
43
|
+
const effectivePermission = task.permission_mode ?? defaults?.permission_mode ?? caps?.permissionModes[0]?.value ?? null;
|
|
44
|
+
const model = models.find((m) => m.value === effectiveModel)?.label ?? modelLabel(effectiveModel ?? "");
|
|
45
|
+
const permission = permissions.find((p) => p.value === effectivePermission);
|
|
46
|
+
const change = async (save: () => Promise<void>) => {
|
|
47
|
+
if (disabled) return;
|
|
48
|
+
const release = acquireConfiguration(task.id);
|
|
49
|
+
if (!release) return;
|
|
50
|
+
const targetId = task.id;
|
|
51
|
+
setError("");
|
|
52
|
+
try { onBusyChange?.(true); await save(); if(currentTask.current === targetId) setOpen(null); }
|
|
53
|
+
catch (e) { if(currentTask.current === targetId) setError(e instanceof Error ? e.message : "设置保存失败,请重试。"); }
|
|
54
|
+
finally { release(); onBusyChange?.(false); }
|
|
55
|
+
};
|
|
56
|
+
const locked = disabled || busy || !caps;
|
|
57
|
+
return <>
|
|
58
|
+
{!!caps?.permissionModes.length && <div className="comp-control">
|
|
59
|
+
<button className={`comp-setting permission${effectivePermission === "bypassPermissions" ? " elevated" : ""}`} disabled={locked} aria-label="权限模式" aria-expanded={open === "permission"} aria-haspopup="dialog" title={disabled ? "本轮结束后可修改权限" : permission?.sub} onClick={(e) => { e.stopPropagation(); setOpen(open === "permission" ? null : "permission"); }}>
|
|
60
|
+
{Icon.lock()} <span>{permission?.value ? permission.label : "默认权限"}</span>{Icon.chevDown()}
|
|
61
|
+
</button>
|
|
62
|
+
{open === "permission" && <Popover onClose={() => setOpen(null)}>
|
|
63
|
+
<div className="pop-sec">权限模式 · 保存后用于下一轮</div>
|
|
64
|
+
{permissions.map((p) => <button key={p.value ?? "default"} className="pop-item" disabled={locked} aria-pressed={(task.permission_mode ?? null) === p.value} onClick={() => void change(() => onSetPermission(p.value))}>
|
|
65
|
+
<span><span>{p.label}</span><span className="pi-sub">{p.sub}</span></span>{(task.permission_mode ?? null) === p.value && Icon.check()}
|
|
66
|
+
</button>)}
|
|
67
|
+
{error && <div className="control-error" role="alert">{error}</div>}
|
|
68
|
+
</Popover>}
|
|
69
|
+
</div>}
|
|
70
|
+
<div className="comp-control comp-model">
|
|
71
|
+
<button className="comp-setting" disabled={locked} aria-label="模型与推理设置" aria-expanded={open === "model"} aria-haspopup="dialog" title={disabled ? "本轮结束后可修改模型与推理" : `模型:${model}${task.resolved_model ? ` · 上次运行:${task.resolved_model}` : ""}`} onClick={(e) => { e.stopPropagation(); setOpen(open === "model" ? null : "model"); }}>
|
|
72
|
+
<span className="comp-model-name">{model === "默认" ? "默认模型" : model}</span>
|
|
73
|
+
{!!caps?.reasoningOptions.length && <span className="comp-reasoning">{reasoning.find((r) => r.value === effectiveReasoning)?.label ?? "默认"}</span>}{Icon.chevDown()}
|
|
74
|
+
</button>
|
|
75
|
+
{open === "model" && <Popover onClose={() => setOpen(null)}>
|
|
76
|
+
{agents && agents.length>1 && onSetAgent && <><div className="pop-sec">智能体 · 接手当前任务的历史</div>{agents.map(a=><button key={a.id} className="pop-item" disabled={locked||a.disabled} aria-pressed={task.agent===a.id} onClick={()=>void change(()=>onSetAgent(a.id))}>{a.label}{task.agent===a.id&&Icon.check()}</button>)}<div className="divider"/></>}
|
|
77
|
+
<div className="pop-sec">模型 · 保存后用于下一轮</div>
|
|
78
|
+
{models.map((m, i) => <Fragment key={m.value ?? "default"}>
|
|
79
|
+
{m.group && m.group !== models[i - 1]?.group && <div className="pop-sec">{m.group}</div>}
|
|
80
|
+
<button className="pop-item" disabled={locked} aria-pressed={(task.model ?? null) === m.value} onClick={() => void change(() => onSetModel(m.value))}>
|
|
81
|
+
<span><span>{m.label}</span><span className="pi-sub">{m.sub}</span></span>{(task.model ?? null) === m.value && Icon.check()}
|
|
82
|
+
</button>
|
|
83
|
+
</Fragment>)}
|
|
84
|
+
{caps?.supportsCustomModels && <form className="pop-custom-model" onSubmit={(e) => { e.preventDefault(); if (custom.trim()) void change(() => onSetModel(custom.trim())); }}>
|
|
85
|
+
<label className="pop-sec" htmlFor={`model-${task.id}`}>自定义模型</label>
|
|
86
|
+
<div className="pop-custom-row"><input id={`model-${task.id}`} value={custom} onChange={(e) => setCustom(e.target.value)} placeholder="模型 ID" disabled={locked} /><button className="btn btn-sm" disabled={locked || !custom.trim()}>使用</button></div>
|
|
87
|
+
</form>}
|
|
88
|
+
{!!caps?.reasoningOptions.length && <>
|
|
89
|
+
<div className="divider" /><div className="pop-sec">推理强度</div>
|
|
90
|
+
{reasoning.map((r) => <button key={r.value ?? "default"} className="pop-item" disabled={locked} aria-pressed={(task.reasoning ?? null) === r.value} onClick={() => void change(() => onSetReasoning(r.value))}>
|
|
91
|
+
<span><span>{r.label}</span><span className="pi-sub">{r.sub}</span></span>{(task.reasoning ?? null) === r.value && Icon.check()}
|
|
92
|
+
</button>)}
|
|
93
|
+
</>}
|
|
94
|
+
{busy && <div className="pop-sec" role="status">正在保存…</div>}
|
|
95
|
+
{error && <div className="control-error" role="alert">{error}</div>}
|
|
96
|
+
</Popover>}
|
|
97
|
+
</div>
|
|
98
|
+
</>;
|
|
99
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
// A task's setting request outlives its controls when the user changes tasks.
|
|
3
|
+
const pending = new Set<string>();
|
|
4
|
+
const listeners = new Set<() => void>();
|
|
5
|
+
const notify = () => listeners.forEach(listener => listener());
|
|
6
|
+
export const isConfigurationBusy = (taskId:string) => pending.has(taskId);
|
|
7
|
+
export function acquireConfiguration(taskId:string): (()=>void) | null {
|
|
8
|
+
if (pending.has(taskId)) return null;
|
|
9
|
+
pending.add(taskId); notify();
|
|
10
|
+
let active=true;
|
|
11
|
+
return () => { if(active){active=false;pending.delete(taskId);notify();} };
|
|
12
|
+
}
|
|
13
|
+
export function useConfigurationBusy(taskId:string) {
|
|
14
|
+
return useSyncExternalStore(listener=>{listeners.add(listener);return()=>listeners.delete(listener);},()=>isConfigurationBusy(taskId),()=>false);
|
|
15
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import type { TextBlock } from "../core/blocks";
|
|
3
|
+
export type PendingFile = { key: string; name: string; status: "reading" | "error"; error?: string };
|
|
4
|
+
export interface ComposerDraft { text: string; blocks: Array<TextBlock & { key: string }>; pending: PendingFile[]; sending: boolean; error: string | null; revision: number }
|
|
5
|
+
const empty = (): ComposerDraft => ({ text: "", blocks: [], pending: [], sending: false, error: null, revision: 0 });
|
|
6
|
+
const server = empty();
|
|
7
|
+
const entries = new Map<string, { state: ComposerDraft; stamp: string }>();
|
|
8
|
+
const listeners = new Set<() => void>();
|
|
9
|
+
const textKey = (id: string) => `cli-ui:draft:${id}`;
|
|
10
|
+
const dataKey = (id: string) => `${textKey(id)}:data`;
|
|
11
|
+
const stamp = (id: string) => { try { return JSON.stringify([localStorage.getItem(textKey(id)), localStorage.getItem(dataKey(id))]); } catch { return ""; } };
|
|
12
|
+
export function readComposerDraft(id: string): ComposerDraft {
|
|
13
|
+
const stored = stamp(id), cached = entries.get(id);
|
|
14
|
+
if (cached?.stamp === stored) return cached.state;
|
|
15
|
+
let state = empty();
|
|
16
|
+
try {
|
|
17
|
+
state.text = localStorage.getItem(textKey(id)) ?? "";
|
|
18
|
+
const data = JSON.parse(localStorage.getItem(dataKey(id)) ?? "null");
|
|
19
|
+
if (data?.version === 1) {
|
|
20
|
+
state.blocks = Array.isArray(data.blocks) ? data.blocks.filter((b: TextBlock & { key: string }) => typeof b?.text === "string" && typeof b?.key === "string") : [];
|
|
21
|
+
state.pending = Array.isArray(data.pending) ? data.pending.map((p: PendingFile) => ({ ...p, status: "error", error: p.error || "页面重载中断了解析,请移除后重新添加" })) : [];
|
|
22
|
+
}
|
|
23
|
+
} catch { /* Old text drafts and restricted storage remain usable. */ }
|
|
24
|
+
entries.set(id, { state, stamp: stored });
|
|
25
|
+
return state;
|
|
26
|
+
}
|
|
27
|
+
export function updateComposerDraft(id: string, update: (state: ComposerDraft) => ComposerDraft) {
|
|
28
|
+
const previous = readComposerDraft(id);
|
|
29
|
+
const state = { ...update(previous), revision: previous.revision + 1 };
|
|
30
|
+
try {
|
|
31
|
+
if (state.text) localStorage.setItem(textKey(id), state.text); else localStorage.removeItem(textKey(id));
|
|
32
|
+
if (state.blocks.length || state.pending.length) localStorage.setItem(dataKey(id), JSON.stringify({ version: 1, blocks: state.blocks, pending: state.pending })); else localStorage.removeItem(dataKey(id));
|
|
33
|
+
} catch { /* Keep the in-memory draft even if storage is full. */ }
|
|
34
|
+
entries.set(id, { state, stamp: stamp(id) });
|
|
35
|
+
listeners.forEach(listener => listener());
|
|
36
|
+
return state;
|
|
37
|
+
}
|
|
38
|
+
export function useComposerDraft(id: string) {
|
|
39
|
+
return useSyncExternalStore(listener => { listeners.add(listener); return () => listeners.delete(listener); }, () => readComposerDraft(id), () => server);
|
|
40
|
+
}
|
|
41
|
+
export function clearComposerDraft(id: string) { updateComposerDraft(id, () => empty()); }
|
package/src/chat/export.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { CenterTurn, Msg } from "../core/types
|
|
2
|
-
import { toolDataOf } from "../ui/transcriptModel
|
|
1
|
+
import type { CenterTurn, Msg } from "../core/types";
|
|
2
|
+
import { toolDataOf } from "../ui/transcriptModel";
|
|
3
3
|
|
|
4
4
|
/** 把对话过程导出成 Markdown 下载(对应 Operator 的「导出执行过程」,这里在前端用已加载的转录生成)。 */
|
|
5
5
|
export function buildMarkdown(title: string, jobId: string, messages: Msg[], turns: CenterTurn[]): string {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import { CliMsgList as CenterMsgList, type CliMsgListProps as CenterMsgListProps } from "./msglist/CliMsgList";
|
|
3
|
+
import { CliChat as CenterChat, type CliChatProps as CenterChatProps } from "./chat/CliChat";
|
|
4
|
+
import { CliBoard as CenterBoard, type CliBoardProps as CenterBoardProps } from "./board/CliBoard";
|
|
5
|
+
import { TasksColumn } from "./operator/node/TasksColumn";
|
|
6
|
+
import { SessionView } from "./operator/node/SessionView";
|
|
7
|
+
import { SessionRail } from "./operator/node/SessionRail";
|
|
8
|
+
export interface CliMsgListProps extends CenterMsgListProps { operator?: ComponentProps<typeof TasksColumn> }
|
|
9
|
+
export interface CliChatProps extends CenterChatProps { operator?: ComponentProps<typeof SessionView> }
|
|
10
|
+
export interface CliBoardProps extends CenterBoardProps { operator?: ComponentProps<typeof SessionRail> }
|
|
11
|
+
export function CliMsgList(props: CliMsgListProps) { return props.operator ? <TasksColumn {...props.operator}/> : <CenterMsgList {...props}/>; }
|
|
12
|
+
export function CliChat(props: CliChatProps) { return props.operator ? <SessionView {...props.operator} board={{...props.operator.board,autoContext:props.operator.board?.autoContext??props.passiveContext,onFilesChange:props.operator.board?.onFilesChange??props.onFilesChange,onFilesWatch:props.operator.board?.onFilesWatch??props.onFilesWatch,onBoardContextChange:props.operator.board?.onBoardContextChange??props.onBoardContextChange}}/> : <CenterChat {...props}/>; }
|
|
13
|
+
export function CliBoard(props: CliBoardProps) { return props.operator ? <SessionRail key={props.operator.task.id} {...props.operator} tabs={props.operator.tabs ?? props.tabs} availableTabs={props.operator.availableTabs ?? props.availableTabs} defaultTab={props.operator.defaultTab ?? props.defaultTab} autoContext={props.operator.autoContext ?? props.autoQuoteOpenFile} watchFiles={props.operator.watchFiles ?? props.watchFiles} fileWatchIntervalMs={props.operator.fileWatchIntervalMs ?? props.fileWatchIntervalMs} onFilesChange={props.operator.onFilesChange ?? props.onFilesChange} onFilesWatch={props.operator.onFilesWatch ?? props.onFilesWatch} onBoardContextChange={props.operator.onBoardContextChange ?? props.onBoardContextChange} onEvent={props.operator.onEvent ?? props.onEvent}/> : <CenterBoard {...props}/>; }
|
package/src/core/blocks.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* 对 Agent 来说这也是清楚的分隔:哪段是附件、叫什么、在哪结束。
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { decodeText } from "./textDecode
|
|
15
|
+
import { decodeText } from "./textDecode";
|
|
16
16
|
|
|
17
17
|
export interface TextBlock {
|
|
18
18
|
/** 显示名:文件名,粘贴的文字为「粘贴的文本」。 */
|
|
@@ -142,7 +142,7 @@ export async function readFileText(file: File): Promise<string> {
|
|
|
142
142
|
if (SAVE_AS[ext]) throw new FileReadError(`不支持这种格式:${SAVE_AS[ext]}`);
|
|
143
143
|
if (!(ACCEPTED_EXTENSIONS as readonly string[]).includes(ext)) throw new FileReadError(`「${file.name}」不是支持的文档,只能上传 ${ACCEPTED_LABEL}`);
|
|
144
144
|
if (ext === ".xlsx" || ext === ".xlsm") {
|
|
145
|
-
const { readXlsx, sheetsToText } = await import("./xlsx
|
|
145
|
+
const { readXlsx, sheetsToText } = await import("./xlsx");
|
|
146
146
|
try {
|
|
147
147
|
return normalizeText(sheetsToText(await readXlsx(await readAs(file, "buffer"))));
|
|
148
148
|
} catch {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** A host subscriber must not interrupt state updates or another subscriber. */
|
|
2
|
+
export function notifyCallback<T>(callback: ((value: T) => unknown) | undefined, value: T): void {
|
|
3
|
+
const report = (error: unknown) => console.error("[@spzxg/cli-ui] subscriber callback failed", error);
|
|
4
|
+
try {
|
|
5
|
+
const result = callback?.(value);
|
|
6
|
+
if (result && typeof (result as PromiseLike<unknown>).then === "function") void Promise.resolve(result).catch(report);
|
|
7
|
+
} catch (error) { report(error); }
|
|
8
|
+
}
|
package/src/core/client.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { createSseParser } from "./sse
|
|
2
|
-
import { NEW_TASK, parseConvKey } from "./conversation
|
|
1
|
+
import { createSseParser } from "./sse";
|
|
2
|
+
import { NEW_TASK, parseConvKey } from "./conversation";
|
|
3
3
|
import type {
|
|
4
4
|
BoardInfo, BrowserMessage, BrowserStatus, CenterJob, CenterTask, CenterTurn, DiffResult, DirListing, FileInfo, Message,
|
|
5
5
|
BatchStreamSnapshot, BrowserFramesState, NewConversationOptions, SendResult, StreamEnd, StreamItem, StreamSnapshot,
|
|
6
|
-
} from "./types
|
|
7
|
-
import { decodeText } from "./textDecode
|
|
6
|
+
} from "./types";
|
|
7
|
+
import { decodeText } from "./textDecode";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* center 服务(cli-task-center)对话任务接口的浏览器端客户端。组件的数据全部从这里来。
|
|
@@ -141,6 +141,8 @@ export interface BoardBackend {
|
|
|
141
141
|
/** fileUrl 给出的地址浏览器能直接打开(不需要额外请求头)。false 时看板先 fetch 再显示。 */
|
|
142
142
|
directLinks: boolean;
|
|
143
143
|
info(jobId: string): Promise<BoardInfo>;
|
|
144
|
+
/** 精准文件事件流;没有时组件明确降级为目录快照比对。 */
|
|
145
|
+
watchFiles?(jobId: string, handlers: { onEvent: (event: import("./types").WorkspaceFileBatch) => void; onClose: (error: CenterError | null) => void }): () => void;
|
|
144
146
|
/** 目录(缺省根目录)或文件的信息。 */
|
|
145
147
|
stat(jobId: string, path?: string): Promise<DirListing | FileInfo>;
|
|
146
148
|
/** 文件地址(给 <img> / <iframe> / 下载链接用)。相对路径用路径式地址,HTML 里的相对资源能跟着解析。 */
|
|
@@ -171,7 +173,7 @@ export interface BoardBackend {
|
|
|
171
173
|
browserStatus(jobId: string): Promise<BrowserStatus>;
|
|
172
174
|
browserAction(jobId: string, action: "open" | "close" | "takeover" | "handback"): Promise<BrowserStatus>;
|
|
173
175
|
/** 浏览器状态实时流;返回停止函数。onClose 在断开时调用(error 为 null 表示正常结束)。 */
|
|
174
|
-
browserEvents(jobId: string, handlers: { onStatus: (status: BrowserStatus) => void; onClose?: (error: CenterError | null) => void }): () => void;
|
|
176
|
+
browserEvents(jobId: string, handlers: { onStatus: (status: BrowserStatus) => void; onClose?: (error: CenterError | null) => void; /** Recoverable transport disruption; subscription remains active. */ onError?: (error: CenterError) => void }): () => void;
|
|
175
177
|
/** 浏览器实时画面;onViewer 给出发输入要用的 viewer id。 */
|
|
176
178
|
browserFrames(jobId: string, handlers: { onViewer: (viewer: string) => void; onMessage: (message: BrowserMessage) => void; onEnd?: (end: { reason: string; detail?: string }) => void; onClose?: (error: CenterError | null) => void }): () => void;
|
|
177
179
|
browserInput(jobId: string, viewer: string, events: Array<Record<string, unknown>>): Promise<void>;
|
|
@@ -546,7 +548,7 @@ function createBoardClient(
|
|
|
546
548
|
if (ctl.signal.aborted) reject(new DOMException("aborted", "AbortError"));
|
|
547
549
|
ctl.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
|
|
548
550
|
});
|
|
549
|
-
response = await Promise.race([doFetch(boardFileUrl(base, jobId, path), { headers: headers(extra), signal: ctl.signal }), stopped]);
|
|
551
|
+
response = await Promise.race([doFetch(boardFileUrl(base, jobId, path), { headers: headers(extra), signal: ctl.signal, cache: "no-store" }), stopped]);
|
|
550
552
|
} catch (error) {
|
|
551
553
|
release();
|
|
552
554
|
if (signal?.aborted) throw aborted();
|
package/src/core/conversation.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* · 节点转录(transcript + node 事件):Agent 实际的对话过程,规则与 Operator 的 useTaskStream 一致。
|
|
7
7
|
* 界面以节点转录为主;转录还没有(没派发、节点连不上)时用轮次的 input / reply 兜底。
|
|
8
8
|
*/
|
|
9
|
-
import { isBatchSnapshot, type AskAnswers, type AskQuestion, type BatchStreamSnapshot, type BatchStreamTask, type CenterTurn, type ConversationClosedCallback, type ConversationTurnCallback, type JobStatus, type Message, type Msg, type NodeEvent, type StreamItem, type StreamSnapshot, type StreamTask, type ToolData, type TurnUsage } from "./types
|
|
10
|
-
import { indexFromEnd, mergeSnapshot, prependOlder, toolDataOf } from "../ui/transcriptModel
|
|
11
|
-
import { formatAnswersText } from "../ui/format
|
|
9
|
+
import { isBatchSnapshot, type AskAnswers, type AskQuestion, type BatchStreamSnapshot, type BatchStreamTask, type CenterTurn, type ConversationClosedCallback, type ConversationTurnCallback, type JobStatus, type Message, type Msg, type NodeEvent, type StreamItem, type StreamSnapshot, type StreamTask, type ToolData, type TurnUsage } from "./types";
|
|
10
|
+
import { indexFromEnd, mergeSnapshot, prependOlder, toolDataOf } from "../ui/transcriptModel";
|
|
11
|
+
import { formatAnswersText } from "../ui/format";
|
|
12
12
|
|
|
13
13
|
export const TERMINAL_TURN = new Set(["succeeded", "failed", "cancelled", "merged"]);
|
|
14
14
|
export const ACTIVE_TURN = new Set(["queued", "dispatching", "running"]);
|
package/src/core/id.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Correlation/draft IDs only: never use this fallback as a credential or token. */
|
|
2
|
+
let sequence = 0;
|
|
3
|
+
export function clientId(): string {
|
|
4
|
+
try { if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID(); } catch { /* Fall back in restricted or insecure contexts. */ }
|
|
5
|
+
try {
|
|
6
|
+
if (typeof globalThis.crypto?.getRandomValues === 'function') {
|
|
7
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
8
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
9
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
10
|
+
const hex = Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
|
|
11
|
+
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
|
|
12
|
+
}
|
|
13
|
+
} catch { /* Drafts still work when Web Crypto is unavailable. */ }
|
|
14
|
+
return `${Date.now().toString(36)}-${(++sequence).toString(36)}-${Math.random().toString(36).slice(2,10)}`;
|
|
15
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CenterClientOptions } from './client';
|
|
2
|
+
import { clientId } from './id';
|
|
3
|
+
// A synchronous SHA-256 fingerprint partitions persistent drafts without putting
|
|
4
|
+
// credentials in localStorage keys. It is never used for authentication.
|
|
5
|
+
const K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
|
|
6
|
+
const rotate = (x:number,n:number)=>(x>>>n)|(x<<(32-n));
|
|
7
|
+
export function connectionFingerprint(text:string):string {
|
|
8
|
+
const raw=new TextEncoder().encode(text),data=new Uint8Array(Math.ceil((raw.length+9)/64)*64);data.set(raw);data[raw.length]=128;
|
|
9
|
+
const view=new DataView(data.buffer);view.setUint32(data.length-8,Math.floor(raw.length/0x20000000));view.setUint32(data.length-4,(raw.length*8)>>>0);
|
|
10
|
+
const hash=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19],w=new Int32Array(64);
|
|
11
|
+
for(let offset=0;offset<data.length;offset+=64){
|
|
12
|
+
for(let i=0;i<16;i++)w[i]=view.getInt32(offset+i*4);
|
|
13
|
+
for(let i=16;i<64;i++){const x=w[i-15],y=w[i-2];w[i]=(w[i-16]+(rotate(x,7)^rotate(x,18)^(x>>>3))+w[i-7]+(rotate(y,17)^rotate(y,19)^(y>>>10)))|0;}
|
|
14
|
+
let [a,b,c,d,e,f,g,h]=hash;
|
|
15
|
+
for(let i=0;i<64;i++){const t1=(h+(rotate(e,6)^rotate(e,11)^rotate(e,25))+((e&f)^(~e&g))+K[i]+w[i])|0,t2=((rotate(a,2)^rotate(a,13)^rotate(a,22))+((a&b)^(a&c)^(b&c)))|0;
|
|
16
|
+
h=g;g=f;f=e;e=(d+t1)|0;d=c;c=b;b=a;a=(t1+t2)|0;}
|
|
17
|
+
[a,b,c,d,e,f,g,h].forEach((value,i)=>{hash[i]=(hash[i]+value)|0;});
|
|
18
|
+
}
|
|
19
|
+
return hash.map(value=>(value>>>0).toString(16).padStart(8,'0')).join('');
|
|
20
|
+
}
|
|
21
|
+
const customScopes=new WeakMap<object,string>();
|
|
22
|
+
export function connectionDraftScope(options:CenterClientOptions&{namespace?:string|null;client?:object}):string {
|
|
23
|
+
// Custom transports may hide cookies or credentials. Isolate by object identity
|
|
24
|
+
// rather than pretending their URL proves the account identity.
|
|
25
|
+
const headers = options.headers?.() ?? {};
|
|
26
|
+
const signature=JSON.stringify([options.baseUrl?.replace(/\/+$/,'')??'',options.token??'',options.line??'',options.namespace??'',Object.keys(headers).sort().map(key=>[key,headers[key]])]);
|
|
27
|
+
const transport=options.client;
|
|
28
|
+
if(transport){let scope=customScopes.get(transport);if(!scope){scope=clientId();customScopes.set(transport,scope);}return `custom-${scope}:${connectionFingerprint(signature)}`;}
|
|
29
|
+
// Preserve the historical default same-origin draft keys. Cookie-auth hosts
|
|
30
|
+
// must supply an account-specific namespace and remount after account changes.
|
|
31
|
+
if(!options.baseUrl&&!options.token&&!options.line&&!options.namespace&&!Object.keys(headers).length)return '';
|
|
32
|
+
return connectionFingerprint(signature);
|
|
33
|
+
}
|
package/src/core/provider.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
1
|
+
import { notifyCallback } from "./callbacks";
|
|
2
|
+
import { Fragment, createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
|
|
3
|
+
import { connectionDraftScope } from "./identity";
|
|
4
|
+
import { createCliStore, cloneWorkspaceEvent, type CliEvent, type CliState, type CliStore, type CliStoreOptions } from "./store";
|
|
5
|
+
import type { ConversationCallback, ConversationEndPayload, TurnEndPayload } from "./types";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* 积木的拼接点。放在同一个 <CliCenterProvider> 里的组件共享一个仓库,切换时一起变:
|
|
@@ -36,7 +38,12 @@ export interface CliCenterProviderProps extends CliStoreOptions, ResultHandlers
|
|
|
36
38
|
* 结果通知:组件从实时流里看到一轮结束 / 对话结束时调用,全在浏览器里发生,**和 center 的 callback.url 无关**——
|
|
37
39
|
* 不配 callback 也照样会调。载荷字段与 center 回调体一致,方便业务后端和前端用同一套代码解析。
|
|
38
40
|
*/
|
|
39
|
-
export interface
|
|
41
|
+
export interface WorkspaceHandlers {
|
|
42
|
+
onFilesChange?: (event: import("./types").WorkspaceChangeEvent) => void;
|
|
43
|
+
onFilesWatch?: (event: import("./types").WorkspaceWatchStatus) => void;
|
|
44
|
+
onBoardContextChange?: (event: Extract<CliEvent,{type:"board_context"}>) => void;
|
|
45
|
+
}
|
|
46
|
+
export interface ResultHandlers extends WorkspaceHandlers {
|
|
40
47
|
/** 一轮结束(成功 / 失败 / 取消)。 */
|
|
41
48
|
onTurnEnd?: (payload: TurnEndPayload) => void;
|
|
42
49
|
/** 对话结束(关闭 / 取消 / 过期……)。 */
|
|
@@ -46,11 +53,14 @@ export interface ResultHandlers {
|
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
export function dispatchResult(event: CliEvent, handlers: ResultHandlers) {
|
|
49
|
-
if (event.type === "conversation_turn") { handlers.onTurnEnd
|
|
50
|
-
else if (event.type === "conversation_closed") { handlers.onConversationEnd
|
|
56
|
+
if (event.type === "conversation_turn") { notifyCallback(handlers.onTurnEnd, event.payload); notifyCallback(handlers.onCallback, event.payload); }
|
|
57
|
+
else if (event.type === "conversation_closed") { notifyCallback(handlers.onConversationEnd, event.payload); notifyCallback(handlers.onCallback, event.payload); }
|
|
58
|
+
else if(event.type === "files_changed") notifyCallback(handlers.onFilesChange,event);
|
|
59
|
+
else if(event.type === "files_watch") notifyCallback(handlers.onFilesWatch,event);
|
|
60
|
+
else if(event.type === "board_context") notifyCallback(handlers.onBoardContextChange,event);
|
|
51
61
|
}
|
|
52
62
|
|
|
53
|
-
export function CliCenterProvider({ store: external, selectedId, onEvent, onTurnEnd, onConversationEnd, onCallback, children, ...options }: CliCenterProviderProps) {
|
|
63
|
+
export function CliCenterProvider({ store: external, selectedId, onEvent, onTurnEnd, onConversationEnd, onCallback, onFilesChange, onFilesWatch, onBoardContextChange, children, ...options }: CliCenterProviderProps) {
|
|
54
64
|
// 连接参数只在建仓库时用一次:中途换 baseUrl / token 请换 key 重建。
|
|
55
65
|
const optionsRef = useRef(options);
|
|
56
66
|
const applied = useRef<string | null | undefined>(undefined);
|
|
@@ -69,12 +79,12 @@ export function CliCenterProvider({ store: external, selectedId, onEvent, onTurn
|
|
|
69
79
|
const onEventRef = useRef(onEvent);
|
|
70
80
|
onEventRef.current = onEvent;
|
|
71
81
|
const resultRef = useRef<ResultHandlers>({});
|
|
72
|
-
resultRef.current = { onTurnEnd, onConversationEnd, onCallback };
|
|
82
|
+
resultRef.current = { onTurnEnd, onConversationEnd, onCallback, onFilesChange, onFilesWatch, onBoardContextChange };
|
|
73
83
|
useEffect(() => store.on((event) => {
|
|
74
|
-
onEventRef.current
|
|
84
|
+
notifyCallback(onEventRef.current, cloneWorkspaceEvent(event));
|
|
75
85
|
dispatchResult(event, resultRef.current);
|
|
76
86
|
}), [store]);
|
|
77
|
-
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
|
|
87
|
+
return <StoreContext.Provider value={store}><Fragment key={store.draftScope}>{children}</Fragment></StoreContext.Provider>;
|
|
78
88
|
}
|
|
79
89
|
|
|
80
90
|
export function useCliStoreContext(): CliStore | null {
|
|
@@ -96,11 +106,11 @@ export function useCliState<T>(selector: (state: CliState) => T): T {
|
|
|
96
106
|
/** 组件外面没有 Provider 时,用组件自己的 props 包一层。 */
|
|
97
107
|
export function withOwnProvider<P extends object>(props: P & CliStoreOptions & ResultHandlers & { onEvent?: (event: CliEvent) => void; store?: CliStore }, inner: ReactNode, hasContext: boolean) {
|
|
98
108
|
if (hasContext) return inner;
|
|
99
|
-
const { baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, store } = props;
|
|
109
|
+
const { baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, onFilesChange, onFilesWatch, onBoardContextChange, store } = props;
|
|
100
110
|
// 连接参数变了(换了中枢地址、令牌、产品线、命名空间)就换一个新仓库重连,不沿用旧连接。
|
|
101
|
-
const connection =
|
|
111
|
+
const connection = connectionDraftScope({baseUrl,token,line,namespace,headers,fetch,client});
|
|
102
112
|
return (
|
|
103
|
-
<CliCenterProvider key={store ? "external" : connection} {...{ baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, store }}>
|
|
113
|
+
<CliCenterProvider key={store ? "external" : connection} {...{ baseUrl, token, line, headers, fetch, client, namespace, newConversation, createConversation, businessIdFor, onEvent, onTurnEnd, onConversationEnd, onCallback, onFilesChange, onFilesWatch, onBoardContextChange, store }}>
|
|
104
114
|
{inner}
|
|
105
115
|
</CliCenterProvider>
|
|
106
116
|
);
|
|
@@ -117,7 +127,7 @@ export function useComponentEvents(onEvent: ((event: CliEvent) => void) | undefi
|
|
|
117
127
|
useEffect(() => {
|
|
118
128
|
if (!enabled || !store) return;
|
|
119
129
|
return store.on((event) => {
|
|
120
|
-
ref.current.onEvent
|
|
130
|
+
notifyCallback(ref.current.onEvent, cloneWorkspaceEvent(event));
|
|
121
131
|
dispatchResult(event, ref.current.results);
|
|
122
132
|
});
|
|
123
133
|
}, [store, enabled]);
|