@spzxg/cli-ui 0.6.5 → 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 +55 -0
- package/README.md +109 -30
- package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
- package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-BmwOLWFI.js} +2 -2
- package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
- package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
- package/dist/{cli-ui-xlsx-zs7oEtPj.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 +11 -8
- package/src/board/BrowserPane.tsx +198 -53
- package/src/board/CliBoard.tsx +128 -100
- package/src/board/FileViewer.tsx +276 -136
- 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 -70
- package/src/board/folderDownload.ts +39 -5
- package/src/board/frameQuote.ts +12 -10
- package/src/board/htmlReading.ts +148 -0
- package/src/board/textLoad.ts +18 -41
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +53 -11
- package/src/chat/CliChat.tsx +105 -55
- package/src/chat/Composer.tsx +55 -21
- 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 +17 -5
- package/src/core/client.ts +88 -19
- package/src/core/conversation.ts +1 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/provider.tsx +7 -0
- package/src/core/store.ts +134 -36
- package/src/core/types.ts +31 -10
- package/src/index.ts +3 -0
- package/src/layout/split.ts +67 -30
- package/src/msglist/CliMsgList.tsx +18 -5
- 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 +28 -11
- package/src/operator/node/boardBackend.ts +6 -23
- 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/protocol/types.ts +2 -2
- package/src/operator/node/useTaskStream.ts +8 -5
- package/src/operator.base.css +148 -37
- package/src/operator.css +148 -37
- package/src/styles.css +274 -32
- package/src/ui/Markdown.tsx +2 -2
- package/src/ui/Popover.tsx +32 -14
- 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 +27 -12
- 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-BmLN2Jjg.js +0 -43290
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import type { Msg } from "../core/types";
|
|
3
|
+
import { useCliState, useCliStore } from "../core/provider";
|
|
4
|
+
import { boardContextBlock, charCount, MAX_MESSAGE_CHARS, serializeBlock } from "../core/blocks";
|
|
5
|
+
import { MessageView } from "../ui/Transcript";
|
|
6
|
+
import { ReferenceCard } from "../ui/ReferenceCard";
|
|
7
|
+
import { useChatScrollbar } from "../ui/useChatScrollbar";
|
|
8
|
+
import { Icon } from "../ui/icons";
|
|
9
|
+
import { FileLinkContext, FileOpenContext } from "../ui/fileLinks";
|
|
10
|
+
import { Composer, type ComposerOptions } from "./Composer";
|
|
11
|
+
|
|
12
|
+
export interface GroupChatReference { id: string; label: string; text?: string }
|
|
13
|
+
export interface GroupChatMessage {
|
|
14
|
+
id: string;
|
|
15
|
+
role: "user" | "assistant" | "system";
|
|
16
|
+
content: string;
|
|
17
|
+
createdAt?: string;
|
|
18
|
+
actor?: { id: string; name: string };
|
|
19
|
+
recipientLabel?: string;
|
|
20
|
+
status?: string;
|
|
21
|
+
error?: string | null;
|
|
22
|
+
references?: GroupChatReference[];
|
|
23
|
+
}
|
|
24
|
+
/** 群消息由宿主提供;所有输入始终经过同一个 onSend,不按发言人建立独立发送通道。 */
|
|
25
|
+
export interface GroupChatAdapter {
|
|
26
|
+
/** 包含业务与用户身份的稳定键,隔离草稿和引用。 */
|
|
27
|
+
scopeKey: string;
|
|
28
|
+
messages: GroupChatMessage[];
|
|
29
|
+
onSend: (text: string) => Promise<void>;
|
|
30
|
+
userName?: string;
|
|
31
|
+
placeholder?: string;
|
|
32
|
+
toolbar?: ReactNode;
|
|
33
|
+
beforeMessages?: ReactNode;
|
|
34
|
+
onSource?: (reference: GroupChatReference, message: GroupChatMessage) => void;
|
|
35
|
+
/** 本地文件沿用共享 Markdown 解析;宿主按实际发言人的授权工作区打开。 */
|
|
36
|
+
onOpenFile?: (path: string, message: GroupChatMessage) => void;
|
|
37
|
+
/** 仅消费宿主明确授权给此群的看板引用键(如当前 Job 的成员 job#task)。 */
|
|
38
|
+
referenceKeys?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const OPTIONS: ComposerOptions = { placeholderRunning: "继续补充…", hints: false, charCount: false, fileUpload: false, fileButtonText: "文件", queue: true };
|
|
42
|
+
type Quote = { id: string; author: string; text: string };
|
|
43
|
+
|
|
44
|
+
/** CliChat 的群消息适配;沿用共享消息、输入框和引用组件,不连接单任务转录。 */
|
|
45
|
+
export function GroupChat({ group, theme = "light", className, style, readOnly }: {
|
|
46
|
+
group: GroupChatAdapter; theme?: "light" | "dark"; className?: string; style?: CSSProperties; readOnly?: boolean;
|
|
47
|
+
}) {
|
|
48
|
+
const [quote, setQuote] = useState<Quote | null>(null);
|
|
49
|
+
const [visibleCount, setVisibleCount] = useState(120);
|
|
50
|
+
const store = useCliStore();
|
|
51
|
+
const references = useCliState(state => state.refs);
|
|
52
|
+
const referenceKeys = [...new Set(group.referenceKeys ?? [])];
|
|
53
|
+
const boardRefs = referenceKeys.flatMap(key => (references[key] ?? []).map(ref => ({ key, ref })));
|
|
54
|
+
const scroll = useRef<HTMLDivElement>(null);
|
|
55
|
+
const pinned = useRef(true);
|
|
56
|
+
useChatScrollbar(scroll, group.scopeKey);
|
|
57
|
+
useLayoutEffect(() => {
|
|
58
|
+
if (pinned.current && scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight;
|
|
59
|
+
}, [group.messages]);
|
|
60
|
+
const send = async (text: string) => {
|
|
61
|
+
const submittedQuote = quote;
|
|
62
|
+
const message = submittedQuote
|
|
63
|
+
? `引用 ${submittedQuote.author}:\n${submittedQuote.text.split("\n").map(line => "> " + line).join("\n")}\n\n${text}` : text;
|
|
64
|
+
const taken = referenceKeys.map(key => ({ key, refs: store.takeRefs(key) }));
|
|
65
|
+
const refs = taken.flatMap(item => item.refs);
|
|
66
|
+
const content = refs.length ? `${message}\n\n${serializeBlock(boardContextBlock(refs.map(ref => ref.text)))}` : message;
|
|
67
|
+
try {
|
|
68
|
+
if (charCount(content) > MAX_MESSAGE_CHARS) throw new Error("加上引用后消息过长,请精简消息内容后重试。");
|
|
69
|
+
await group.onSend(content);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
for (const item of taken) store.restoreRefs(item.key, item.refs);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
setQuote(current => current === submittedQuote ? null : current);
|
|
75
|
+
pinned.current = true;
|
|
76
|
+
if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight;
|
|
77
|
+
};
|
|
78
|
+
return <div className={`cli-ui cli-chat cli-group-chat ${className ?? ""}`} data-theme={theme} style={style}>
|
|
79
|
+
<div className="session"><div className="workspace-chat-content" data-chat-layout="centered">
|
|
80
|
+
<div className="transcript-wrap"><div className="transcript" ref={scroll} role="log" aria-label="群组对话" aria-live="polite" onScroll={() => {
|
|
81
|
+
const element = scroll.current;
|
|
82
|
+
if (element) pinned.current = element.scrollHeight - element.scrollTop - element.clientHeight < 48;
|
|
83
|
+
}}><div className="tw">
|
|
84
|
+
{group.beforeMessages}
|
|
85
|
+
{group.messages.length > visibleCount && <div className="load-older"><button type="button" className="btn btn-ghost btn-sm" onClick={() => { pinned.current = false; setVisibleCount(value => value + 80); }}>查看更早的消息</button></div>}
|
|
86
|
+
{group.messages.slice(-visibleCount).map(message => {
|
|
87
|
+
const name = message.actor?.name || (message.role === "user" ? group.userName || "你" : "智能体");
|
|
88
|
+
const ts = message.createdAt ? Date.parse(message.createdAt) : NaN;
|
|
89
|
+
const msg: Msg = { id: message.id, role: message.role, content: message.content, generation: 0, ...(Number.isFinite(ts) ? { ts } : {}) };
|
|
90
|
+
return <div key={message.id} data-msg="" data-message-id={message.id} data-actor-id={message.actor?.id}>
|
|
91
|
+
{message.recipientLabel && <div className="cli-group-meta">{message.recipientLabel}</div>}
|
|
92
|
+
<FileLinkContext.Provider value={null}><FileOpenContext.Provider value={message.role === "assistant" && message.actor && group.onOpenFile
|
|
93
|
+
? path => { group.onOpenFile?.(path, message); return true; } : null}>
|
|
94
|
+
<MessageView m={msg} initial={false} hideWho={false} view={{ timestamps: true, initialBadge: false, userLabel: name, agentName: name }} handlers={{
|
|
95
|
+
onQuote: readOnly ? undefined : (value, selected) => setQuote({ id: value.id, author: name, text: selected || value.content }),
|
|
96
|
+
}} />
|
|
97
|
+
</FileOpenContext.Provider></FileLinkContext.Provider>
|
|
98
|
+
{message.status && <div className="cli-group-meta">{message.status}</div>}
|
|
99
|
+
{message.error && <div className="cli-comp-err" role="alert">{message.error}</div>}
|
|
100
|
+
{!!message.references?.length && <div className="cli-group-references">{message.references.map(reference => group.onSource
|
|
101
|
+
? <button key={reference.id} type="button" className="btn btn-ghost btn-sm" onClick={() => group.onSource?.(reference, message)}>{Icon.doc()}{reference.label}</button>
|
|
102
|
+
: <ReferenceCard key={reference.id} title={reference.label} text={reference.text || "引用内容未提供"} />)}</div>}
|
|
103
|
+
</div>;
|
|
104
|
+
})}
|
|
105
|
+
</div></div></div>
|
|
106
|
+
{!readOnly && <Composer draft={`group:${group.scopeKey}`} placeholder={group.placeholder || "发送消息…"} disabled={false} running={false} onSend={send} options={OPTIONS} toolbar={group.toolbar}
|
|
107
|
+
context={(quote || boardRefs.length > 0) && <div className="cli-refs composer-references">
|
|
108
|
+
{quote && <ReferenceCard title={quote.author} text={quote.text} removeLabel="取消引用" onRemove={() => setQuote(null)} />}
|
|
109
|
+
{boardRefs.map(({ key, ref }) => <ReferenceCard key={`${key}:${ref.id}`} title={ref.label} text={ref.text} removeLabel={`不带上 ${ref.label}`} onRemove={() => store.removeRef(key, ref.id)} />)}
|
|
110
|
+
</div>} />}
|
|
111
|
+
</div></div>
|
|
112
|
+
</div>;
|
|
113
|
+
}
|
package/src/chat/RunControls.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { Icon } from "../ui/icons";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { AgentMark, Icon } from "../ui/icons";
|
|
5
5
|
import { Popover } from "../ui/Popover";
|
|
6
6
|
import { modelLabel } from "../ui/format";
|
|
7
7
|
import { acquireConfiguration, useConfigurationBusy } from "./configuration";
|
|
@@ -11,7 +11,8 @@ export interface RunTask { id: string; agent?: string; model?: string | null; re
|
|
|
11
11
|
const withDefault = (options?: RunOption[]) => [{ value: null as string | null, label: "默认", sub: "沿用智能体的默认设置", group: undefined as string | undefined }, ...(options ?? [])];
|
|
12
12
|
const modelOptions = (caps?: RunCapabilities) => withDefault(caps?.models);
|
|
13
13
|
const permissionOptions = (caps?: RunCapabilities) => withDefault(caps?.permissionModes);
|
|
14
|
-
const
|
|
14
|
+
const agentNames = [{ id: "codex", label: "Codex" }, { id: "claude", label: "Claude" }, { id: "grok", label: "Grok" }, { id: "pi", label: "PI" }];
|
|
15
|
+
const permissionIcon = (value: string | null) => value === null ? Icon.clear() : value === "bypassPermissions" ? Icon.bolt() : value === "plan" ? Icon.list() : value === "acceptEdits" ? Icon.edit() : Icon.lock();
|
|
15
16
|
|
|
16
17
|
export type RunControlProps = {
|
|
17
18
|
task: RunTask;
|
|
@@ -30,68 +31,157 @@ export type RunControlProps = {
|
|
|
30
31
|
// Await persistence before allowing Send so the next turn uses the chosen value.
|
|
31
32
|
export function RunControls({ task, agents, onSetAgent, caps, defaults, disabled, onSetModel, onSetReasoning, onSetPermission, onBusyChange }: RunControlProps) {
|
|
32
33
|
const [open, setOpen] = useState<"model" | "permission" | null>(null);
|
|
34
|
+
const [modelPage, setModelPage] = useState<"agents" | "settings" | "models">("agents");
|
|
35
|
+
const [switching, setSwitching] = useState<{ agent: string; saved: boolean } | null>(null);
|
|
33
36
|
const [custom, setCustom] = useState("");
|
|
34
37
|
const busy = useConfigurationBusy(task.id);
|
|
35
|
-
const currentTask = useRef(task.id
|
|
36
|
-
|
|
38
|
+
const currentTask = useRef({ id: task.id, revision: 0 });
|
|
39
|
+
if (currentTask.current.id !== task.id) currentTask.current = { id: task.id, revision: currentTask.current.revision + 1 };
|
|
40
|
+
const agentListRef = useRef<HTMLDivElement>(null);
|
|
41
|
+
const modelEntryRef = useRef<HTMLButtonElement>(null);
|
|
42
|
+
const modelListRef = useRef<HTMLDivElement>(null);
|
|
43
|
+
useEffect(() => { setOpen(null); setError(""); setCustom(""); setSwitching(null); setModelPage("agents"); }, [task.id]);
|
|
37
44
|
const [error, setError] = useState("");
|
|
45
|
+
const availableAgents = agentNames.flatMap(agent => {
|
|
46
|
+
const available = agents?.find(item => item.id === agent.id);
|
|
47
|
+
return available ? [{ ...available, label: agent.label }] : [];
|
|
48
|
+
});
|
|
49
|
+
const hasAgentPicker = !!onSetAgent && availableAgents.length > 0;
|
|
50
|
+
const currentAgent = agentNames.find(agent => agent.id === task.agent)?.label ?? agents?.find(agent => agent.id === task.agent)?.label ?? task.agent ?? "智能体";
|
|
38
51
|
const models = modelOptions(caps);
|
|
39
|
-
const reasoning = reasoningOptions(caps);
|
|
40
52
|
const permissions = permissionOptions(caps);
|
|
41
53
|
const effectiveModel = task.model ?? defaults?.model ?? null;
|
|
42
|
-
const
|
|
54
|
+
const currentModel = effectiveModel ? models.find(option => option.value === effectiveModel)?.label ?? modelLabel(effectiveModel) : "默认模型";
|
|
43
55
|
const effectivePermission = task.permission_mode ?? defaults?.permission_mode ?? caps?.permissionModes[0]?.value ?? null;
|
|
44
|
-
const
|
|
56
|
+
const defaultModel = defaults?.model ? models.find(option => option.value === defaults.model)?.label ?? modelLabel(defaults.model) : null;
|
|
57
|
+
const defaultReasoningOption = caps?.reasoningOptions.find(option => option.value === defaults?.reasoning);
|
|
58
|
+
const defaultReasoning = defaultReasoningOption?.label ?? defaults?.reasoning ?? null;
|
|
59
|
+
const currentReasoning = caps?.reasoningOptions.find(option => option.value === task.reasoning);
|
|
60
|
+
const reasoningLabel = task.reasoning ? currentReasoning?.label ?? task.reasoning : defaultReasoning ? `默认(${defaultReasoning})` : "默认";
|
|
61
|
+
const reasoningDescription = task.reasoning ? currentReasoning?.sub ?? "当前任务的推理设置" : defaultReasoningOption?.sub ?? "沿用智能体的默认设置";
|
|
62
|
+
const defaultPermission = defaults?.permission_mode ?? caps?.permissionModes[0]?.value ?? null;
|
|
63
|
+
const defaultPermissionLabel = permissions.find(option => option.value === defaultPermission)?.label ?? defaultPermission;
|
|
45
64
|
const permission = permissions.find((p) => p.value === effectivePermission);
|
|
46
|
-
const change = async (save: () => Promise<void
|
|
47
|
-
if (disabled) return;
|
|
65
|
+
const change = async (save: () => Promise<void>, close = true) => {
|
|
66
|
+
if (disabled) return false;
|
|
48
67
|
const release = acquireConfiguration(task.id);
|
|
49
|
-
if (!release) return;
|
|
50
|
-
const
|
|
68
|
+
if (!release) return false;
|
|
69
|
+
const targetRevision = currentTask.current.revision;
|
|
51
70
|
setError("");
|
|
52
|
-
try {
|
|
53
|
-
|
|
71
|
+
try {
|
|
72
|
+
onBusyChange?.(true);
|
|
73
|
+
await save();
|
|
74
|
+
if (currentTask.current.revision !== targetRevision) return false;
|
|
75
|
+
if (close) setOpen(null);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch (e) { if(currentTask.current.revision === targetRevision) setError(e instanceof Error ? e.message : "设置保存失败,请重试。"); return false; }
|
|
54
79
|
finally { release(); onBusyChange?.(false); }
|
|
55
80
|
};
|
|
56
|
-
const locked = disabled || busy || !caps;
|
|
81
|
+
const locked = disabled || busy || !!switching || !caps;
|
|
82
|
+
const pickAgent = async (agent: string) => {
|
|
83
|
+
if (locked || !onSetAgent) return;
|
|
84
|
+
setError(""); setCustom("");
|
|
85
|
+
if (agent === task.agent) { setModelPage("settings"); return; }
|
|
86
|
+
const targetRevision = currentTask.current.revision;
|
|
87
|
+
setSwitching({ agent, saved: false });
|
|
88
|
+
const saved = await change(() => onSetAgent(agent), false);
|
|
89
|
+
if (currentTask.current.revision === targetRevision) setSwitching(saved ? { agent, saved: true } : null);
|
|
90
|
+
};
|
|
91
|
+
// SessionView derives caps from task.agent in the same render. Wait for that
|
|
92
|
+
// task update as well as the save, so the old agent's models never appear.
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (switching?.saved && task.agent === switching.agent && caps) {
|
|
95
|
+
setSwitching(null); setModelPage("settings");
|
|
96
|
+
}
|
|
97
|
+
}, [switching, task.agent, caps]);
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (open !== "model" || switching || busy) return;
|
|
100
|
+
if (modelPage === "settings") modelEntryRef.current?.focus({ preventScroll: true });
|
|
101
|
+
else if (modelPage === "models") (modelListRef.current?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]:not(:disabled)') ?? modelListRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)'))?.focus({ preventScroll: true });
|
|
102
|
+
else (agentListRef.current?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]:not(:disabled)') ?? agentListRef.current?.querySelector<HTMLButtonElement>('button:not(:disabled)'))?.focus({ preventScroll: true });
|
|
103
|
+
}, [open, modelPage, switching, busy]);
|
|
104
|
+
const modelGroups = models.reduce<{ label?: string; options: typeof models }[]>((groups, option) => {
|
|
105
|
+
const previous = groups.at(-1);
|
|
106
|
+
if (previous && previous.label === option.group) previous.options.push(option);
|
|
107
|
+
else groups.push({ label: option.group, options: [option] });
|
|
108
|
+
return groups;
|
|
109
|
+
}, []);
|
|
57
110
|
return <>
|
|
58
111
|
{!!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"); }}>
|
|
112
|
+
<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(); setError(""); setOpen(open === "permission" ? null : "permission"); }}>
|
|
60
113
|
{Icon.lock()} <span>{permission?.value ? permission.label : "默认权限"}</span>{Icon.chevDown()}
|
|
61
114
|
</button>
|
|
62
|
-
{open === "permission" && <Popover onClose={() => setOpen(null)}>
|
|
63
|
-
<div className="
|
|
64
|
-
|
|
65
|
-
|
|
115
|
+
{open === "permission" && <Popover onClose={() => setOpen(null)} className="composer-popover composer-permission-popover" ariaLabel="权限模式" placement="top" align="start">
|
|
116
|
+
<div className="composer-menu-heading">权限模式</div>
|
|
117
|
+
<p className="composer-menu-note">保存后用于下一轮</p>
|
|
118
|
+
{permissions.map((p) => <button key={p.value ?? "default"} className="pop-item composer-option run-permission-option" disabled={locked} aria-pressed={(task.permission_mode ?? null) === p.value} onClick={() => void change(() => onSetPermission(p.value))}>
|
|
119
|
+
<span className="composer-option-icon" aria-hidden="true">{permissionIcon(p.value)}</span>
|
|
120
|
+
<span className="composer-option-copy"><span className="composer-option-title">{p.label}</span><span className="composer-option-sub">{p.value === null && defaultPermissionLabel ? `沿用智能体默认设置:${defaultPermissionLabel}` : p.sub}</span></span>
|
|
121
|
+
<span className="composer-option-trailing" aria-hidden="true">{(task.permission_mode ?? null) === p.value && Icon.check()}</span>
|
|
66
122
|
</button>)}
|
|
123
|
+
{busy && <div className="composer-menu-note" role="status">正在保存…</div>}
|
|
67
124
|
{error && <div className="control-error" role="alert">{error}</div>}
|
|
68
125
|
</Popover>}
|
|
69
126
|
</div>}
|
|
70
127
|
<div className="comp-control comp-model">
|
|
71
|
-
<button className="comp-setting" disabled={locked} aria-label="
|
|
72
|
-
<span className="comp-model-name">{
|
|
73
|
-
{!!caps?.reasoningOptions.length && <span className="comp-reasoning">{reasoning.find((r) => r.value === effectiveReasoning)?.label ?? "默认"}</span>}{Icon.chevDown()}
|
|
128
|
+
<button className="comp-setting" disabled={locked} aria-label="智能体设置" aria-expanded={open === "model"} aria-haspopup="dialog" title={disabled ? "本轮结束后可修改智能体设置" : `当前智能体:${currentAgent}`} onClick={(e) => { e.stopPropagation(); setError(""); setCustom(""); setModelPage(hasAgentPicker ? "agents" : "settings"); setOpen(open === "model" ? null : "model"); }}>
|
|
129
|
+
<span className="comp-model-name">{currentAgent}</span>{Icon.chevDown()}
|
|
74
130
|
</button>
|
|
75
|
-
{open === "model" && <Popover onClose={() => setOpen(null)}>
|
|
76
|
-
{
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
<span
|
|
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()}
|
|
131
|
+
{open === "model" && <Popover onClose={() => setOpen(null)} className="composer-popover composer-agent-popover" ariaLabel={modelPage === "agents" ? "选择智能体" : modelPage === "models" ? "选择模型" : "模型与推理设置"} placement="top" align="end">
|
|
132
|
+
{modelPage === "agents" ? <div ref={agentListRef} className="run-agent-list">
|
|
133
|
+
<div className="composer-menu-heading">选择智能体</div>
|
|
134
|
+
{availableAgents.map(agent => <button key={agent.id} className="pop-item composer-option run-agent-option" disabled={locked || agent.disabled} aria-pressed={task.agent === agent.id} onClick={() => void pickAgent(agent.id)}>
|
|
135
|
+
<span className="composer-option-icon" aria-hidden="true">{AgentMark[agent.id]?.() ?? Icon.spark()}</span>
|
|
136
|
+
<span className="composer-option-copy"><span className="composer-option-title">{agent.label}</span></span>
|
|
137
|
+
<span className="composer-option-trailing run-agent-trailing" aria-hidden="true">{task.agent === agent.id && Icon.check()}{Icon.chevRight()}</span>
|
|
92
138
|
</button>)}
|
|
93
|
-
|
|
94
|
-
|
|
139
|
+
</div> : !switching && modelPage === "settings" ? <div className="run-settings">
|
|
140
|
+
<div className="run-settings-head">
|
|
141
|
+
{hasAgentPicker && <button className="pop-item run-settings-back" onClick={() => { setModelPage("agents"); setError(""); }}>{Icon.chevRight({ style: { transform: "rotate(180deg)" } })} 智能体</button>}
|
|
142
|
+
<span className="composer-menu-heading">{currentAgent}</span>
|
|
143
|
+
</div>
|
|
144
|
+
<div className="run-settings-field">
|
|
145
|
+
<div className="run-field-label">模型</div>
|
|
146
|
+
<button ref={modelEntryRef} className="composer-option run-model-entry" disabled={locked} aria-label="选择模型" onClick={() => { setModelPage("models"); setError(""); }}>
|
|
147
|
+
<span className="composer-option-icon" aria-hidden="true">{Icon.spark()}</span>
|
|
148
|
+
<span className="composer-option-copy"><span className="composer-option-title run-model-value">{currentModel}</span><span className="composer-option-sub">{task.model ? models.find(option => option.value === task.model)?.sub ?? "当前使用自定义模型" : "沿用智能体的默认设置"}</span></span>
|
|
149
|
+
<span className="composer-option-trailing" aria-hidden="true">{Icon.chevRight()}</span>
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
{!!caps?.reasoningOptions.length && <div className="run-reasoning-card">
|
|
153
|
+
<div className="run-reasoning-head"><span className="run-field-label">推理强度</span><span className="run-reasoning-value">{reasoningLabel}</span></div>
|
|
154
|
+
<p className="run-reasoning-note">{reasoningDescription}</p>
|
|
155
|
+
<div className="run-reasoning-options" role="group" aria-label="推理强度档位">
|
|
156
|
+
{caps.reasoningOptions.map(option => <button key={option.value} className="run-reasoning-option" disabled={locked} aria-pressed={task.reasoning === option.value} title={option.sub} onClick={() => void change(() => onSetReasoning(option.value))}>{option.label}</button>)}
|
|
157
|
+
</div>
|
|
158
|
+
<button className="run-reasoning-reset" disabled={locked} aria-label="使用默认推理强度" aria-pressed={task.reasoning == null} onClick={() => void change(() => onSetReasoning(null))}>{Icon.clear()} {defaultReasoning ? `默认(${defaultReasoning})` : "使用默认设置"}{task.reasoning == null && Icon.check()}</button>
|
|
159
|
+
</div>}
|
|
160
|
+
<p className="composer-menu-note run-settings-note">保存后用于下一轮</p>
|
|
161
|
+
</div> : !switching && modelPage === "models" && <div className="run-settings run-models">
|
|
162
|
+
<div className="run-settings-head">
|
|
163
|
+
<button className="pop-item run-models-back" onClick={() => { setModelPage("settings"); setError(""); }}>{Icon.chevRight({ style: { transform: "rotate(180deg)" } })} 返回</button>
|
|
164
|
+
<span className="composer-menu-heading">选择模型</span>
|
|
165
|
+
</div>
|
|
166
|
+
<div ref={modelListRef} className="run-model-list">
|
|
167
|
+
{modelGroups.map((group, index) => <div className="run-model-group" key={`${group.label ?? "default"}-${index}`}>
|
|
168
|
+
{group.label && <div className="run-model-group-title">{group.label}</div>}
|
|
169
|
+
{group.options.map(option => <button key={option.value ?? "default"} className="pop-item composer-option run-model-option" data-model-value={option.value ?? ""} disabled={locked} aria-pressed={(task.model ?? null) === option.value} onClick={() => void change(() => onSetModel(option.value))}>
|
|
170
|
+
<span className="composer-option-copy"><span className="composer-option-title">{option.value === null && defaultModel ? `默认(${defaultModel})` : option.label}</span><span className="composer-option-sub">{option.value === null && defaultModel ? `沿用智能体默认设置:${defaultModel}` : option.sub}</span></span>
|
|
171
|
+
<span className="composer-option-trailing" aria-hidden="true">{(task.model ?? null) === option.value && Icon.check()}</span>
|
|
172
|
+
</button>)}
|
|
173
|
+
</div>)}
|
|
174
|
+
{task.model && !models.some(option => option.value === task.model) && <button className="pop-item composer-option run-model-option" data-model-value={task.model} disabled={locked} aria-pressed onClick={() => void change(() => onSetModel(task.model!))}>
|
|
175
|
+
<span className="composer-option-copy"><span className="composer-option-title">{modelLabel(task.model)}(当前模型)</span><span className="composer-option-sub">当前任务使用的自定义模型</span></span>
|
|
176
|
+
<span className="composer-option-trailing" aria-hidden="true">{Icon.check()}</span>
|
|
177
|
+
</button>}
|
|
178
|
+
</div>
|
|
179
|
+
{caps?.supportsCustomModels && <form className="pop-custom-model" onSubmit={(e) => { e.preventDefault(); if (custom.trim()) void change(() => onSetModel(custom.trim())); }}>
|
|
180
|
+
<label className="composer-menu-heading" htmlFor={`custom-model-${task.id}`}>自定义模型</label>
|
|
181
|
+
<div className="pop-custom-row"><input id={`custom-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>
|
|
182
|
+
</form>}
|
|
183
|
+
</div>}
|
|
184
|
+
{(busy || switching) && <div className="composer-menu-note" role="status">{switching ? "正在切换智能体…" : "正在保存…"}</div>}
|
|
95
185
|
{error && <div className="control-error" role="alert">{error}</div>}
|
|
96
186
|
</Popover>}
|
|
97
187
|
</div>
|
package/src/chat/draft.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useSyncExternalStore } from "react";
|
|
2
2
|
import type { TextBlock } from "../core/blocks";
|
|
3
|
-
|
|
3
|
+
import type { UploadPhase } from "../core/documentUpload";
|
|
4
|
+
export type PendingFile = { key: string; name: string; status: "reading" | "error"; error?: string; progress?: string; phase?: UploadPhase; bytes?: number; size?: number };
|
|
4
5
|
export interface ComposerDraft { text: string; blocks: Array<TextBlock & { key: string }>; pending: PendingFile[]; sending: boolean; error: string | null; revision: number }
|
|
5
6
|
const empty = (): ComposerDraft => ({ text: "", blocks: [], pending: [], sending: false, error: null, revision: 0 });
|
|
6
7
|
const server = empty();
|
|
@@ -18,7 +19,9 @@ export function readComposerDraft(id: string): ComposerDraft {
|
|
|
18
19
|
const data = JSON.parse(localStorage.getItem(dataKey(id)) ?? "null");
|
|
19
20
|
if (data?.version === 1) {
|
|
20
21
|
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.
|
|
22
|
+
state.pending = Array.isArray(data.pending) ? data.pending.map((p: PendingFile) => ({ ...p, status: "error", error: p.phase
|
|
23
|
+
? `${p.error || "页面重载中断了上传。"}${p.error?.includes("重新选择文件") ? "" : " 请重新选择文件。"}`
|
|
24
|
+
: p.error || "页面重载中断了解析,请移除后重新添加" })) : [];
|
|
22
25
|
}
|
|
23
26
|
} catch { /* Old text drafts and restricted storage remain usable. */ }
|
|
24
27
|
entries.set(id, { state, stamp: stored });
|
package/src/core/blocks.ts
CHANGED
|
@@ -48,16 +48,16 @@ export function backgroundBlock(items: Array<{ title?: string; text: string }>):
|
|
|
48
48
|
return { name: BACKGROUND_NAME, kind: "background", text: `以下是补充的项目背景信息,请在这次及之后的工作中一并参考:\n\n${body}` };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** 支持的文件:只收文档——Word(.docx)、Excel(.xlsx / .xlsm)、Markdown、TXT、HTML、CSV / TSV。都在浏览器里转成文字。 */
|
|
52
|
-
export const ACCEPTED_EXTENSIONS = [".docx", ".xlsx", ".xlsm", ".md", ".markdown", ".txt", ".html", ".htm", ".csv", ".tsv"] as const;
|
|
51
|
+
/** 支持的文件:只收文档——Word(.docx)、Excel(.xlsx / .xlsm)、Markdown、TXT、YAML、HTML、CSV / TSV。都在浏览器里转成文字。 */
|
|
52
|
+
export const ACCEPTED_EXTENSIONS = [".docx", ".xlsx", ".xlsm", ".md", ".markdown", ".txt", ".yaml", ".yml", ".html", ".htm", ".csv", ".tsv"] as const;
|
|
53
53
|
export const ACCEPT_ATTR = [
|
|
54
54
|
...ACCEPTED_EXTENSIONS,
|
|
55
|
-
"text/plain", "text/markdown", "text/html", "text/csv", "text/tab-separated-values",
|
|
55
|
+
"text/plain", "text/markdown", "text/yaml", "application/yaml", "text/html", "text/csv", "text/tab-separated-values",
|
|
56
56
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
57
57
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-excel.sheet.macroEnabled.12",
|
|
58
58
|
].join(",");
|
|
59
59
|
/** 不支持时提示里列的格式。 */
|
|
60
|
-
export const ACCEPTED_LABEL = "Word(.docx)、Excel(.xlsx)、Markdown、TXT、HTML、CSV";
|
|
60
|
+
export const ACCEPTED_LABEL = "Word(.docx)、Excel(.xlsx)、Markdown、TXT、YAML(.yaml / .yml)、HTML、CSV";
|
|
61
61
|
/** 一些常见但读不了的文档:给出另存为什么。 */
|
|
62
62
|
const SAVE_AS: Record<string, string> = {
|
|
63
63
|
".doc": "老版 Word(.doc)请另存为 .docx 后再上传",
|
|
@@ -72,6 +72,8 @@ export const PASTE_BLOCK_CHARS = 2_000;
|
|
|
72
72
|
export const PASTE_BLOCK_LINES = 40;
|
|
73
73
|
/** center 单条消息上限(POST /api/jobs/:id/messages 的 text 最长 200,000 字符)。 */
|
|
74
74
|
export const MAX_MESSAGE_CHARS = 200_000;
|
|
75
|
+
/** Same source-file budget as the full node composer; reject before reading/parsing. */
|
|
76
|
+
export const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
|
|
75
77
|
export const PASTE_NAME = "粘贴的文本";
|
|
76
78
|
|
|
77
79
|
const OPEN = /^----- 附件:(.+?)([\d,]+ 字)-----$/;
|
|
@@ -86,7 +88,8 @@ export function serializeBlock(block: TextBlock): string {
|
|
|
86
88
|
|
|
87
89
|
/** 输入框里的文字 + 文本块 → 发给 center 的正文。 */
|
|
88
90
|
export function composeMessage(typed: string, blocks: TextBlock[]): string {
|
|
89
|
-
|
|
91
|
+
const originals = blocks.some(block => block.kind === "file" && (/^\[Attached (?:file|image): /.test(block.text) || /^\[文件原件:.*\]\(cli-upload:\/\//.test(block.text)));
|
|
92
|
+
return [typed.trim(), originals ? "请先读取以下附件原件,再处理本条消息。" : "", ...blocks.map(serializeBlock)].filter(Boolean).join("\n\n");
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
export type MessagePart = { kind: "text"; text: string } | { kind: "block"; block: TextBlock };
|
|
@@ -138,6 +141,13 @@ export class FileReadError extends Error {}
|
|
|
138
141
|
* 其他文件(图片、PDF、压缩包……)不收;老版 .doc / .xls 浏览器里读不了,提示另存为。
|
|
139
142
|
*/
|
|
140
143
|
export async function readFileText(file: File): Promise<string> {
|
|
144
|
+
if (file.size > MAX_DOCUMENT_BYTES) throw new FileReadError(`“${file.name}”超过 10 MB,请缩小后重试。`);
|
|
145
|
+
const text = await extractFileText(file);
|
|
146
|
+
if (text.length > MAX_MESSAGE_CHARS) throw new FileReadError(`“${file.name}”解析后的正文超过 ${MAX_MESSAGE_CHARS.toLocaleString("en-US")} 字符,请拆分后重试。`);
|
|
147
|
+
return text;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function extractFileText(file: File): Promise<string> {
|
|
141
151
|
const ext = extensionOf(file.name);
|
|
142
152
|
if (SAVE_AS[ext]) throw new FileReadError(`不支持这种格式:${SAVE_AS[ext]}`);
|
|
143
153
|
if (!(ACCEPTED_EXTENSIONS as readonly string[]).includes(ext)) throw new FileReadError(`「${file.name}」不是支持的文档,只能上传 ${ACCEPTED_LABEL}`);
|
|
@@ -165,6 +175,8 @@ export async function readFileText(file: File): Promise<string> {
|
|
|
165
175
|
}
|
|
166
176
|
}
|
|
167
177
|
const { text } = decodeText(new Uint8Array(await readAs(file, "buffer")));
|
|
178
|
+
// YAML literal scalars depend on blank lines, indentation and the final newline.
|
|
179
|
+
if (ext === ".yaml" || ext === ".yml") return text;
|
|
168
180
|
if (ext === ".html" || ext === ".htm") return normalizeText(text.replace(INLINE_DATA, (m) => `${m.slice(0, m.indexOf(",") + 1)}…(内嵌数据已省略)`));
|
|
169
181
|
return normalizeText(text);
|
|
170
182
|
}
|