@xgspz/cli-ui 0.0.0-stage → 0.6.15
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 +165 -0
- package/README.md +517 -2
- package/dist/cli-ui-embed-vIXVNs6k.js +50295 -0
- package/dist/cli-ui-index-R24x28OX.js +11209 -0
- package/dist/cli-ui-jszip.min-DUghzByz.js +2324 -0
- package/dist/cli-ui-jszip.min-DfH6rdQD.js +26 -0
- package/dist/cli-ui-xlsx-CP0lJhff.js +93 -0
- package/dist/cli-ui.css +32 -0
- package/dist/cli-ui.js +17 -0
- package/package.json +60 -4
- package/src/board/BrowserPane.tsx +969 -0
- package/src/board/ChangesPane.tsx +185 -0
- package/src/board/CliBoard.tsx +484 -0
- package/src/board/FileViewer.tsx +721 -0
- package/src/board/FilesPane.tsx +262 -0
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +40 -0
- package/src/board/blocks.tsx +97 -0
- package/src/board/context.ts +65 -0
- package/src/board/diffLines.ts +46 -0
- package/src/board/fileKind.ts +250 -0
- package/src/board/fileOps.ts +85 -0
- package/src/board/fileScope.ts +32 -0
- package/src/board/fileSync.ts +351 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +72 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +211 -0
- package/src/board/folderDownload.ts +211 -0
- package/src/board/frameQuote.ts +52 -0
- package/src/board/htmlReading.ts +148 -0
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +76 -0
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +200 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +960 -0
- package/src/chat/Composer.tsx +239 -0
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +196 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +49 -0
- package/src/chat/export.ts +34 -0
- package/src/chat/queueEdit.ts +27 -0
- package/src/chat/useExecutionConfig.ts +93 -0
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +205 -0
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +822 -0
- package/src/core/config.ts +252 -0
- package/src/core/conversation.ts +581 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/execution.ts +24 -0
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +21 -0
- package/src/core/provider.tsx +141 -0
- package/src/core/sha256.ts +21 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +1109 -0
- package/src/core/subagents.ts +78 -0
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +507 -0
- package/src/core/xlsx.ts +142 -0
- package/src/elements.tsx +376 -0
- package/src/embed.ts +56 -0
- package/src/index.ts +56 -0
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +251 -0
- package/src/msglist/CliMsgList.tsx +422 -0
- 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 +619 -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 +538 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +316 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +192 -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 +248 -0
- package/src/operator/node/Orchestrator.tsx +644 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +559 -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 +299 -0
- package/src/operator/node/SessionView.tsx +292 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +526 -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 +485 -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 +57 -0
- package/src/operator/node/boardBackend.ts +63 -0
- package/src/operator/node/boardEvents.ts +40 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +74 -0
- package/src/operator/node/boardWorkspace.ts +15 -0
- package/src/operator/node/boundedPoll.ts +47 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +78 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +292 -0
- package/src/operator/node/github.tsx +232 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +323 -0
- package/src/operator/node/modals.tsx +766 -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 +335 -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 +305 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +697 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +102 -0
- package/src/operator/node/useTaskStream.ts +286 -0
- package/src/operator.base.css +1952 -0
- package/src/operator.css +1952 -0
- package/src/styles.css +579 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/Markdown.tsx +60 -0
- package/src/ui/Popover.tsx +95 -0
- package/src/ui/QueuedMessages.tsx +110 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/SubagentChat.tsx +233 -0
- package/src/ui/TextBlock.tsx +51 -0
- package/src/ui/Transcript.tsx +435 -0
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/fileLinks.ts +52 -0
- package/src/ui/format.ts +103 -0
- package/src/ui/icons.tsx +92 -0
- package/src/ui/reference.ts +40 -0
- package/src/ui/shared.tsx +38 -0
- package/src/ui/transcriptModel.ts +151 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Agent 消息里指向本地文件的链接([报告](report.md)、/Users/…/a.ts、file:///…、src/a.ts:12)。
|
|
2
|
+
// 识别规则移植自 Operator 的 app/orchestrator/fileLinks.ts:原样渲染的话浏览器会把它当成宿主网站根目录下的地址,打不开。
|
|
3
|
+
// Operator 把它改写到任务的文件接口;这里由宿主 / 后端给出地址(fileUrl),给不出就显示成不可点的文字,不留坏链接。
|
|
4
|
+
import { createContext } from "react";
|
|
5
|
+
|
|
6
|
+
/** 本地文件 → 能打开的地址;返回 null 表示打不开(显示成文字)。 */
|
|
7
|
+
export type FileUrlResolver = (path: string, kind: "link" | "image") => string | null;
|
|
8
|
+
export const FileLinkContext = createContext<FileUrlResolver | null>(null);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 点了本地文件链接:交给看板 / 弹窗打开,返回 true 表示接住了(链接不再按网址跳转)。
|
|
12
|
+
* 按住 ⌘/Ctrl/Shift 点、或中键点,仍然按网址在新窗口打开。
|
|
13
|
+
*/
|
|
14
|
+
export type FileOpenHandler = (path: string) => boolean;
|
|
15
|
+
export const FileOpenContext = createContext<FileOpenHandler | null>(null);
|
|
16
|
+
|
|
17
|
+
const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
18
|
+
const WIN_ABS = /^[a-z]:[\\/]/i;
|
|
19
|
+
|
|
20
|
+
function normalize(p: string): string {
|
|
21
|
+
const abs = p.startsWith("/");
|
|
22
|
+
const out: string[] = [];
|
|
23
|
+
for (const seg of p.split("/")) {
|
|
24
|
+
if (!seg || seg === ".") continue;
|
|
25
|
+
if (seg === ".." && out.length && out[out.length - 1] !== "..") out.pop();
|
|
26
|
+
else if (seg === ".." && abs) continue;
|
|
27
|
+
else out.push(seg);
|
|
28
|
+
}
|
|
29
|
+
return (abs ? "/" : "") + out.join("/");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 链接指向本地文件时返回路径(相对工作目录或绝对路径);网址、锚点等返回 null。规则与 Operator 一致。 */
|
|
33
|
+
export function localFilePath(href: string | undefined | null): string | null {
|
|
34
|
+
let h = String(href ?? "").trim();
|
|
35
|
+
if (!h || h.startsWith("#") || h.startsWith("//") || h.startsWith("/api/") || h.startsWith("/view?")) return null;
|
|
36
|
+
if (/^file:/i.test(h)) h = h.replace(/^file:(\/\/localhost)?(\/\/)?/i, "");
|
|
37
|
+
else if (SCHEME.test(h) && !WIN_ABS.test(h)) return null;
|
|
38
|
+
h = h.replace(/[?#].*$/, "");
|
|
39
|
+
try { h = decodeURI(h); } catch { /* 保持原样 */ }
|
|
40
|
+
h = h.replace(/\\/g, "/");
|
|
41
|
+
h = h.replace(/:(\d+)(:\d+)?$/, "");
|
|
42
|
+
if (!h) return null;
|
|
43
|
+
if (h === "~" || h.startsWith("~/")) return h;
|
|
44
|
+
if (h.startsWith("/") || WIN_ABS.test(h)) return WIN_ABS.test(h) ? h : normalize(h);
|
|
45
|
+
return normalize(h);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** File-shaped inline code is clickable; commands, models and code blocks remain code. */
|
|
49
|
+
export function inlineFilePath(value: unknown): string | null {
|
|
50
|
+
if (typeof value !== 'string' || /\s/.test(value) || !/\.(?:md|markdown|txt|log|csv|tsv|json|ya?ml|toml|ini|xml|[cm]?[jt]sx?|css|scss|html?|py|sh|go|rs|java|c|cpp|h|pdf|png|jpe?g|gif|webp|svg|docx?|xlsx?|pptx?|zip)(?::\d+(?::\d+)?)?$/i.test(value)) return null;
|
|
51
|
+
return localFilePath(value);
|
|
52
|
+
}
|
package/src/ui/format.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// 格式化 / 推导小工具。规则与 Operator(operator-oss/app/orchestrator/format.ts)一致,
|
|
2
|
+
// 只保留对话任务用得到的部分。
|
|
3
|
+
import type { AskAnswers, AskQuestion, Msg } from "../core/types";
|
|
4
|
+
|
|
5
|
+
// 紧凑 token 数:1234 → "1.2k",1_200_000 → "1.2M"。
|
|
6
|
+
export function fmtTokens(n: number): string {
|
|
7
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`;
|
|
8
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`;
|
|
9
|
+
return String(n);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function fmtCount(n: number): string {
|
|
13
|
+
return n.toLocaleString("zh-CN");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 消息时间:当天只显示时刻,跨天带日期。绝对时间是有意的:消息行是 memo 的,相对时间会冻住。
|
|
17
|
+
export function clockTime(ts: number): string {
|
|
18
|
+
const d = new Date(ts);
|
|
19
|
+
const time = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
20
|
+
if (d.toDateString() === new Date().toDateString()) return time;
|
|
21
|
+
return `${d.toLocaleDateString([], { month: "short", day: "numeric" })}, ${time}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function relTime(ts: number): string {
|
|
25
|
+
const d = Date.now() - ts;
|
|
26
|
+
const m = Math.floor(d / 60000);
|
|
27
|
+
if (m < 1) return "刚刚";
|
|
28
|
+
if (m < 60) return `${m} 分钟前`;
|
|
29
|
+
const h = Math.floor(m / 60);
|
|
30
|
+
if (h < 24) return `${h} 小时前`;
|
|
31
|
+
return `${Math.floor(h / 24)} 天前`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const toMs = (value: string | number | null | undefined) => {
|
|
35
|
+
if (value === null || value === undefined) return null;
|
|
36
|
+
const ms = typeof value === "number" ? value : new Date(value).getTime();
|
|
37
|
+
return Number.isFinite(ms) ? ms : null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// 用户回答 AskUserQuestion 后,组成一条普通回复发出去(对话任务里回答就是下一条消息)。
|
|
41
|
+
export function formatAnswersText(questions: AskQuestion[], answers: AskAnswers): string {
|
|
42
|
+
const lines = questions.map((q, i) => {
|
|
43
|
+
const picked = (answers[i] ?? []).filter((s) => s && s.trim());
|
|
44
|
+
return `- ${q.header || q.question}:${picked.length ? picked.join("、") : "(未选择)"}`;
|
|
45
|
+
});
|
|
46
|
+
return `回答你的问题:\n${lines.join("\n")}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const diffCls = (sign: "+" | "-" | " ") => (sign === "+" ? "add" : sign === "-" ? "del" : "ctx");
|
|
50
|
+
|
|
51
|
+
// 按 generation 把扁平的消息分成一次次会话,抽出 /clear 的摘要。
|
|
52
|
+
export function buildSessions(messages: Msg[]) {
|
|
53
|
+
const summaryByGen: Record<number, string> = {};
|
|
54
|
+
for (const m of messages) if (m.role === "session_break") summaryByGen[m.generation] = m.content;
|
|
55
|
+
const committed = messages.filter((m) => m.role !== "queued");
|
|
56
|
+
const gens = Array.from(new Set(committed.filter((m) => m.role !== "session_break").map((m) => m.generation))).sort((a, b) => a - b);
|
|
57
|
+
return gens.map((n) => ({
|
|
58
|
+
n,
|
|
59
|
+
summaryBefore: summaryByGen[n - 1] ?? null,
|
|
60
|
+
messages: committed.filter((m) => m.generation === n && m.role !== "session_break"),
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 智能体显示名:与 Operator 的驱动 id 对应。
|
|
65
|
+
const AGENT_LABELS: Record<string, string> = { claude: "Claude Code", codex: "Codex", grok: "Grok", pi: "Pi", mock: "Mock" };
|
|
66
|
+
export const agentLabel = (agent: string | null | undefined) => (agent ? AGENT_LABELS[agent] ?? agent : "智能体");
|
|
67
|
+
|
|
68
|
+
// 已解析的模型 id 的友好名("claude-opus-5" → "Opus 5";非 Claude 的原样显示)。
|
|
69
|
+
export function modelLabel(id: string | null | undefined): string {
|
|
70
|
+
if (!id) return "";
|
|
71
|
+
const s = id.toLowerCase();
|
|
72
|
+
const long = s.includes("[1m]") ? " (1M)" : "";
|
|
73
|
+
const fam = ["fable", "opus", "sonnet", "haiku"].find((f) => s.includes(f));
|
|
74
|
+
if (!fam) return id;
|
|
75
|
+
const cap = fam[0].toUpperCase() + fam.slice(1);
|
|
76
|
+
const v = s.match(new RegExp(`${fam}-(\\d+)(?:-(\\d+))?`));
|
|
77
|
+
return v ? `${cap} ${v[1]}${v[2] ? `.${v[2]}` : ""}${long}` : `${cap}${long}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Operator 的附件标记行 ─────────────────────────────
|
|
81
|
+
// Operator 界面里上传的图片 / 大段粘贴,以一行「[Attached image: 路径]」「[Attached file: 路径]」写进用户消息。
|
|
82
|
+
// 规则与 Operator 的 splitAttachments 一致:路径形如 …/uploads/<task>/<file> 的才算附件,手打的同形文字留在正文里。
|
|
83
|
+
const ATTACHMENT_RE = /^\[Attached (image|file): (.+)\]$/;
|
|
84
|
+
export interface MsgAttachment { path: string; kind: "image" | "file"; name: string; taskId: string }
|
|
85
|
+
|
|
86
|
+
export function splitAttachments(content: string): { text: string; attachments: MsgAttachment[] } {
|
|
87
|
+
if (!content.includes("[Attached image: ") && !content.includes("[Attached file: ")) return { text: content, attachments: [] };
|
|
88
|
+
const attachments: MsgAttachment[] = [];
|
|
89
|
+
const kept: string[] = [];
|
|
90
|
+
for (const line of content.split("\n")) {
|
|
91
|
+
const m = ATTACHMENT_RE.exec(line.trim());
|
|
92
|
+
const parts = m ? m[2].split(/[\\/]/).filter(Boolean) : [];
|
|
93
|
+
const upload = parts.length >= 3 && parts[parts.length - 3] === "uploads";
|
|
94
|
+
const managed = parts.length >= 4 && parts[parts.length - 4] === ".workspace" && parts[parts.length - 3] === "attachments";
|
|
95
|
+
if (m && (upload || managed) && parts.slice(-2).every(part => part !== "." && part !== "..")) {
|
|
96
|
+
const [taskId, file] = parts.slice(-2);
|
|
97
|
+
attachments.push({ path: m[2], kind: m[1] === "image" ? "image" : "file", name: file, taskId });
|
|
98
|
+
} else {
|
|
99
|
+
kept.push(line);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { text: kept.join("\n").trim(), attachments };
|
|
103
|
+
}
|
package/src/ui/icons.tsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// 图标:原样取自 operator-oss/app/icons.tsx,保持与 Operator 界面一致。
|
|
2
|
+
/* Orchestrator — icon set (ported from the design's icons.js) */
|
|
3
|
+
import type { ReactElement, ReactNode, SVGProps } from "react";
|
|
4
|
+
|
|
5
|
+
type P = SVGProps<SVGSVGElement>;
|
|
6
|
+
|
|
7
|
+
function S(children: ReactNode, props?: P) {
|
|
8
|
+
return (
|
|
9
|
+
<svg
|
|
10
|
+
viewBox="0 0 24 24"
|
|
11
|
+
fill="none"
|
|
12
|
+
stroke="currentColor"
|
|
13
|
+
strokeWidth={1.7}
|
|
14
|
+
strokeLinecap="round"
|
|
15
|
+
strokeLinejoin="round"
|
|
16
|
+
width="1em"
|
|
17
|
+
height="1em"
|
|
18
|
+
style={{ flex: "none" }}
|
|
19
|
+
{...props}
|
|
20
|
+
>
|
|
21
|
+
{children}
|
|
22
|
+
</svg>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const Icon = {
|
|
27
|
+
plus: (p?: P) => S(<><line x1={12} y1={5} x2={12} y2={19} /><line x1={5} y1={12} x2={19} y2={12} /></>, p),
|
|
28
|
+
list: (p?: P) => S(<><line x1={9} y1={6} x2={20} y2={6} /><line x1={9} y1={12} x2={20} y2={12} /><line x1={9} y1={18} x2={20} y2={18} /><circle cx={5} cy={6} r={0.5} /><circle cx={5} cy={12} r={0.5} /><circle cx={5} cy={18} r={0.5} /></>, p),
|
|
29
|
+
board: (p?: P) => S(<><rect x={3} y={4} width={5} height={16} rx={1.5} /><rect x={10} y={4} width={5} height={11} rx={1.5} /><rect x={17} y={4} width={4} height={8} rx={1.5} /></>, p),
|
|
30
|
+
chart: (p?: P) => S(<><line x1={5} y1={20} x2={5} y2={13} /><line x1={12} y1={20} x2={12} y2={5} /><line x1={19} y1={20} x2={19} y2={9} /></>, p),
|
|
31
|
+
folder: (p?: P) => S(<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />, p),
|
|
32
|
+
gear: (p?: P) => S(<><circle cx={12} cy={12} r={3} /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></>, p),
|
|
33
|
+
send: (p?: P) => S(<><path d="M7 11l5-5 5 5" /><line x1={12} y1={6} x2={12} y2={18} /></>, p),
|
|
34
|
+
spark: (p?: P) => S(<path d="M12 3l1.6 5.4L19 10l-5.4 1.6L12 17l-1.6-5.4L5 10l5.4-1.6z" />, p),
|
|
35
|
+
clock: (p?: P) => S(<><circle cx={12} cy={12} r={9} /><path d="M12 7v5l3 2" /></>, p),
|
|
36
|
+
clear: (p?: P) => S(<><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v4h4" /></>, p),
|
|
37
|
+
dots: (p?: P) => S(<><circle cx={5} cy={12} r={1} /><circle cx={12} cy={12} r={1} /><circle cx={19} cy={12} r={1} /></>, p),
|
|
38
|
+
chevDown: (p?: P) => S(<path d="M6 9l6 6 6-6" />, p),
|
|
39
|
+
chevUp: (p?: P) => S(<path d="M6 15l6-6 6 6" />, p),
|
|
40
|
+
chevRight: (p?: P) => S(<path d="M9 6l6 6-6 6" />, p),
|
|
41
|
+
toBottom: (p?: P) => S(<><path d="M7 7l5 5 5-5" /><line x1={5} y1={17} x2={19} y2={17} /></>, p),
|
|
42
|
+
x: (p?: P) => S(<><line x1={6} y1={6} x2={18} y2={18} /><line x1={6} y1={18} x2={18} y2={6} /></>, p),
|
|
43
|
+
/** 回车 ↵:排队消息「立即发送」。 */
|
|
44
|
+
enter: (p?: P) => S(<><path d="M9 10l-5 5 5 5" /><path d="M20 4v7a4 4 0 0 1-4 4H4" /></>, p),
|
|
45
|
+
clip: (p?: P) => S(<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />, p),
|
|
46
|
+
check: (p?: P) => S(<path d="M5 12l5 5 9-11" />, p),
|
|
47
|
+
lock: (p?: P) => S(<><rect x={4} y={11} width={16} height={9} rx={2} /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></>, p),
|
|
48
|
+
edit: (p?: P) => S(<><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" /></>, p),
|
|
49
|
+
doc: (p?: P) => S(<><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></>, p),
|
|
50
|
+
github: (p?: P) => S(<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" />, p),
|
|
51
|
+
git: (p?: P) => S(<><circle cx={6} cy={6} r={2} /><circle cx={6} cy={18} r={2} /><circle cx={18} cy={9} r={2} /><path d="M18 11a8 8 0 0 1-8 8" /><line x1={6} y1={8} x2={6} y2={16} /></>, p),
|
|
52
|
+
flag: (p?: P) => S(<path d="M5 21V4h12l-2 4 2 4H5" />, p),
|
|
53
|
+
bolt: (p?: P) => S(<path d="M13 2L4 14h7l-1 8 9-12h-7z" />, p),
|
|
54
|
+
moon: (p?: P) => S(<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />, p),
|
|
55
|
+
sun: (p?: P) => S(<><circle cx={12} cy={12} r={4} /><line x1={12} y1={2} x2={12} y2={5} /><line x1={12} y1={19} x2={12} y2={22} /><line x1={2} y1={12} x2={5} y2={12} /><line x1={19} y1={12} x2={22} y2={12} /><line x1={4.9} y1={4.9} x2={6.8} y2={6.8} /><line x1={17.2} y1={17.2} x2={19.1} y2={19.1} /><line x1={4.9} y1={19.1} x2={6.8} y2={17.2} /><line x1={17.2} y1={6.8} x2={19.1} y2={4.9} /></>, p),
|
|
56
|
+
external: (p?: P) => S(<><path d="M14 4h6v6" /><path d="M20 4l-9 9" /><path d="M19 13v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5" /></>, p),
|
|
57
|
+
copy: (p?: P) => S(<><rect x={9} y={9} width={12} height={12} rx={2} /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></>, p),
|
|
58
|
+
play: (p?: P) => S(<path d="M7 5l11 7-11 7z" />, p),
|
|
59
|
+
pause: (p?: P) => S(<><path d="M9 5v14" /><path d="M15 5v14" /></>, p),
|
|
60
|
+
stop: (p?: P) => S(<rect x={6} y={6} width={12} height={12} rx={2} />, p),
|
|
61
|
+
sliders: (p?: P) => S(<><line x1={4} y1={21} x2={4} y2={14} /><line x1={4} y1={10} x2={4} y2={3} /><line x1={12} y1={21} x2={12} y2={12} /><line x1={12} y1={8} x2={12} y2={3} /><line x1={20} y1={21} x2={20} y2={16} /><line x1={20} y1={12} x2={20} y2={3} /><line x1={1} y1={14} x2={7} y2={14} /><line x1={9} y1={8} x2={15} y2={8} /><line x1={17} y1={16} x2={23} y2={16} /></>, p),
|
|
62
|
+
search: (p?: P) => S(<><circle cx={11} cy={11} r={7} /><line x1={21} y1={21} x2={16.65} y2={16.65} /></>, p),
|
|
63
|
+
terminal: (p?: P) => S(<><rect x={3} y={4} width={18} height={16} rx={2} /><path d="M7 9l3 3-3 3" /><line x1={13} y1={15} x2={17} y2={15} /></>, p),
|
|
64
|
+
archive: (p?: P) => S(<><rect x={3} y={4} width={18} height={4} rx={1} /><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8" /><line x1={10} y1={12} x2={14} y2={12} /></>, p),
|
|
65
|
+
restore: (p?: P) => S(<><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v4h4" /><path d="M12 8v4l3 2" /></>, p),
|
|
66
|
+
download: (p?: P) => S(<><path d="M12 4v11" /><path d="M7 11l5 5 5-5" /><line x1={5} y1={20} x2={19} y2={20} /></>, p),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ---- agent brand marks ----
|
|
70
|
+
// Logos, not line icons: filled paths in `currentColor` (the stroked `S()` set
|
|
71
|
+
// above would smear these at avatar size). Keyed by agent driver id so any UI
|
|
72
|
+
// that knows a task's `agent` can stamp the right mark; unknown/legacy ids get
|
|
73
|
+
// no entry and callers fall back to a generic glyph.
|
|
74
|
+
|
|
75
|
+
function Brand(children: ReactNode, props?: P) {
|
|
76
|
+
return (
|
|
77
|
+
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none" width="1em" height="1em" style={{ flex: "none" }} aria-hidden {...props}>
|
|
78
|
+
{children}
|
|
79
|
+
</svg>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const AgentMark: Record<string, (p?: P) => ReactElement> = {
|
|
84
|
+
claude: (p?: P) => Brand(
|
|
85
|
+
<path d="M4.709 15.955l4.72-2.647.079-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 0 1-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.984-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.003 1.81 2.508 2.33.127.578-.322.455-.34-.049-2.204-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" />,
|
|
86
|
+
p,
|
|
87
|
+
),
|
|
88
|
+
codex: (p?: P) => Brand(
|
|
89
|
+
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.911 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.182a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.998-2.9 6.056 6.056 0 0 0-.748-7.073zm-9.022 12.608a4.476 4.476 0 0 1-2.876-1.04l.142-.081 4.778-2.758a.795.795 0 0 0 .393-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.495 4.494zM3.6 18.304a4.471 4.471 0 0 1-.535-3.014l.142.085 4.783 2.758a.771.771 0 0 0 .78 0l5.843-3.368v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.499 4.499 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.677l5.814 3.354-2.02 1.169a.076.076 0 0 1-.071 0l-4.83-2.787A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.856L13.104 8.364 15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.024l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.499 4.499 0 0 1 6.681 4.66zM8.307 12.863l-2.02-1.164a.08.08 0 0 1-.038-.057V6.074a4.499 4.499 0 0 1 7.376-3.453l-.142.08-4.778 2.758a.795.795 0 0 0-.393.682zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z" />,
|
|
90
|
+
p,
|
|
91
|
+
),
|
|
92
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** A compact display-only excerpt. The original reference is kept for sending. */
|
|
2
|
+
export function referencePreview(text: string): string {
|
|
3
|
+
const plain = text.slice(0, 4_000)
|
|
4
|
+
.replace(/^文件修订:[a-f0-9]{64}\n/gm, "")
|
|
5
|
+
.replace(/^\s*(?:```|~~~)[^\n]*$/gm, "")
|
|
6
|
+
.replace(/!\[([^\]]*)\]\([^\n)]*\)/g, "$1")
|
|
7
|
+
.replace(/\[([^\]]+)\]\([^\n)]*\)/g, "$1")
|
|
8
|
+
.replace(/^\s{0,3}#{1,6}\s+/gm, "")
|
|
9
|
+
.replace(/^\s*(?:>\s*)+/gm, "")
|
|
10
|
+
.replace(/^\s*(?:[-*+] |\d+[.)] )/gm, "")
|
|
11
|
+
.replace(/^\s*\|?[ :|-]+\|\s*$/gm, "")
|
|
12
|
+
.replace(/\*\*|__|~~|`/g, "")
|
|
13
|
+
.replace(/\*([^*\n]+)\*/g, "$1")
|
|
14
|
+
.replace(/\|/g, " · ")
|
|
15
|
+
.replace(/\s+/g, " ").trim();
|
|
16
|
+
return plain.length > 240 ? `${plain.slice(0, 240)}…` : plain;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Shorten known file-reference headings, while leaving URLs and agent names intact. */
|
|
20
|
+
export function referenceTitle(title: string): string {
|
|
21
|
+
const file = /^(文件|文件片段) (.+)$/.exec(title);
|
|
22
|
+
const workspaceFile = /^(?:当前工作区文件|来源任务\s*.+?\s*的工作区文件) (.+)$/.exec(title);
|
|
23
|
+
const path = file?.[2] ?? workspaceFile?.[1];
|
|
24
|
+
if (!path) return title;
|
|
25
|
+
const name = path.split(/[\\/]/).filter(Boolean).pop();
|
|
26
|
+
return name ? `${file?.[1] ?? "文件"} ${name}` : title;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Only the leading format emitted by node composerDraft.quoteText is a reference. */
|
|
30
|
+
export function splitQuotedMessage(content: string): { quote: { author: string; text: string }; text: string } | null {
|
|
31
|
+
const lines = content.split("\n");
|
|
32
|
+
const heading = /^引用 ([^\r\n]+):$/.exec(lines[0]);
|
|
33
|
+
if (!heading) return null;
|
|
34
|
+
let index = 1;
|
|
35
|
+
const quoted: string[] = [];
|
|
36
|
+
while (lines[index]?.startsWith("> ")) quoted.push(lines[index++].slice(2));
|
|
37
|
+
if (!quoted.length || (index < lines.length && lines[index] !== "")) return null;
|
|
38
|
+
if (lines[index] === "") index++;
|
|
39
|
+
return { quote: { author: heading[1], text: quoted.join("\n") }, text: lines.slice(index).join("\n") };
|
|
40
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// 小部件:状态点、头像、智能体标签、骨架条。与 Operator 的 shared.tsx 同样的类名和规则。
|
|
2
|
+
import type { CSSProperties } from "react";
|
|
3
|
+
import { AgentMark, Icon } from "./icons";
|
|
4
|
+
|
|
5
|
+
/** Operator 的状态色:r 未开始 · a 进行中 · h 搁置 · g 完成 · x 取消 · b 运行中(蓝,闪)· c 等你处理(珊瑚,闪)。 */
|
|
6
|
+
export type DotCls = "r" | "a" | "h" | "g" | "x";
|
|
7
|
+
|
|
8
|
+
export function StatusDot({ cls, running, awaiting, title }: { cls: DotCls; running?: boolean; awaiting?: boolean; title?: string }) {
|
|
9
|
+
const c = awaiting ? "c" : running ? "b" : cls;
|
|
10
|
+
return <span className={`sdot ${c} ${awaiting || running ? "pulse" : ""}`} title={title} />;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Avatar({ who, agent }: { who: "user" | "cc"; agent?: string | null }) {
|
|
14
|
+
if (who === "user") return <span className="av you">A</span>;
|
|
15
|
+
const mark = agent ? AgentMark[agent] : undefined;
|
|
16
|
+
return <span className={`av cc${mark ? ` ${agent}` : ""}`}>{mark ? mark() : Icon.bolt()}</span>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function AgentBadge({ label }: { label: string }) {
|
|
20
|
+
return <span className="agent-badge" title={`运行于 ${label}`}>{label}</span>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Skel({ w, h = 10, r, style }: { w: number | string; h?: number; r?: number | string; style?: CSSProperties }) {
|
|
24
|
+
return <span className="skel" aria-hidden style={{ width: w, height: h, ...(r !== undefined ? { borderRadius: r } : null), ...style }} />;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Spinner({ size }: { size?: number }) {
|
|
28
|
+
return <span className="spinner" role="status" aria-label="加载中" style={size ? { width: size, height: size } : undefined} />;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ErrNote({ children, onRetry }: { children: React.ReactNode; onRetry?: () => void }) {
|
|
32
|
+
return (
|
|
33
|
+
<div className="err-note">
|
|
34
|
+
<span className="err-msg">⚠ {children}</span>
|
|
35
|
+
{onRetry && <button className="btn btn-line btn-sm" onClick={onRetry}>{Icon.restore()} 重试</button>}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// 实时转录背后的纯函数(无 React,可单测)。规则与 Operator 的 transcriptModel.ts 一致:
|
|
2
|
+
// 为长对话设计——成千上万条消息、几 MB 的工具输出。
|
|
3
|
+
import type { Msg, ToolData } from "../core/types";
|
|
4
|
+
import { buildSessions } from "./format";
|
|
5
|
+
|
|
6
|
+
/** 跟随最新输出时 DOM 里保留的行数。 */
|
|
7
|
+
export const RENDER_WINDOW = 120;
|
|
8
|
+
/** 往回翻(或再往下)时每次多显示的行数。 */
|
|
9
|
+
export const RENDER_STEP = 80;
|
|
10
|
+
/** DOM 里最多同时存在的行数,超过就丢掉远端的。 */
|
|
11
|
+
export const RENDER_MAX = 360;
|
|
12
|
+
|
|
13
|
+
// 工具消息解析结果按消息对象缓存:内容变了消息对象就会换,缓存不会过期。
|
|
14
|
+
const toolCache = new WeakMap<Msg, ToolData>();
|
|
15
|
+
/** tool 消息背后的工具卡片数据。只读,不要改返回值。 */
|
|
16
|
+
export function toolDataOf(m: Msg): ToolData {
|
|
17
|
+
let data = toolCache.get(m);
|
|
18
|
+
if (!data) {
|
|
19
|
+
try { data = sanitizeTool(JSON.parse(m.content)); } catch { data = { title: m.content }; }
|
|
20
|
+
toolCache.set(m, data);
|
|
21
|
+
}
|
|
22
|
+
return data;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 工具卡片的数据来自节点,格式不对的部分丢掉而不是让整个对话渲染崩掉:
|
|
27
|
+
* 提问卡片要有 questions 数组,浏览器输入卡片要有 fields 数组,其余字段按类型保留。
|
|
28
|
+
*/
|
|
29
|
+
function sanitizeTool(raw: unknown): ToolData {
|
|
30
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { title: typeof raw === "string" ? raw : String(raw) };
|
|
31
|
+
const d = { ...(raw as Record<string, unknown>) } as Record<string, unknown> & ToolData;
|
|
32
|
+
if (typeof d.title !== "string") d.title = d.title == null ? "" : String(d.title);
|
|
33
|
+
for (const k of ["detail", "result"] as const) if (d[k] != null && typeof d[k] !== "string") d[k] = JSON.stringify(d[k]);
|
|
34
|
+
const ask = d.ask as { questions?: unknown } | undefined;
|
|
35
|
+
if (ask && (typeof ask !== "object" || !Array.isArray(ask.questions))) delete d.ask;
|
|
36
|
+
const input = d.input as { fields?: unknown; id?: unknown } | undefined;
|
|
37
|
+
if (input && (typeof input !== "object" || !Array.isArray(input.fields))) delete d.input;
|
|
38
|
+
if (d.diff && !Array.isArray(d.diff)) delete d.diff;
|
|
39
|
+
if (d.peek && typeof d.peek !== "object") delete d.peek;
|
|
40
|
+
return d;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 从最新一端找这个 id 的消息(实时更新都落在末尾)。 */
|
|
44
|
+
export function indexFromEnd(arr: Msg[], id: string): number {
|
|
45
|
+
for (let i = arr.length - 1; i >= 0; i--) if (arr[i].id === id) return i;
|
|
46
|
+
return -1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 末尾附近还有没回答的 AskUserQuestion 卡片。 */
|
|
50
|
+
export function hasOpenAsk(messages: Msg[], scan = 400): boolean {
|
|
51
|
+
for (let i = messages.length - 1, n = 0; i >= 0 && n < scan; i--, n++) {
|
|
52
|
+
const m = messages[i];
|
|
53
|
+
if (m.role !== "tool") continue;
|
|
54
|
+
const d = toolDataOf(m);
|
|
55
|
+
if (d.ask && !d.ask.answers) return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 把(重连时的)快照合进已有的:没变的消息保持对象引用,memo 的行就不会重渲染(不会重新解析 markdown)。
|
|
62
|
+
* 快照只是最新一段(older > 0)时,保留这个页面已经往上翻出来的更早的消息(与 Operator 的 mergeSnapshot 一致)。
|
|
63
|
+
*/
|
|
64
|
+
export function mergeSnapshot(prev: Msg[] | undefined, committed: Msg[], older = 0): { msgs: Msg[]; older: number } {
|
|
65
|
+
const byId = new Map<string, Msg>();
|
|
66
|
+
for (const m of prev ?? []) byId.set(m.id, m);
|
|
67
|
+
const fresh = committed.map((m) => {
|
|
68
|
+
const p = byId.get(m.id);
|
|
69
|
+
if (!p || p.role !== m.role || p.content !== m.content || p.generation !== m.generation) return m;
|
|
70
|
+
return p.ts === m.ts ? p : { ...p, ts: m.ts };
|
|
71
|
+
});
|
|
72
|
+
let kept: Msg[] = [];
|
|
73
|
+
if (prev && fresh.length && older > 0) {
|
|
74
|
+
const at = prev.findIndex((m) => m.id === fresh[0].id);
|
|
75
|
+
// 只留落过库的(带时间的):本地补的说明行随旧视图一起作废。
|
|
76
|
+
if (at > 0) kept = prev.slice(0, at).filter((m) => m.role !== "queued" && m.ts != null);
|
|
77
|
+
}
|
|
78
|
+
return { msgs: [...kept, ...fresh], older: Math.max(0, older - kept.length) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 往上翻取到的一页更早的消息接在最前面(已经有的跳过)。 */
|
|
82
|
+
export function prependOlder(prev: Msg[], page: Msg[]): Msg[] {
|
|
83
|
+
const have = new Set(prev.map((m) => m.id));
|
|
84
|
+
const add = page.filter((m) => !have.has(m.id));
|
|
85
|
+
return add.length ? [...add, ...prev] : prev;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type TranscriptItem =
|
|
89
|
+
| { kind: "label"; key: string; n: number; current: boolean }
|
|
90
|
+
| { kind: "break"; key: string; summary: string }
|
|
91
|
+
| { kind: "msg"; key: string; m: Msg; initial: boolean; hideWho: boolean }
|
|
92
|
+
/** 收起的「等你在浏览器里操作」:AI 每 ~50 秒续等一次,每次都是「思考 + 工具 + 一句话」,几分钟就刷满一屏。 */
|
|
93
|
+
| { kind: "fold"; key: string; msgs: Msg[]; waits: number };
|
|
94
|
+
|
|
95
|
+
const WAIT_TOOL = /browser_handoff|browser_ask_user/;
|
|
96
|
+
/** 续等的结果:用户还没做完 / 还没填。 */
|
|
97
|
+
const STILL_WAITING = /hasn'?t (finished|answered|responded)|not (finished|answered|done) yet|still waiting|keep waiting|wait_only|还没(有)?(完成|回答|填)|继续等/i;
|
|
98
|
+
|
|
99
|
+
/** 续等浏览器那边的人(browser_handoff / browser_ask_user 带 wait_only,或结果是「还没好」)。第一次请求不算。 */
|
|
100
|
+
export function isWaitCall(m: Msg): boolean {
|
|
101
|
+
if (m.role !== "tool") return false;
|
|
102
|
+
const d = toolDataOf(m);
|
|
103
|
+
if (!WAIT_TOOL.test(d.title)) return false;
|
|
104
|
+
if (d.result === undefined) return /wait_only/.test(d.detail ?? "");
|
|
105
|
+
return STILL_WAITING.test(d.result);
|
|
106
|
+
}
|
|
107
|
+
const isThinking = (m: Msg) => m.role === "tool" && /^\W*thinking\b/i.test(toolDataOf(m).title);
|
|
108
|
+
/** 两次续等之间的消息:智能体的一句话、思考。 */
|
|
109
|
+
const between = (m: Msg) => m.role === "assistant" || isThinking(m);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 一段连续的续等(中间只夹着思考和一句话):最后一次照常显示(它的状态是最新的),前面的收成一行。
|
|
113
|
+
* 返回收起的那段的结束位置(不含)和收起了几次续等;不到两次续等不收,返回 null。
|
|
114
|
+
*/
|
|
115
|
+
function waitRun(msgs: Msg[], start: number): { end: number; waits: number } | null {
|
|
116
|
+
let last = start;
|
|
117
|
+
let waits = 1;
|
|
118
|
+
for (let i = start + 1; i < msgs.length; i++) {
|
|
119
|
+
if (isWaitCall(msgs[i])) { last = i; waits++; continue; }
|
|
120
|
+
if (!between(msgs[i])) break;
|
|
121
|
+
}
|
|
122
|
+
return waits >= 2 ? { end: last, waits: waits - 1 } : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 转录展开成扁平的行(会话标签、/clear 分隔、消息),界面可以只渲染其中一个窗口。
|
|
127
|
+
* hasOlder:更早的消息还没取,第一条就不一定是它那个会话的开场。
|
|
128
|
+
*/
|
|
129
|
+
export function buildTranscriptItems(messages: Msg[], hasOlder = false): TranscriptItem[] {
|
|
130
|
+
const sessions = buildSessions(messages);
|
|
131
|
+
const items: TranscriptItem[] = [];
|
|
132
|
+
sessions.forEach((s, si) => {
|
|
133
|
+
if (si > 0 && s.summaryBefore) items.push({ kind: "break", key: `b-${s.n}`, summary: s.summaryBefore });
|
|
134
|
+
items.push({ kind: "label", key: `s-${s.n}`, n: s.n, current: si === sessions.length - 1 });
|
|
135
|
+
for (let mi = 0; mi < s.messages.length; mi++) {
|
|
136
|
+
const m = s.messages[mi];
|
|
137
|
+
const run = isWaitCall(m) ? waitRun(s.messages, mi) : null;
|
|
138
|
+
if (run) {
|
|
139
|
+
items.push({ kind: "fold", key: `f-${m.id}`, msgs: s.messages.slice(mi, run.end), waits: run.waits });
|
|
140
|
+
mi = run.end - 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const prev = s.messages[mi - 1];
|
|
144
|
+
// 一段连续的智能体输出(文字 → 工具 → 文字)只显示一次头像行
|
|
145
|
+
const hideWho = m.role === "assistant" && !!prev && (prev.role === "assistant" || prev.role === "tool");
|
|
146
|
+
const initial = mi === 0 && m.role === "user" && !(si === 0 && hasOlder);
|
|
147
|
+
items.push({ kind: "msg", key: m.id, m, initial, hideWho });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return items;
|
|
151
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useLayoutEffect, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
/** Keep the fixed composer column aligned with a transcript using classic scrollbars. */
|
|
4
|
+
export function useChatScrollbar(ref: RefObject<HTMLElement | null>, scope?: string | null) {
|
|
5
|
+
useLayoutEffect(() => {
|
|
6
|
+
const element = ref.current;
|
|
7
|
+
const column = element?.closest<HTMLElement>("[data-chat-layout]");
|
|
8
|
+
if (!element || !column) return;
|
|
9
|
+
const property = "--cli-chat-scrollbar";
|
|
10
|
+
const previous = column.style.getPropertyValue(property);
|
|
11
|
+
const priority = column.style.getPropertyPriority(property);
|
|
12
|
+
let active = true, written = "";
|
|
13
|
+
const measure = () => {
|
|
14
|
+
if (!active) return;
|
|
15
|
+
written = `${Math.max(0, element.offsetWidth - element.clientWidth)}px`;
|
|
16
|
+
if (column.style.getPropertyValue(property) !== written) column.style.setProperty(property, written);
|
|
17
|
+
};
|
|
18
|
+
measure();
|
|
19
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
|
|
20
|
+
observer?.observe(element);
|
|
21
|
+
return () => {
|
|
22
|
+
active = false;
|
|
23
|
+
observer?.disconnect();
|
|
24
|
+
if (column.style.getPropertyValue(property) === written) {
|
|
25
|
+
if (previous) column.style.setProperty(property, previous, priority); else column.style.removeProperty(property);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}, [ref, scope]);
|
|
29
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
/** Keep both composers between two and eight visual lines without changing focus or selection. */
|
|
4
|
+
export function useComposerAutosize(ref: RefObject<HTMLTextAreaElement | null>, value: string) {
|
|
5
|
+
const resize = useCallback(() => {
|
|
6
|
+
const element = ref.current;
|
|
7
|
+
if (!element || element.clientWidth === 0) return;
|
|
8
|
+
const style = window.getComputedStyle(element);
|
|
9
|
+
const lineHeight = parseFloat(style.lineHeight) || (parseFloat(style.fontSize) || 16) * 1.6;
|
|
10
|
+
const padding = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0);
|
|
11
|
+
const border = (parseFloat(style.borderTopWidth) || 0) + (parseFloat(style.borderBottomWidth) || 0);
|
|
12
|
+
const borderBox = style.boxSizing === "border-box";
|
|
13
|
+
const extra = borderBox ? padding + border : 0;
|
|
14
|
+
const minimum = Math.ceil(lineHeight * 2 + extra);
|
|
15
|
+
const maximum = Math.ceil(lineHeight * 8 + extra);
|
|
16
|
+
const scrollTop = element.scrollTop;
|
|
17
|
+
element.style.minHeight = `${minimum}px`;
|
|
18
|
+
element.style.maxHeight = `${maximum}px`;
|
|
19
|
+
element.style.overflowY = "hidden";
|
|
20
|
+
element.style.height = "auto";
|
|
21
|
+
const contentHeight = element.value ? element.scrollHeight + (borderBox ? border : -padding) : minimum;
|
|
22
|
+
element.style.height = `${Math.max(minimum, Math.min(maximum, contentHeight))}px`;
|
|
23
|
+
element.style.overflowY = contentHeight > maximum ? "auto" : "hidden";
|
|
24
|
+
element.scrollTop = scrollTop;
|
|
25
|
+
}, [ref]);
|
|
26
|
+
|
|
27
|
+
useLayoutEffect(resize, [resize, value]);
|
|
28
|
+
useLayoutEffect(() => {
|
|
29
|
+
const element = ref.current;
|
|
30
|
+
if (!element) return;
|
|
31
|
+
let width = element.clientWidth;
|
|
32
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => {
|
|
33
|
+
const nextWidth = element.clientWidth;
|
|
34
|
+
if (nextWidth === width) return;
|
|
35
|
+
width = nextWidth;
|
|
36
|
+
resize();
|
|
37
|
+
});
|
|
38
|
+
observer?.observe(element);
|
|
39
|
+
const viewport = window.visualViewport;
|
|
40
|
+
const fonts = document.fonts;
|
|
41
|
+
const pointer = typeof window.matchMedia === "function" ? window.matchMedia("(pointer: coarse)") : null;
|
|
42
|
+
window.addEventListener("resize", resize);
|
|
43
|
+
viewport?.addEventListener("resize", resize);
|
|
44
|
+
fonts?.addEventListener("loadingdone", resize);
|
|
45
|
+
pointer?.addEventListener("change", resize);
|
|
46
|
+
return () => {
|
|
47
|
+
observer?.disconnect();
|
|
48
|
+
window.removeEventListener("resize", resize);
|
|
49
|
+
viewport?.removeEventListener("resize", resize);
|
|
50
|
+
fonts?.removeEventListener("loadingdone", resize);
|
|
51
|
+
pointer?.removeEventListener("change", resize);
|
|
52
|
+
};
|
|
53
|
+
}, [ref, resize]);
|
|
54
|
+
}
|