From e7bd614496d74f3cc3395682fabb9090779859b8 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:29:10 +0800 Subject: [PATCH 01/21] resource: let a large cached entry be dropped The resource cache never evicts: fine for the small data it held so far, but a session's transcript can be several megabytes. `forget(key)` drops an entry nobody is subscribed to and nothing is fetching, so a view that caches big payloads can keep only the last few. Co-Authored-By: Claude Opus 5.5 --- src/lib/resource.test.ts | 32 +++++++++++++++++++++++++++++++- src/lib/resource.ts | 14 ++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/lib/resource.test.ts b/src/lib/resource.test.ts index 841252f8..e9d4ac01 100644 --- a/src/lib/resource.test.ts +++ b/src/lib/resource.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { fetchInto, resetResources } from "./resource"; +import { fetchInto, forget, resetResources } from "./resource"; /** 一个手动放行的取数:`fetch` 交给 `fetchInto`,`land` 让它带着某个值落地 */ function gate() { @@ -87,3 +87,33 @@ describe("飞着的时候又要重取", () => { expect(again.calls).toBe(0); }); }); + +/** + * 用完就丢的大数据(一次会话的对话)。**正在取的不丢**:取回来的要写进那一条,丢了就 + * 写丢了,挂着它的地方会一直停在读取中。 + */ +describe("丢掉一份缓存", () => { + it("取完了可以丢,丢过一次就没有了", async () => { + const g = gate(); + const p = fetchInto("big", g.fetch); + g.land("几 MB"); + await p; + await settle(); + expect(forget("big")).toBe(true); + expect(forget("big")).toBe(false); + }); + + it("正在取的不丢", async () => { + const g = gate(); + const p = fetchInto("big", g.fetch); + expect(forget("big")).toBe(false); + g.land("几 MB"); + expect(await p).toBe("几 MB"); + await settle(); + expect(forget("big")).toBe(true); + }); + + it("没有的键不算丢掉", () => { + expect(forget("never")).toBe(false); + }); +}); diff --git a/src/lib/resource.ts b/src/lib/resource.ts index 0539c269..b55b6cbd 100644 --- a/src/lib/resource.ts +++ b/src/lib/resource.ts @@ -259,6 +259,20 @@ export function invalidate(prefix: string) { } } +/** + * 不再要的一份数据:没人挂着、也没在取的时候,从缓存里拿掉。 + * + * 缓存不会自己清:平常的数据都不大,留着换来的是切回来立刻有。**大的那几样用完要丢** + * —— 一次长会话的对话就有几 MB,看过几十次会话的话全都留在内存里。还挂着的不动(拿掉 + * 了它也会马上再取一次),正在取的也不动(取回来的要写进这一条,拿掉了就写丢了)。 + * 返回拿掉了没有。 + */ +export function forget(key: string): boolean { + const e = cache.get(key); + if (!e || e.listeners.size > 0 || e.inflight !== null) return false; + return cache.delete(key); +} + /** * 全部重取:⌘R、命令面板的「刷新数据」。和 `invalidate` 一样,挂着的立刻重取,没人 * 挂着的只标成过时 —— 刷新的是眼前这一页的全部数据,不只是外壳读的状态和概览。 From aa637d8b5bc9cd0985731245e14719a7493abb24 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:29:44 +0800 Subject: [PATCH 02/21] traffic: replay a session as a conversation The session sheet gets two tabs, Summary (the existing totals, input per turn and cost per turn) and Conversation: the session replayed turn by turn from core's SessionTranscript. Each turn has a header with its number, time, model, cost and outcome, joined from SessionDetail by request id (the transcript's ids are strings, TurnView's are numbers), and a "Request details" button that stacks the request drawer on top. Inside a turn, a fixed role column (User / Assistant / Tool / System) and the content: plain text with whitespace kept, thinking folded, tool calls with the name and a one-line preview (arguments folded, pretty-printed JSON), tool results folded with an error state, images and other parts as chips. The system prompt sits folded at the top; a system prompt change is a folded row on that turn; a restart (e.g. a compacted context) gets a divider and folds the history the request carried up to the model's last message. Gaps are short inline notes: past the retention period, too large to keep whole, unreadable, or the turn's failure reason. Runs of turns past retention fold into one row; calls that generate no answer (count_tokens, compaction) are a single header line; turns still in flight close the list. The transcript is fetched when the tab is first opened and refetched only when SessionDetail records a new turn; unchanged turns keep their objects so memoised turns do not re-render, and only the last three sessions' transcripts stay cached. Long transcripts render in batches (no long task for 600 turns), long text and long tool output are clipped with "Show all", and folded content is not in the DOM. Switching tabs keeps the conversation's scroll position and expanded rows. Core's endpoint is not released yet: its types and fetch live in src/traffic/transcript.provisional.ts with the swap steps for integration. The whitelists in src/control.ts and src-tauri/src/call.rs cannot list SessionTranscript before the generated types have it; both carry a TODO. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/call.rs | 3 + src/RequestDrawer.tsx | 4 +- src/control.ts | 6 + src/i18n/terminology.md | 6 + src/traffic/Conversation.i18n.tsx | 117 ++++ src/traffic/Conversation.tsx | 870 ++++++++++++++++++++++++++ src/traffic/SessionPanel.tsx | 175 ++++-- src/traffic/Sessions.i18n.ts | 5 + src/traffic/transcript.provisional.ts | 104 +++ src/traffic/transcript.test.ts | 338 ++++++++++ src/traffic/transcript.ts | 340 ++++++++++ 11 files changed, 1923 insertions(+), 45 deletions(-) create mode 100644 src/traffic/Conversation.i18n.tsx create mode 100644 src/traffic/Conversation.tsx create mode 100644 src/traffic/transcript.provisional.ts create mode 100644 src/traffic/transcript.test.ts create mode 100644 src/traffic/transcript.ts diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index 2de91495..0e73a083 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -69,6 +69,9 @@ webview_endpoints![ RequestDetail, Sessions, SessionDetail, + // TODO(core SessionTranscript): 钉点升到带 `ep::SessionTranscript` 的 core 之后在这里加上 + // `SessionTranscript,`(`src/control.ts` 的 `WEBVIEW_ENDPOINTS` 同时加),步骤见 + // `src/traffic/transcript.provisional.ts`。现在钉着的 tw-api 里没有这个端点,加了编译不过。 // 测速、回放、试路由 SpeedQuote, SpeedRun, diff --git a/src/RequestDrawer.tsx b/src/RequestDrawer.tsx index 130d4c12..53acc6df 100644 --- a/src/RequestDrawer.tsx +++ b/src/RequestDrawer.tsx @@ -872,8 +872,10 @@ function Body({ * * **JSON 按词折,原文见字就断。**SSE 那种 `data: {…}` 按词折会在冒号后面断开, * 第一行只剩一个 `data:`。 + * + * 会话的「对话」那一页也用它画工具的参数和结果,两处的等宽正文是同一个样子。 */ -function BodyText({ +export function BodyText({ text, json, more = false, diff --git a/src/control.ts b/src/control.ts index 32df9451..66c49981 100644 --- a/src/control.ts +++ b/src/control.ts @@ -18,6 +18,12 @@ import type { ENDPOINTS, Endpoints } from "./generated/tw-api"; /** * 界面能直接调的端点。**和 `src-tauri/src/call.rs` 的 `ALLOWED` 是同一份** * (那边的测试核对):不在这里的端点,界面够不着。 + * + * TODO(core SessionTranscript): core 发版、`src/generated/tw-api.ts` 重新生成之后,在 + * `"SessionDetail"` 后面加上 `"SessionTranscript"`(`call.rs` 那边同时加),步骤见 + * `src/traffic/transcript.provisional.ts`。生成的类型里还没有它之前加不了:`call` 的类型 + * 按端点名查 `Endpoints`,查不到就编译不过。(写在这里不写进数组:Rust 那条测试按逗号切 + * 这个数组,数组里的注释会被当成一个端点名。) */ export const WEBVIEW_ENDPOINTS = [ "Interfaces", diff --git a/src/i18n/terminology.md b/src/i18n/terminology.md index 8c05601e..11a9bc9d 100644 --- a/src/i18n/terminology.md +++ b/src/i18n/terminology.md @@ -56,6 +56,12 @@ known colloquialisms. | 流量 | Traffic | sidebar; the list of requests | | 会话 | Sessions / session | | | 轮次 / 轮 | turns / turn | one request within a session | +| 概况 / 对话 | Summary / Conversation | the two tabs of a session: totals and cost per turn; the session replayed turn by turn | +| 用户 / 助手 / 工具 / 系统 | User / Assistant / Tool / System | who said a message in a conversation | +| 系统提示 | system prompt | | +| 思考 | thinking | | +| 工具调用 / 工具结果 | tool call / tool result | 「Read 的结果」 = "Read result" | +| 保留期限 | retention period | how long request and response bodies are kept | | 上下文峰值 | peak context | | | 缓存节省 | cache savings | | | 发现 | Findings | sidebar | diff --git a/src/traffic/Conversation.i18n.tsx b/src/traffic/Conversation.i18n.tsx new file mode 100644 index 00000000..b30d04b2 --- /dev/null +++ b/src/traffic/Conversation.i18n.tsx @@ -0,0 +1,117 @@ +import type { ReactNode } from "react"; +import { messages } from "@/i18n"; + +/** 英文的单复数 */ +const count = (n: number, one: string, many: string) => (n === 1 ? `1 ${one}` : `${n.toLocaleString()} ${many}`); + +/** + * 会话「对话」那一页的文案。 + * + * `.tsx`:「Read 的结果」里工具名是加粗的片段,它在中英文句子里的位置不同,由句子 + * 自己决定放在哪儿。 + * + * 缺口那几句和请求详情的说法一致:内容没有了说「已超过保留期限」(请求详情「未保存」 + * 的悬停说明),客户端先断开的那一句和「时间线」状态那一行是同一句。 + */ +export const conversationText = messages( + { + loadFailed: "对话读取失败", + /** 会话的轮次一轮都还没落库 */ + emptyTitle: "尚无已记录的轮次", + emptyHint: "每轮结束后显示在此处", + /** 每一轮的内容都已超过保留期限 */ + allLostTitle: "对话内容已超过保留期限", + allLostHint: "费用与用量仍可在概况中查看", + showSummary: "查看概况", + + // 左边那一列:这一块是谁说的 + user: "用户", + assistant: "助手", + tool: "工具", + system: "系统", + + turnNo: (n: number) => `第 ${n} 轮`, + /** 轮次头上的按钮:在这一层之上打开那一条请求 */ + openRequest: "请求详情", + /** 还在跑的那一轮,下面那一句 */ + afterEnd: "请求结束后可查看", + + systemPrompt: "系统提示", + systemChanged: "系统提示已更改", + thinking: "思考", + /** 响应里只有思考的签名,没有正文 */ + thinkingHidden: "上游未返回思考内容", + /** 找不到是哪个工具调用的结果 */ + result: "工具结果", + resultOf: (name: ReactNode) => <>{name} 的结果, + error: "错误", + noOutput: "无输出", + image: "图片", + chars: (n: number) => `${n.toLocaleString()} 字符`, + showAll: "展开全部", + collapse: "折叠", + + /** 历史重新开始的那一轮上面的分隔线 */ + restart: "对话历史从此处重新开始", + /** 那一轮带着的、此前已显示过的历史,收起 */ + earlier: (n: number) => `此前的对话 · ${n} 条消息`, + + // 显示不出来的部分 + lost: "此轮内容已超过保留期限", + lostRun: (from: number, to: number) => `第 ${from}–${to} 轮的内容已超过保留期限`, + requestMissing: "请求内容已超过保留期限", + requestTruncated: "请求过大,未完整保存", + responseMissing: "响应内容已超过保留期限", + /** `reason` 是 core 说的失败原因,一整句 */ + responseFailed: (reason: string) => `请求失败:${reason}`, + responseCancelled: "已取消:客户端在响应结束前断开连接", + responseTruncated: "响应过大,未完整保存", + responseUnreadable: "响应格式无法识别,原文见请求详情", + /** 不生成回答的调用(数 token、压缩上下文):轮次头上那一句 */ + noContent: "无对话内容", + }, + { + loadFailed: "Could not load the conversation", + emptyTitle: "No turns recorded yet", + emptyHint: "Each turn appears here when it ends", + allLostTitle: "The conversation is past the retention period", + allLostHint: "Cost and usage are still in the summary", + showSummary: "Show summary", + + user: "User", + assistant: "Assistant", + tool: "Tool", + system: "System", + + turnNo: (n: number) => `Turn ${n}`, + openRequest: "Request details", + afterEnd: "Available when the request ends", + + systemPrompt: "System prompt", + systemChanged: "System prompt changed", + thinking: "Thinking", + thinkingHidden: "The upstream returned no thinking content", + result: "Tool result", + resultOf: (name: ReactNode) => <>{name} result, + error: "Error", + noOutput: "No output", + image: "Image", + chars: (n: number) => count(n, "character", "characters"), + showAll: "Show all", + collapse: "Collapse", + + restart: "The conversation history starts over here", + earlier: (n: number) => `Earlier conversation · ${count(n, "message", "messages")}`, + + lost: "This turn is past the retention period", + lostRun: (from: number, to: number) => `Turns ${from}–${to} are past the retention period`, + requestMissing: "The request is past the retention period", + requestTruncated: "The request was too large to save in full", + responseMissing: "The response is past the retention period", + responseFailed: (reason: string) => `The request failed: ${reason}`, + responseCancelled: "Canceled: the client disconnected before the response finished", + responseTruncated: "The response was too large to save in full", + responseUnreadable: "The response format is not recognized; the raw body is in the request details", + noContent: "No conversation content", + }, +); diff --git a/src/traffic/Conversation.tsx b/src/traffic/Conversation.tsx new file mode 100644 index 00000000..a42abc11 --- /dev/null +++ b/src/traffic/Conversation.tsx @@ -0,0 +1,870 @@ +import { + createContext, + memo, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { ChevronRightIcon, ImageIcon } from "lucide-react"; +import { size, when } from "@/format"; +import { useText } from "@/i18n"; +import { coreText } from "@/i18n/core.i18n"; +import { DISCLOSURE } from "@/keys/parts"; +import { forget, useResource } from "@/lib/resource"; +import { cn } from "@/lib/utils"; +import { prettyJson } from "@/prettyJson"; +import { BodyText } from "@/RequestDrawer"; +import type { RequestRow, TurnView } from "@/types"; +import { Button } from "@/ui/button"; +import { Reveal } from "@/ui/motion"; +import { Skeleton } from "@/ui/skeleton"; +import { EmptyState, ErrorState } from "@/ui/states"; +import { StatusLabel } from "@/ui/status-dot"; +import { IconSession } from "@/ui/icons"; +import { PanelSkeleton } from "./PanelHeader"; +import { sessionsText } from "./Sessions.i18n"; +import { conversationText } from "./Conversation.i18n"; +import { turnCost } from "./costCell"; +import { + allLost, + argsPreview, + blocksOf, + clip, + idKey, + items, + keepTurns, + notesOf, + outcomeOf, + quiet, + requestIdOf, + splitRestart, + toolNames, + unrecorded, + viewsById, + visibleParts, + type Block, + type Note, + type Outcome, + type ToolCall, + type ToolResult, +} from "./transcript"; +import { + fetchTranscript, + type Transcript, + type TranscriptMessage, + type TranscriptPart, + type TranscriptRole, + type TranscriptTurn, +} from "./transcript.provisional"; + +/** 第一次画多少轮,够铺满一屏还有富余;其余的一批一批补上(见 `useProgressive`) */ +const FIRST_PAINT = 30; +const CHUNK = 40; + +/** 正文(用户、助手说的话,思考,系统提示)超过多少先收起 */ +const PROSE = { chars: 2000, lines: 40 }; +/** 等宽的那几样(工具参数、工具结果):框子自己会滚,收的是画进页面的量 */ +const MONO = { chars: 12_000, lines: 300 }; + +/** + * 左边一列写这一块是谁说的,右边是内容。**列宽固定**:每一轮各自按内容定宽的话, + * 几十轮读下来左边那一列忽宽忽窄。宽度按语言定(`:lang(en)`):中文的标签都是两个字, + * 英文最长的「Assistant」在 Windows 大一号的字下有 54px。 + */ +const COLS = "grid-cols-[2.75rem_minmax(0,1fr)] [&:lang(en)]:grid-cols-[4rem_minmax(0,1fr)]"; +const LINES = `grid ${COLS} items-start gap-x-3 gap-y-2`; +/** 底色那一块里的(见 `EarlierBlocks`):左右各有 8px 内边距,左边一列窄 8px,右边的内容和外面对齐 */ +const NESTED_COLS = "grid-cols-[2.25rem_minmax(0,1fr)] [&:lang(en)]:grid-cols-[3.5rem_minmax(0,1fr)]"; +const NESTED_LINES = `grid ${NESTED_COLS} items-start gap-x-3 gap-y-2`; + +/** 结果那一行写「Read 的结果」:调用 id → 工具名(见 `toolNames`) */ +const Names = createContext>(new Map()); + +/** + * 最近看过的几次会话的对话留在缓存里,再早的丢掉。一次长会话的对话就有几 MB, + * 缓存不会自己清(见 `forget`)。 + */ +const KEEP = 3; +const recent: string[] = []; +function remember(key: string) { + const i = recent.indexOf(key); + if (i >= 0) recent.splice(i, 1); + recent.push(key); + while (recent.length > KEEP) forget(recent.shift()!); +} + +/** 轮次头上的几项,从会话详情里那一轮来(`TurnView`) */ +interface Head { + at: number | null; + model: string; + /** 费用那一格写什么,和「每轮费用」同一个写法。`null`:没什么可写的 */ + cost: string | null; + costMuted: boolean; + outcome: Outcome; + /** 失败的原因,一整句 */ + failure: string | null; + /** 点「请求详情」打开哪一条 */ + rid: number | null; +} + +/** + * 会话的「对话」:按对话的样子把这次会话一轮一轮重放出来 —— 用户说了什么、模型想了 + * 什么、调了哪些工具、拿回来什么。 + * + * **打开这一页才去取**(挂上它的是「对话」标签,见 `SessionPanel`):一次长会话的对话 + * 有几 MB,只看费用的人用不着。取回来的按会话缓存,切回来立刻有。 + * + * **会话还在进行时跟着往下走**:会话详情多落一轮(`turns` 变了),就重取一次。这一页 + * 不自己听事件 —— 事件不分会话,别的会话落一轮也重取整段对话不值得。没变的轮次沿用 + * 原来的对象(`keepTurns`),一轮是 `memo` 的,新落的那一轮才画。 + * + * 轮次头上的时刻、模型、费用、失败与否来自会话详情的那一轮(`turns`,按请求 id 对上)。 + * 还在跑的那几轮库里还没有,对话里也没有,末尾各写一行「进行中」。 + */ +export function Conversation({ + id, + turns, + running, + onOpenTurn, + onShowSummary, +}: { + id: string; + /** 会话详情里落了库的轮次 */ + turns: readonly TurnView[]; + /** 还在跑的那几轮(表里的行)和它们是第几轮 */ + running: readonly { row: RequestRow; n: number }[]; + onOpenTurn: (id: number) => void; + onShowSummary: () => void; +}) { + const t = useText(conversationText); + const s = useText(sessionsText); + const key = `transcript:${id}`; + useEffect(() => remember(key), [key]); + /** 上一次取到的那一份:重取回来的轮次没变就换回它(`keepTurns`) */ + const prev = useRef(undefined); + const r = useResource(key, async () => keepTurns(prev.current, await fetchTranscript(id)), { + deps: [turns.length, turns[turns.length - 1]?.id ?? null], + }); + prev.current = r.data; + const data = r.data ?? (unrecorded(r.error) ? null : undefined); + + const list = useMemo(() => (data ? items(data.turns) : []), [data]); + const names = useMemo(() => (data ? toolNames(data.turns) : new Map()), [data]); + const shown = useProgressive(list); + const views = useMemo(() => viewsById(turns), [turns]); + + if (data === undefined) { + return r.error !== undefined ? ( + void r.reload()} retrying={r.loading} /> + ) : ( + + ); + } + if (data !== null && allLost(data.turns)) { + return ( + } + title={t.allLostTitle} + description={t.allLostHint} + action={ + + } + /> + ); + } + if (list.length === 0 && running.length === 0) { + return } title={t.emptyTitle} description={t.emptyHint} />; + } + + const system = data?.system ?? null; + const headOf = (turnId: string): Head => { + const v = views.get(idKey(turnId)); + const cost = v + ? turnCost( + { + cost: v.cost_micros, + estimated: v.cost_estimated, + usage: v.input_tokens != null, + billing: v.billing, + recorded: true, + }, + s, + ) + : null; + return { + at: v?.at_ms ?? null, + model: v?.model ?? "", + // 「—」不写:头上写一个破折号只是噪音 + cost: cost && cost.text !== "—" ? cost.text : null, + costMuted: cost?.muted ?? false, + outcome: outcomeOf(v), + failure: v?.error ? coreText(v.error) : null, + rid: requestIdOf(turnId, v), + }; + }; + /** 还没补齐的时候不画末尾在跑的那几轮:它们会先出现在第 30 轮后面,再跳到最后 */ + const complete = shown === list; + + return ( + +
+ {/* 左边不标「系统」:这一行自己写着「系统提示」 */} + {system !== null && ( +
+ + + +
+ )} +
    + {shown.map((it, i) => { + const first = i === 0 && system === null; + if (it.kind === "lost") { + return ( +
  1. + {t.lostRun(it.from, it.to)} +
  2. + ); + } + return ( +
  3. + +
  4. + ); + })} + {complete && + running.map(({ row, n }, i) => ( +
  5. 0 || shown.length > 0 || system !== null) && "mt-4 border-t border-border pt-4")} + > + +

    {t.afterEnd}

    +
  6. + ))} +
+
+
+ ); +} + +/** + * 长的对话一批一批画:先画 `FIRST_PAINT` 轮,之后每一批 `CHUNK` 轮,批与批之间把主线程 + * 让出来。 + * + * **不用 `useDeferredValue`**(流量表的做法):它把其余的放在一次后台渲染里,渲染能被 + * 打断,提交却是一次 —— 六百轮一万多个节点一次插进页面,实测一个 190ms 的长任务,那一下 + * 点什么都没反应。分批之后每次提交几十轮。 + */ +function useProgressive(list: readonly T[]): readonly T[] { + const [n, setN] = useState(FIRST_PAINT); + const more = n < list.length; + useEffect(() => { + if (!more) return; + const h = setTimeout(() => setN((x) => x + CHUNK), 0); + return () => clearTimeout(h); + }, [more, n]); + return more ? list.slice(0, n) : list; +} + +/** 两份头一样:轮次只在它们变了的时候重画 */ +function sameHead(a: Head, b: Head): boolean { + return ( + a.at === b.at && + a.model === b.model && + a.cost === b.cost && + a.costMuted === b.costMuted && + a.outcome === b.outcome && + a.failure === b.failure && + a.rid === b.rid + ); +} + +/** + * 一轮:头(第几轮、时刻、模型、费用、请求详情),然后是这一轮新加的输入和回答。 + * + * 输入里可能先有一条助手消息:上一轮的回答没能完整显示(响应有缺口),客户端在这一轮的 + * 历史里带着它 —— 那是它说过什么的唯一记录,照常画在这一轮的开头。 + * + * 不生成回答的调用(数 token、压缩上下文,见 `quiet`)只有一行头,写「无对话内容」。 + * + * **`memo`,比的是这一轮的对象和头上那几项**:会话在进行时,每落一轮整段对话重取一次, + * 没变的轮次沿用原来的对象(`keepTurns`),这里就不重画。 + */ +const Turn = memo( + function Turn({ + turn, + n, + head, + onOpen, + }: { + turn: TranscriptTurn; + n: number; + head: Head; + onOpen: (id: number) => void; + }) { + const t = useText(conversationText); + const restart = useMemo(() => (turn.restart ? splitRestart(turn.input) : null), [turn]); + const blocks = useMemo(() => blocksOf(restart ? restart.latest : turn.input), [turn, restart]); + const output = useMemo(() => visibleParts(turn.output), [turn]); + const notes = notesOf(turn, head.outcome); + const reply = output.length > 0 || notes.response.length > 0; + // 失败了的不算:失败的原因要写出来 + if (quiet(turn) && !reply) { + return ( +
+ +
+ ); + } + return ( +
+ {turn.restart && } + +
+ {turn.system_changed !== null && ( + + + + )} + {notes.request.length > 0 && ( + + + + )} + {restart && restart.earlier.length > 0 && } + {blocks.map((b, i) => ( + + ))} + {reply && ( + + {output.length > 0 && } + {notes.response.length > 0 && ( + 0 && "mt-1.5")} /> + )} + + )} +
+
+ ); + }, + (a, b) => a.turn === b.turn && a.n === b.n && a.onOpen === b.onOpen && sameHead(a.head, b.head), +); + +/** + * 一轮的头。还在跑的那一轮(`running`)没有费用,写「进行中」;`note` 是跟在后面的一句 + * 淡的话(没有对话内容的那几轮)。 + */ +function TurnHeader({ + n, + head, + running = false, + note, + onOpen, +}: { + n: number; + head: Head; + running?: boolean; + note?: string; + onOpen: (id: number) => void; +}) { + const t = useText(conversationText); + const s = useText(sessionsText); + const rid = head.rid; + return ( +
+

{t.turnNo(n)}

+ {head.at !== null && {when(head.at)}} + {head.model && {head.model}} + {head.cost !== null && ( + {head.cost} + )} + {running ? ( + + {s.turnRunning} + + ) : head.outcome === "failed" ? ( + + {s.turnFailed} + + ) : head.outcome === "cancelled" ? ( + + {s.turnCancelled} + + ) : null} + {note && {note}} + + {rid !== null && ( + + )} +
+ ); +} + +/** + * 历史重新开始的那一轮上面那条线。**它就是这一轮和上一轮之间的分隔线**,不再另画一条。 + */ +function RestartRule() { + const t = useText(conversationText); + return ( +
+ + {t.restart} + +
+ ); +} + +/** 网格里的一行:左边是谁说的(可以空着),右边是内容 */ +function Line({ label, children }: { label?: string; children: ReactNode }) { + return ( + <> + {/* 和右边第一行的中线对齐:右边的行(折叠行、一行字)都是 24px 高 */} +
{label}
+
{children}
+ + ); +} + +function roleLabel(role: TranscriptRole, t: (typeof conversationText)["zh"]): string { + switch (role) { + case "user": + return t.user; + case "assistant": + return t.assistant; + case "tool": + return t.tool; + case "system": + return t.system; + } +} + +/** 一块:一个角色说的几段,或者几条工具结果(结果那一行自己写着是谁的,左边不标) */ +function BlockLine({ b }: { b: Block }) { + const t = useText(conversationText); + return ( + + + + ); +} + +/** + * 历史重新开始的那一轮带着的、模型上一次开口为止的历史(见 `splitRestart`)。**默认收起**: + * 前面的轮次里多半已经显示过,整段再铺一遍,这一轮新加的那一点就找不到了。 + * + * 展开的仍是同样的两列,左边那一列和外面对齐;淡淡的底色说明它是一整段带过来的历史。 + */ +function Earlier({ messages }: { messages: readonly TranscriptMessage[] }) { + const t = useText(conversationText); + const [open, setOpen] = useState(false); + return ( + <> + + setOpen((o) => !o)}> + {t.earlier(messages.length)} + + + {/* 收起时整格不在网格里:空的一行也会多占一道行距 */} + + + + + ); +} + +function EarlierBlocks({ messages }: { messages: readonly TranscriptMessage[] }) { + const blocks = useMemo(() => blocksOf(messages), [messages]); + return ( +
+
+ {blocks.map((b, i) => ( + + ))} +
+
+ ); +} + +/** 一块里的几段,上下排。相邻的图片和附件排成一行 */ +function Parts({ parts }: { parts: readonly TranscriptPart[] }) { + const runs: (TranscriptPart | TranscriptPart[])[] = []; + for (const p of parts) { + const chip = p.kind === "image" || p.kind === "other"; + const last = runs[runs.length - 1]; + if (chip && Array.isArray(last)) last.push(p); + else runs.push(chip ? [p] : p); + } + return ( +
+ {runs.map((x, i) => + Array.isArray(x) ? ( +
+ {x.map((p, j) => ( + + ))} +
+ ) : ( + + ), + )} +
+ ); +} + +function Part({ p }: { p: TranscriptPart }) { + switch (p.kind) { + case "text": + return ; + case "thinking": + return ; + case "tool_call": + return ; + case "tool_result": + return ; + case "image": + case "other": + return ; + } +} + +/** + * 说的话:原样的空白和换行,不当 Markdown 解析(模型写的 `**` 和 `#` 就是那几个字符)。 + * 长的先显示开头,「展开全部」看整段。 + */ +function Prose({ text, muted = false }: { text: string; muted?: boolean }) { + const t = useText(conversationText); + const [all, setAll] = useState(false); + const head = useMemo(() => clip(text, PROSE), [text]); + const cut = head !== null && !all; + return ( +
+ {/* 上边 2px:一行字的中线和左边标签的中线对齐(标签那一格 24px 高,字的行高 19.5px) */} +

+ {cut ? head : text} + {cut && …} +

+ {head !== null && ( + + )} +
+ ); +} + +/** + * 可以点开的一行:箭头,一行提要,右边一个淡的数。点开的内容在 `Fold` 里。 + * 按钮左右各伸出 6px,悬停的底色比文字宽一圈,文字和上下的正文对齐(同「每轮费用」)。 + */ +function FoldRow({ + open, + onToggle, + meta, + children, +}: { + open: boolean; + onToggle: () => void; + /** 右端那个淡的数(字数) */ + meta?: ReactNode; + children: ReactNode; +}) { + return ( +
+ +
+ ); +} + +/** 点不开的一行,和 `FoldRow` 对齐:箭头的位置空着 */ +function StillRow({ children }: { children: ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +function Fold({ head, meta, children }: { head: ReactNode; meta?: ReactNode; children: ReactNode }) { + const [open, setOpen] = useState(false); + return ( +
+ setOpen((o) => !o)} meta={meta}> + {head} + + {/* 收起时内容不在页面里:几百轮里每个工具结果都画出来,展开前就够重了 */} + +
{children}
+
+
+ ); +} + +/** 一段要读的长文字(系统提示、思考),收起;点开是左边一道竖线引着的原文 */ +function TextFold({ title, text, muted = false }: { title: string; text: string; muted?: boolean }) { + const t = useText(conversationText); + return ( + {title}} meta={t.chars(text.length)}> +
+ +
+
+ ); +} + +/** 思考,默认收起。只有签名没有正文的,说上游没给 */ +function Thinking({ text }: { text: string }) { + const t = useText(conversationText); + if (text.trim() === "") { + return ( + + {t.thinking} + {t.thinkingHidden} + + ); + } + return ; +} + +/** 工具调用:名字和参数的提要一行,点开是排好的参数 */ +function ToolCallRow({ p }: { p: ToolCall }) { + const preview = useMemo(() => argsPreview(p.input), [p.input]); + return ( + + {p.name} + {preview && {preview}} + + } + > + + + ); +} + +/** 点开之后才排:Write 的参数里是整个文件 */ +function Args({ input }: { input: string }) { + const pretty = useMemo(() => prettyJson(input, false), [input]); + return ; +} + +/** 工具结果的第一行非空的字,收起时那一行的提要 */ +function firstLine(text: string): string { + const m = /\S[^\n]*/.exec(text.slice(0, 1000)); + return m ? m[0].trim() : ""; +} + +/** 工具结果:谁的结果、出没出错、开头一行,点开是原文 */ +function ToolResultRow({ p }: { p: ToolResult }) { + const t = useText(conversationText); + const name = useContext(Names).get(p.call_id); + const label = ( + + {name ? t.resultOf({name}) : t.result} + + ); + const error = p.is_error && ( + + {t.error} + + ); + if (p.text === "") { + return ( + + {label} + {error} + {t.noOutput} + + ); + } + return ( + + {label} + {error} + {firstLine(p.text)} + + } + meta={t.chars(p.text.length)} + > + + + ); +} + +/** + * 等宽的一框(工具参数、工具结果),和请求详情「内容」那一页同一个样子:框子最高 + * 320px、自己滚。整个文件那么长的结果先画开头,「展开全部」再画其余。 + */ +function Mono({ text, json, error = false }: { text: string; json: boolean; error?: boolean }) { + const t = useText(conversationText); + const [all, setAll] = useState(false); + const head = useMemo(() => clip(text, MONO), [text]); + const cut = head !== null && !all; + return ( +
+ + {head !== null && ( + + )} +
+ ); +} + +/** 图片和其他附件:一个小方块写清是什么,不画内容(记录里本来也没有) */ +function Chip({ p }: { p: TranscriptPart }) { + const t = useText(conversationText); + if (p.kind !== "image" && p.kind !== "other") return null; + const text = + p.kind === "image" + ? [t.image, p.media_type, p.bytes != null ? size(p.bytes) : null].filter(Boolean).join(" · ") + : p.label; + return ( + + {p.kind === "image" && } + {text} + + ); +} + +/** 一小块说明:显示不出来的部分(虚线框),或者失败(红) */ +function Pill({ tone = "gap", children }: { tone?: "gap" | "error"; children: ReactNode }) { + return ( + + {children} + + ); +} + +function noteText(n: Note, failure: string | null, t: (typeof conversationText)["zh"]): string { + switch (n) { + case "lost": + return t.lost; + case "request_missing": + return t.requestMissing; + case "request_truncated": + return t.requestTruncated; + case "response_missing": + return t.responseMissing; + case "response_failed": + return failure !== null ? t.responseFailed(failure) : t.responseMissing; + case "response_cancelled": + return t.responseCancelled; + case "response_truncated": + return t.responseTruncated; + case "response_unreadable": + return t.responseUnreadable; + } +} + +function Notes({ notes, failure, className }: { notes: readonly Note[]; failure: string | null; className?: string }) { + const t = useText(conversationText); + return ( +
+ {notes.map((n) => ( + + {noteText(n, failure, t)} + + ))} +
+ ); +} + +/** 取数时的样子:几轮的头和几行,落进来时不跳。和浮层的骨架一样等 150ms 才露出来 */ +function ConversationSkeleton() { + return ( + + {Array.from({ length: 3 }, (_, i) => ( +
0 && "mt-4 border-t border-border pt-4")} style={{ opacity: 1 - i * 0.25 }}> + +
+ + + + + + +
+
+ ))} +
+ ); +} diff --git a/src/traffic/SessionPanel.tsx b/src/traffic/SessionPanel.tsx index a4e09bc3..0bf180e2 100644 --- a/src/traffic/SessionPanel.tsx +++ b/src/traffic/SessionPanel.tsx @@ -1,4 +1,4 @@ -import { useRef, type ReactNode } from "react"; +import { memo, useMemo, useRef, useState, type ReactNode } from "react"; import { call } from "@/control"; import { useText } from "@/i18n"; import { cn } from "@/lib/utils"; @@ -11,10 +11,13 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/ui/sheet"; import { Skeleton } from "@/ui/skeleton"; import { ErrorState } from "@/ui/states"; import { StatusDot, StatusLabel } from "@/ui/status-dot"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/ui/tabs"; import { Tip } from "@/ui/tip"; import { PanelHeader, PanelHeaderSkeleton, PanelSkeleton } from "./PanelHeader"; import { SessionCost } from "./SessionCost"; import { sessionsText } from "./Sessions.i18n"; +import { Conversation } from "./Conversation"; +import { unrecorded } from "./transcript"; import { turnCost, type TurnCost } from "./costCell"; import { dur, tokens, when } from "./format"; import { tally } from "./grouping"; @@ -52,7 +55,7 @@ export function SessionSheet({ events: ["request_finished", "request_failed", "request_cancelled"], }); const early = r.data === undefined && rows.length > 0 && unrecorded(r.error); - const now = r.data ? { d: r.data, rows } : early ? { d: null, rows } : undefined; + const now = id === null ? undefined : r.data ? { id, d: r.data, rows } : early ? { id, d: null, rows } : undefined; /** 关上的那一下还要画着刚才那一份:浮层是滑出去的,不是一下没了 */ const last = useRef(now); if (now) last.current = now; @@ -74,7 +77,15 @@ export function SessionSheet({ {t.title} {shown ? ( - + // 换一次会话从「概况」看起,和请求详情换一条从「时间线」看起一样 + ) : r.error !== undefined ? ( // 重试的时候留在这里,按钮转着 —— 换回骨架的话,看起来像是点了没反应 <> @@ -89,13 +100,9 @@ export function SessionSheet({ ); } -/** core 说没有这次会话:它的轮次还一轮都没落库 */ -function unrecorded(e: unknown): boolean { - return typeof e === "object" && e !== null && (e as { code?: unknown }).code === "control.session_not_found"; -} - /** - * 会话详情:几个总数、每轮的输入、每轮的费用。 + * 会话详情,两个标签:「概况」是几个总数、每轮的输入、每轮的费用;「对话」把这次会话按 + * 对话的样子重放出来(`Conversation`)。 * * 瀑布里的一轮就是一条请求(`TurnView.id` 就是请求 id):从「这次任务第 12 轮 * 特别贵」走到「那一条请求到底发了什么」,点一下就到。 @@ -106,11 +113,13 @@ function unrecorded(e: unknown): boolean { * 会话,全靠行。 */ export function SessionPanel({ + id, d, rows, onOpenTurn, onClose, }: { + id: string; d: SessionDetail | null; rows: readonly RequestRow[]; onOpenTurn: (id: number) => void; @@ -118,18 +127,36 @@ export function SessionPanel({ }) { const t = useText(sessionsText); const s = d?.session ?? null; - const turns = d?.turns ?? []; - const recorded = new Set(turns.map((x) => x.id)); - const pending = rows.filter((r) => !recorded.has(r.id)).sort((a, b) => a.atMs - b.atMs); + const turns = d?.turns ?? NO_TURNS; + const pending = useMemo(() => unrecordedRows(turns, rows), [turns, rows]); const n = tally(s, rows, pending); // 走过哪几个上游,按第一次出现的先后。一次任务中途换过上游,这里能看出来。 // 没有发往任何上游的那几轮(被规则拒绝)上游是空的,不算 const providers = [...new Set([...turns.map((x) => x.provider), ...pending.map((r) => r.provider)].filter(Boolean))]; const client = s?.client ?? pending[0]?.client; + const [tab, setTab] = useState("summary"); + /** 「对话」打开过:之后切走也留着(读到哪儿、展开了哪几条都在),见 `PANE` */ + const [seen, setSeen] = useState(false); + const show = (v: Tab) => { + setTab(v); + if (v === "conversation") setSeen(true); + }; + /** 还在跑的那几轮在「对话」末尾各占一行。序号和瀑布里一样:接在落了库的那几轮后面 */ + const running = pending.flatMap((r, i) => (r.state === "in_flight" ? [{ row: r, n: turns.length + i + 1 }] : [])); return ( - <> + show(v as Tab)} className="flex min-h-0 flex-1 flex-col gap-0"> {/* 第二行和请求详情同一个顺序:上游、密钥,然后是这次用过的模型 */} - + + {t.tabSummary} + {t.tabConversation} + + } + > {providers.length > 0 && ( @@ -139,39 +166,99 @@ export function SessionPanel({ {client && {client}} {n.models.join(t.modelSep)} -
-
- } /> - - {/* 库里还没有这次会话:一轮费用都还没算出来 */} - : —} /> - 0 ? ( - - - - - ) : ( - 0 - ) - } - /> -
- {s && ( -

- {t.usage(tokens(s.input_tokens), tokens(s.output_tokens), tokens(s.cache_read_tokens))} -

- )} - - {turns.length > 0 && } - +
+ + + + + {seen && ( + setTab("summary")} + /> + )} +
- + ); } +type Tab = "summary" | "conversation"; + +const NO_TURNS: TurnView[] = []; + +/** 表里这次会话的行里,详情里还没有的那几轮(在跑的、刚落地还没重读的),按时间排 */ +function unrecordedRows(turns: readonly TurnView[], rows: readonly RequestRow[]): RequestRow[] { + const recorded = new Set(turns.map((x) => x.id)); + return rows.filter((r) => !recorded.has(r.id)).sort((a, b) => a.atMs - b.atMs); +} + +/** + * 「概况」:几个总数、每轮的输入、每轮的费用。 + * + * **`memo`**:切到「对话」再切回来时它不重画。几百轮的会话,每轮输入的柱子和每轮费用的 + * 行各几百个,切一次标签就整页重画一遍,那一下是卡的。 + */ +const Summary = memo(function Summary({ + d, + rows, + pending, + onOpenTurn, +}: { + d: SessionDetail | null; + rows: readonly RequestRow[]; + pending: readonly RequestRow[]; + onOpenTurn: (id: number) => void; +}) { + const t = useText(sessionsText); + const s = d?.session ?? null; + const turns = d?.turns ?? NO_TURNS; + const n = tally(s, rows, pending); + return ( + <> +
+ } /> + + {/* 库里还没有这次会话:一轮费用都还没算出来 */} + : —} /> + 0 ? ( + + + + + ) : ( + 0 + ) + } + /> +
+ {s && ( +

+ {t.usage(tokens(s.input_tokens), tokens(s.output_tokens), tokens(s.cache_read_tokens))} +

+ )} + + {turns.length > 0 && } + + + ); +}); + +/** + * 两个标签页**叠在一起,切走的那一页只是藏起来**(`forceMount` 加 `invisible`),不卸掉。 + * 一次几百轮的对话,读到一半切去看费用再回来,还停在原来那一轮、展开的还展开着; + * 卸掉的话从头开始。藏起来用 `visibility` 而不是 `display: none`:后者会丢掉滚动位置。 + * 每一页自己滚。切回来的那一页照常淡入(`motion-fade` 在藏起来时摘掉,回来时重新播一次)。 + */ +const PANE = + "absolute inset-0 overflow-y-auto px-4 pt-4 pb-6 data-[state=inactive]:invisible data-[state=inactive]:animate-none"; + function Stat({ label, value }: { label: string; value: ReactNode }) { return (
@@ -346,7 +433,7 @@ function SessionSkeleton({ onClose }: { onClose: () => void }) { const t = useText(sessionsText); return ( - +
{Array.from({ length: 4 }, (_, i) => ( diff --git a/src/traffic/Sessions.i18n.ts b/src/traffic/Sessions.i18n.ts index 2d8e7ac7..ceefaf89 100644 --- a/src/traffic/Sessions.i18n.ts +++ b/src/traffic/Sessions.i18n.ts @@ -41,6 +41,9 @@ export const sessionsText = messages( // 详情 title: "会话", + /** 详情的两个标签:几个总数和每轮的费用;按对话重放的每一轮 */ + tabSummary: "概况", + tabConversation: "对话", /** `at` 是开始时刻写出来的样子 */ startedAt: (at: string) => `${at} 开始`, turns: "轮次", @@ -99,6 +102,8 @@ export const sessionsText = messages( : `${n} turns have no usage data: the upstream did not report it, or the connection ended before it was reported. Their cost cannot be calculated and is not included in the total.`, title: "Session", + tabSummary: "Summary", + tabConversation: "Conversation", startedAt: (at: string) => `Started ${at}`, turns: "Turns", duration: "Duration", diff --git a/src/traffic/transcript.provisional.ts b/src/traffic/transcript.provisional.ts new file mode 100644 index 00000000..84d94a4b --- /dev/null +++ b/src/traffic/transcript.provisional.ts @@ -0,0 +1,104 @@ +/** + * PROVISIONAL —— core 的 `SessionTranscript`(`GET /sessions/{id}/transcript`)还没有发版。 + * + * 在它发版之前,会话「对话」那一页要的类型和端点都在这一个文件里,照 core 那边定下的 + * 契约手写。**这是全仓库唯一一处手写的控制面类型**:发版以后它们由 core 生成进 + * `src/generated/tw-api.ts`(那个文件永远不手改),这个文件随之删掉。端点已在 core 的 + * main 上(ThinkWatch-Core#251,`CONTROL_API_VERSION` 32),生成的类型和下面一致 —— + * 包括 `TranscriptTurn.id` 是字符串、`TurnView.id` 是数,两边按 `viewsById` 对上。 + * + * 接入步骤(core 发版、lite 升级钉点的那个 PR 里做): + * + * 1. 升级 `src-tauri/Cargo.toml` 里 tw-api 的 tag(和 twcore 一起,协议版本要相等), + * 重新生成类型:`UPDATE_TS=1 cargo test --manifest-path src-tauri/Cargo.toml --test ts_bindings`。 + * 生成的 `ENDPOINTS.SessionTranscript`、`Endpoints.SessionTranscript` 应和下面的 + * `SESSION_TRANSCRIPT`、`SessionTranscriptEndpoint` 一致;不一致的话以生成的为准。 + * 2. 两份白名单一起加上 `SessionTranscript`(`the_frontend_lists_the_same_endpoints` 核对 + * 二者相同):`src/control.ts` 的 `WEBVIEW_ENDPOINTS`、`src-tauri/src/call.rs` 的 + * `webview_endpoints![…]`(`SessionDetail` 后面,两处都留着 TODO)。 + * 3. 截图流水线的 mock 按端点名映射,缺一个就编译不过:`scripts/shots/mock/core.ts` 的 + * `CORE` 里加 `SessionTranscript`(`pnpm typecheck` 连它一起查)。 + * 4. 删掉这个文件,引用它的地方改成:类型从 `@/types` 引(`Transcript`、`TranscriptTurn`、 + * `TranscriptMessage`、`TranscriptPart`、`TranscriptRole`、`TranscriptGap`), + * 取数改成 `call("SessionTranscript", null, id)`。引用它的只有 `Conversation.tsx`、 + * `transcript.ts` 和 `transcript.test.ts`:`grep -rn "transcript.provisional" src`。 + * + * 5. 预览用的 mock(`.claude/preview/full`,不在仓库里)按名字在 `mock/commands.ts` 里先答了 + * 它;接入以后挪进 `mock/core.ts` 的 `CORE`。 + * + * 在那之前,应用里调这个端点会被 Rust 那一层拒绝(不在白名单上),「对话」那一页显示 + * 读取失败。 + */ +import { invoke } from "@tauri-apps/api/core"; + +/** 一次会话按对话的样子重放出来:开头的系统提示,然后一轮一轮 */ +export type Transcript = { + session: string; + /** 第一条可读的那一轮的系统提示。没有就是 `null` */ + system: string | null; + turns: Array; +}; + +/** 一轮 = 一条请求和它的回答 */ +export type TranscriptTurn = { + /** 请求 id。和 `SessionDetail.turns` 同一批 id、同一个顺序 */ + id: string; + /** + * 这条请求的历史没有接着上一条可读的那一轮往下走(例如上下文被压缩过)。这时 + * `input` 是它带着的整段历史,不只是新的那几条 + */ + restart: boolean; + /** 系统提示和上一条可读的那一轮不同了:新的那一份。没变就是 `null` */ + system_changed: string | null; + /** 这条请求里新出现的消息 */ + input: Array; + /** 回答 */ + output: Array; + /** 这一轮有哪些部分显示不出来 */ + gaps: Array; +}; + +export type TranscriptMessage = { role: TranscriptRole; parts: Array }; + +export type TranscriptRole = "user" | "assistant" | "tool" | "system"; + +export type TranscriptPart = + | { kind: "text"; text: string } + /** 可能是空的:响应里只有签名,没有思考的正文 */ + | { kind: "thinking"; text: string } + /** `input` 是参数的 JSON 原文 */ + | { kind: "tool_call"; id: string; name: string; input: string } + | { kind: "tool_result"; call_id: string; text: string; is_error: boolean } + | { kind: "image"; media_type: string | null; bytes: number | null } + | { kind: "other"; label: string }; + +export type TranscriptGap = + | "request_missing" + | "request_truncated" + | "response_missing" + | "response_truncated" + | "response_unreadable"; + +/** 端点的样子,和生成的 `ENDPOINTS` 里一项同形 */ +export const SESSION_TRANSCRIPT = { + name: "SessionTranscript", + method: "GET", + path: "/sessions/{id}/transcript", + params: ["id"], + format: "json", +} as const; + +/** 请求和响应,和生成的 `Endpoints` 里一项同形 */ +export type SessionTranscriptEndpoint = { req: null; res: Transcript }; + +/** + * 取一次会话的对话。接入以后就是 `call("SessionTranscript", null, id)`:走的是同一个 + * `call` 命令、同样的参数,只是端点名还不在 `WebviewEndpoint` 里,类型查不到它。 + */ +export function fetchTranscript(session: string): Promise { + return invoke("call", { + endpoint: SESSION_TRANSCRIPT.name, + params: [session], + req: null, + }); +} diff --git a/src/traffic/transcript.test.ts b/src/traffic/transcript.test.ts new file mode 100644 index 00000000..9b65c0d3 --- /dev/null +++ b/src/traffic/transcript.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from "vitest"; +import type { TurnView } from "@/types"; +import { + allLost, + argsPreview, + blocksOf, + clip, + items, + keepTurns, + notesOf, + outcomeOf, + quiet, + requestIdOf, + splitRestart, + toolNames, + unrecorded, + viewsById, +} from "./transcript"; +import type { Transcript, TranscriptMessage, TranscriptPart, TranscriptTurn } from "./transcript.provisional"; + +const text = (t: string): TranscriptPart => ({ kind: "text", text: t }); +const call = (id: string, name: string, input = "{}"): TranscriptPart => ({ kind: "tool_call", id, name, input }); +const result = (id: string, t = "ok", is_error = false): TranscriptPart => ({ + kind: "tool_result", + call_id: id, + text: t, + is_error, +}); +const msg = (role: TranscriptMessage["role"], ...parts: TranscriptPart[]): TranscriptMessage => ({ role, parts }); + +function turn(id: string, x: Partial = {}): TranscriptTurn { + return { id, restart: false, system_changed: null, input: [], output: [], gaps: [], ...x }; +} + +/** 一轮什么都显示不出来:请求和响应都已超过保留期限 */ +const gone = (id: string) => turn(id, { gaps: ["request_missing", "response_missing"] }); + +function view(id: number, x: Partial = {}): TurnView { + return { + id, + at_ms: 0, + model: "m", + provider: "p", + input_tokens: 1, + output_tokens: 1, + cache_read_tokens: 0, + cost_micros: 1, + duration_ms: 1, + error: null, + cancelled: false, + cost_estimated: false, + billing: "per-token", + ...x, + }; +} + +describe("一轮的输入排成块", () => { + /** Anthropic 把工具结果放在 `user` 消息里:它们不能标成「用户」 */ + it("工具结果单独成块,不跟着消息的角色走", () => { + const b = blocksOf([msg("user", result("a"), result("b"), text("接着看日志"))]); + const shape = b.map((x) => (x.kind === "said" ? `said:${x.role}:${x.parts.length}` : `results:${x.parts.length}`)); + expect(shape).toEqual(["results:2", "said:user:1"]); + }); + + /** OpenAI 的每条结果是一条 `tool` 消息:连着的几条并成一块 */ + it("连着的几条结果消息并成一块,连着的同角色消息并成一块", () => { + const b = blocksOf([ + msg("tool", result("a")), + msg("tool", result("b")), + msg("user", text("一")), + msg("user", text("二")), + msg("assistant", text("三")), + ]); + expect(b.map((x) => (x.kind === "said" ? `${x.role}:${x.parts.length}` : `results:${x.parts.length}`))).toEqual([ + "results:2", + "user:2", + "assistant:1", + ]); + }); + + it("只有空白的正文不成块", () => { + expect(blocksOf([msg("user", text(" \n ")), msg("assistant", text(""))])).toEqual([]); + const b = blocksOf([msg("user", text(" "), text("有字"))]); + expect(b).toEqual([{ kind: "said", role: "user", parts: [text("有字")] }]); + }); +}); + +describe("历史重新开始的那一轮", () => { + it("收起到模型最后一次开口为止,之后的是这一轮新加的", () => { + const input = [ + msg("user", text("摘要")), + msg("assistant", call("c1", "Read")), + msg("user", result("c1")), + msg("assistant", call("c2", "Bash")), + msg("tool", result("c2")), + msg("user", text("测试都过了吗")), + ]; + const { earlier, latest } = splitRestart(input); + expect(earlier).toHaveLength(4); + expect(latest).toEqual(input.slice(4)); + }); + + /** 压缩成一段摘要、一条助手消息都没有:那一段本身就是这一轮的输入,不收起 */ + it("没有助手消息时一条都不收起", () => { + const input = [msg("user", text("摘要")), msg("user", text("继续"))]; + expect(splitRestart(input)).toEqual({ earlier: [], latest: input }); + }); + + it("以助手消息结尾时,新加的为空", () => { + const input = [msg("user", text("问")), msg("assistant", text("答"))]; + expect(splitRestart(input)).toEqual({ earlier: input, latest: [] }); + }); +}); + +describe("工具结果找回工具名", () => { + it("调用在回答里,也可能在重新开始的那一轮带着的历史里", () => { + const names = toolNames([ + turn("1", { output: [text("先读"), call("c1", "Read")] }), + turn("2", { restart: true, input: [msg("assistant", call("c0", "Grep")), msg("user", result("c0"))] }), + ]); + expect(names.get("c1")).toBe("Read"); + expect(names.get("c0")).toBe("Grep"); + expect(names.get("nope")).toBeUndefined(); + }); +}); + +describe("连着几轮都超过保留期限", () => { + it("两轮以上并成一行,序号按原来的轮次", () => { + const list = items([gone("1"), gone("2"), gone("3"), turn("4"), gone("5"), turn("6")]); + expect(list.map((x) => (x.kind === "lost" ? `lost ${x.from}-${x.to}` : `turn ${x.n}`))).toEqual([ + "lost 1-3", + "turn 4", + // 只有一轮的不并:它照样有自己的头 + "turn 5", + "turn 6", + ]); + }); + + it("系统提示变了的那一轮不算什么都没有", () => { + const changed = turn("2", { gaps: ["request_missing", "response_missing"], system_changed: "新的" }); + expect(items([gone("1"), changed, gone("3")]).map((x) => x.kind)).toEqual(["turn", "turn", "turn"]); + }); + + it("整次会话都超过保留期限", () => { + expect(allLost([gone("1"), gone("2")])).toBe(true); + expect(allLost([gone("1"), turn("2")])).toBe(false); + expect(allLost([])).toBe(false); + }); +}); + +describe("显示不出来的部分写成哪几句", () => { + it("请求和响应都没有了,并成一句", () => { + expect(notesOf(gone("1"), "done")).toEqual({ request: ["lost"], response: [] }); + // 失败与否写在头上,这里只说内容没有了 + expect(notesOf(gone("1"), "failed")).toEqual({ request: ["lost"], response: [] }); + }); + + /** 没有响应是因为根本没有,不是超过了保留期限 */ + it("失败的、取消的说结局,不说缺口", () => { + const t = turn("1", { gaps: ["response_missing"] }); + expect(notesOf(t, "failed").response).toEqual(["response_failed"]); + expect(notesOf(t, "cancelled").response).toEqual(["response_cancelled"]); + expect(notesOf(t, "done").response).toEqual(["response_missing"]); + }); + + /** 回答写了一半就断了:后面要说一句,不然像是模型自己停了 */ + it("回答写了一半的失败、取消也要说", () => { + const half = turn("1", { output: [text("先看")] }); + expect(notesOf(half, "failed").response).toEqual(["response_failed"]); + expect(notesOf(half, "cancelled").response).toEqual(["response_cancelled"]); + expect(notesOf(half, "done").response).toEqual([]); + }); + + it("其余的缺口一一对上", () => { + const t = turn("1", { + gaps: ["request_missing", "request_truncated", "response_truncated", "response_unreadable"], + }); + expect(notesOf(t, "done")).toEqual({ + request: ["request_missing", "request_truncated"], + response: ["response_truncated", "response_unreadable"], + }); + }); +}); + +describe("工具调用的提要", () => { + it("参数里第一个非空的字符串", () => { + expect(argsPreview(JSON.stringify({ file_path: "scripts/deploy.sh", limit: 40 }))).toBe("scripts/deploy.sh"); + expect(argsPreview(JSON.stringify({ description: "", command: "pnpm test" }))).toBe("pnpm test"); + }); + + it("字符串数组连成一行,空白压成一个空格", () => { + expect(argsPreview(JSON.stringify({ command: ["bash", "-lc", "ls -la"] }))).toBe("bash -lc ls -la"); + expect(argsPreview(JSON.stringify({ command: "git log\n --oneline" }))).toBe("git log --oneline"); + }); + + it("没有字符串就把 JSON 压成一行,没有参数就什么都不写", () => { + expect(argsPreview(JSON.stringify({ n: 3, deep: { a: 1 } }))).toBe('{"n":3,"deep":{"a":1}}'); + expect(argsPreview("{}")).toBe(""); + }); + + it("不是 JSON 的原样压成一行,太长的截断", () => { + expect(argsPreview("ls\n-la")).toBe("ls -la"); + const long = argsPreview(JSON.stringify({ content: "x".repeat(500) }), 20); + expect(long).toHaveLength(20); + expect(long.endsWith("…")).toBe(true); + }); +}); + +describe("长文本先显示开头", () => { + const limit = { chars: 100, lines: 10 }; + + it("不长的不截,只多出一点的也不截", () => { + expect(clip("短的", limit)).toBeNull(); + expect(clip("x".repeat(140), limit)).toBeNull(); + expect(clip(Array.from({ length: 14 }, () => "行").join("\n"), limit)).toBeNull(); + }); + + it("按字数截,切在一行中间时退到最近的换行", () => { + const s = `${"a".repeat(80)}\n${"b".repeat(200)}`; + expect(clip(s, limit)).toBe("a".repeat(80)); + // 换行太远就不退 + const far = `${"a".repeat(20)}\n${"b".repeat(300)}`; + expect(clip(far, limit)).toBe(`${"a".repeat(20)}\n${"b".repeat(79)}`); + }); + + it("按行数截", () => { + const s = Array.from({ length: 40 }, (_, i) => `第${i + 1}行`).join("\n"); + const head = clip(s, limit)!; + expect(head.split("\n")).toHaveLength(10); + expect(head.endsWith("第10行")).toBe(true); + }); + + it("不把一个字拆成两半", () => { + const s = "a".repeat(99) + "😀".repeat(100); + const head = clip(s, limit)!; + expect(head).toBe("a".repeat(99)); + }); +}); + +describe("重新取回来的对话", () => { + const base: Transcript = { + session: "s1", + system: "sys", + turns: [turn("1", { input: [msg("user", text("问"))], output: [text("答")] }), turn("2", { output: [text("又答")] })], + }; + const fresh = (): Transcript => JSON.parse(JSON.stringify(base)) as Transcript; + + it("一点没变:还是原来那一份", () => { + expect(keepTurns(base, fresh())).toBe(base); + }); + + it("多了一轮:原来的几轮还是原来的对象,新的那一轮是新的", () => { + const next = fresh(); + next.turns.push(turn("3", { output: [text("新")] })); + const kept = keepTurns(base, next); + expect(kept).not.toBe(base); + expect(kept.turns[0]).toBe(base.turns[0]); + expect(kept.turns[1]).toBe(base.turns[1]); + expect(kept.turns[2]).toBe(next.turns[2]); + }); + + /** 过了保留期限:内容没了、缺口多了,那一轮要换成新的 */ + it("内容没了的那一轮换成新的", () => { + const next = fresh(); + next.turns[0] = gone("1"); + const kept = keepTurns(base, next); + expect(kept.turns[0]).toBe(next.turns[0]); + expect(kept.turns[1]).toBe(base.turns[1]); + }); + + it("换了一次会话就是新的那一份", () => { + const other = { ...fresh(), session: "s2" }; + expect(keepTurns(base, other)).toBe(other); + expect(keepTurns(undefined, base)).toBe(base); + }); +}); + +describe("会话还没有落库", () => { + /** 第一轮还在跑:core 说没有这次会话。那不是读取失败 */ + it("只认 core 的那个码", () => { + expect(unrecorded({ code: "control.session_not_found", args: {}, text: "" })).toBe(true); + expect(unrecorded({ code: "control.request_not_found", args: {}, text: "" })).toBe(false); + expect(unrecorded(new Error("control.session_not_found"))).toBe(false); + expect(unrecorded(undefined)).toBe(false); + }); +}); + +/** + * 对话的 id 是字符串,会话详情的是数。**直接拿数当键的话一轮也对不上**,轮次头上就没有 + * 时刻、模型和费用。 + */ +describe("对话的轮次对上会话详情的轮次", () => { + it("按字符串的 id 找到那一轮", () => { + const views = viewsById([view(48123, { model: "claude-sonnet-5" }), view(48124)]); + expect(views.get("48123")?.model).toBe("claude-sonnet-5"); + expect(views.get("48124")?.id).toBe(48124); + expect(views.get("48125")).toBeUndefined(); + }); + + it("对话里的每一轮都找得到,顺序一致", () => { + const transcript = [turn("48123"), turn("48124"), turn("48130")]; + const views = viewsById([view(48123), view(48124), view(48130)]); + expect(transcript.map((t) => views.get(t.id)?.id)).toEqual([48123, 48124, 48130]); + }); +}); + +/** 数 token、压缩上下文这类调用:没有新消息、没有回答、也没有缺口 */ +describe("没有对话内容的一轮", () => { + it("什么都没有的才算", () => { + expect(quiet(turn("1"))).toBe(true); + expect(quiet(turn("1", { input: [msg("user", text(" "))], output: [text("")] }))).toBe(true); + expect(quiet(turn("1", { output: [text("答")] }))).toBe(false); + expect(quiet(turn("1", { input: [msg("user", text("问"))] }))).toBe(false); + }); + + it("有缺口、系统提示变了、历史重新开始的都不算", () => { + expect(quiet(turn("1", { gaps: ["response_missing"] }))).toBe(false); + expect(quiet(turn("1", { system_changed: "新的" }))).toBe(false); + expect(quiet(turn("1", { restart: true }))).toBe(false); + }); +}); + +describe("轮次头", () => { + it("结局和会话瀑布同一套说法", () => { + expect(outcomeOf(undefined)).toBe("done"); + expect(outcomeOf(view(1))).toBe("done"); + expect(outcomeOf(view(1, { error: { code: "x", args: {}, text: "x" } }))).toBe("failed"); + expect(outcomeOf(view(1, { cancelled: true }))).toBe("cancelled"); + }); + + /** 对话里的 id 是字符串,库里那一轮是数:有库里的就用它 */ + it("请求详情打开哪一条", () => { + expect(requestIdOf("48123", view(48123))).toBe(48123); + expect(requestIdOf("48123", undefined)).toBe(48123); + expect(requestIdOf("not-a-number", undefined)).toBeNull(); + expect(requestIdOf("", undefined)).toBeNull(); + }); +}); diff --git a/src/traffic/transcript.ts b/src/traffic/transcript.ts new file mode 100644 index 00000000..e4435887 --- /dev/null +++ b/src/traffic/transcript.ts @@ -0,0 +1,340 @@ +import type { TurnView } from "@/types"; +import type { + Transcript, + TranscriptGap, + TranscriptMessage, + TranscriptPart, + TranscriptRole, + TranscriptTurn, +} from "./transcript.provisional"; + +/** + * 会话「对话」那一页的纯逻辑:把 core 给的一轮一轮排成要画的样子。界面在 `Conversation.tsx`。 + */ + +export type ToolCall = Extract; +export type ToolResult = Extract; + +/** 一轮的 id 怎么比:对话里是字符串,`TurnView` 里是数 */ +export const idKey = (id: string | number): string => String(id); + +/** + * 会话详情的轮次按请求 id 排成表,对话的每一轮用自己的 id 来找(`idKey`)。**两边的 id + * 类型不一样**:`TranscriptTurn.id` 是字符串,`TurnView.id` 是数。直接拿数当键的话, + * 一轮也对不上,轮次头上就没有时刻、模型和费用。 + */ +export function viewsById(turns: readonly TurnView[]): Map { + return new Map(turns.map((v) => [idKey(v.id), v])); +} + +/** + * core 说没有这次会话:它的轮次还一轮都没落库(第一轮还在跑)。会话详情和对话都这样答, + * 这不是读取失败。 + */ +export function unrecorded(e: unknown): boolean { + return typeof e === "object" && e !== null && (e as { code?: unknown }).code === "control.session_not_found"; +} + +/** 这一轮的结局,和会话瀑布同一套说法 */ +export type Outcome = "done" | "failed" | "cancelled"; + +export function outcomeOf(v: TurnView | undefined): Outcome { + if (!v) return "done"; + return v.error ? "failed" : v.cancelled ? "cancelled" : "done"; +} + +/** 打开请求详情用的 id:库里那一轮的,没有就按对话里的那个读 */ +export function requestIdOf(id: string, v: TurnView | undefined): number | null { + if (v) return v.id; + const n = Number(id); + return id.trim() !== "" && Number.isSafeInteger(n) ? n : null; +} + +/** 只有空白的正文不画:画出来是一个空段落 */ +export function visibleParts(parts: readonly TranscriptPart[]): TranscriptPart[] { + return parts.filter((p) => p.kind !== "text" || p.text.trim() !== ""); +} + +/** + * 画出来的一块:一个角色说的几段,或者几条工具结果。 + * + * **工具结果单独成块,不跟着消息的角色走。**Anthropic 把结果放在 `user` 消息里,OpenAI + * 放在 `tool` 消息里 —— 都标成「用户」的话,读的人会以为那是人打的字。结果那一行自己 + * 写着是谁的结果(「Read 的结果」),所以块上不再标角色。 + * + * 相邻的、同一个角色的几段并成一块:一条消息里的几段,或者连着的两条同角色消息。 + */ +export type Block = + | { kind: "said"; role: TranscriptRole; parts: TranscriptPart[] } + | { kind: "results"; parts: ToolResult[] }; + +export function blocksOf(messages: readonly TranscriptMessage[]): Block[] { + const out: Block[] = []; + for (const m of messages) { + for (const p of visibleParts(m.parts)) { + const last = out[out.length - 1]; + if (p.kind === "tool_result") { + if (last?.kind === "results") last.parts.push(p); + else out.push({ kind: "results", parts: [p] }); + } else if (last?.kind === "said" && last.role === m.role) { + last.parts.push(p); + } else { + out.push({ kind: "said", role: m.role, parts: [p] }); + } + } + } + return out; +} + +/** + * 历史重新开始的那一轮(`restart`),`input` 是它带着的整段历史。**收起的是模型上一次 + * 开口为止的那一段**,之后的才是这一轮新加的:工具结果、用户新说的话。 + * + * 按最后一条助手消息切,不按「最后一条消息」切:OpenAI 的格式里几条工具结果是几条 + * 消息,只留最后一条会漏掉前面的。历史里一条助手消息都没有(压缩成了一段摘要)时 + * 不收起:那一段本身就是这一轮的输入。 + */ +export function splitRestart(input: readonly TranscriptMessage[]): { + earlier: TranscriptMessage[]; + latest: TranscriptMessage[]; +} { + let last = -1; + input.forEach((m, i) => { + if (m.role === "assistant") last = i; + }); + return { earlier: input.slice(0, last + 1), latest: input.slice(last + 1) }; +} + +/** + * 每个工具调用的 id 对应的工具名。结果那一行写「Read 的结果」靠它:结果里只有调用的 + * id,调用在上一轮的回答里(历史重新开始的那一轮,也可能在它自己带着的历史里)。 + */ +export function toolNames(turns: readonly TranscriptTurn[]): Map { + const names = new Map(); + const add = (parts: readonly TranscriptPart[]) => { + for (const p of parts) if (p.kind === "tool_call") names.set(p.id, p.name); + }; + for (const t of turns) { + for (const m of t.input) add(m.parts); + add(t.output); + } + return names; +} + +/** + * 这一轮没有对话内容:不生成回答的调用(数 token、压缩上下文)—— 没有新消息、没有回答、 + * 也没有缺口。界面上只画一行头,不画「什么都没有」的正文。 + */ +export function quiet(t: TranscriptTurn): boolean { + return ( + !t.restart && + t.system_changed === null && + t.gaps.length === 0 && + visibleParts(t.output).length === 0 && + t.input.every((m) => visibleParts(m.parts).length === 0) + ); +} + +/** 这一轮什么都显示不出来:请求和响应都已超过保留期限 */ +export function lost(t: TranscriptTurn): boolean { + return ( + t.gaps.includes("request_missing") && + t.gaps.includes("response_missing") && + t.input.length === 0 && + t.output.length === 0 && + t.system_changed === null + ); +} + +/** + * 要画的一项:一轮,或者连着好几轮都已超过保留期限 —— 那几轮并成一行。 + * + * 保留期限按时间算,跨过界线的会话前面一截全是空的:一轮一个「已超过保留期限」, + * 几十行一模一样的话把能看的那几轮挤到了后面。只有一轮的不并:单独一轮照样有它的头。 + * + * `n` 是第几轮,从 1 数起。对话里的轮次和会话详情的是同一批、同一个顺序,所以和 + * 「每轮费用」里的序号对得上。 + */ +export type Item = + | { kind: "turn"; turn: TranscriptTurn; n: number } + | { kind: "lost"; from: number; to: number; key: string }; + +export function items(turns: readonly TranscriptTurn[]): Item[] { + const out: Item[] = []; + let i = 0; + while (i < turns.length) { + let j = i; + while (j < turns.length && lost(turns[j]!)) j++; + if (j - i >= 2) { + out.push({ kind: "lost", from: i + 1, to: j, key: `lost:${turns[i]!.id}` }); + i = j; + } else { + out.push({ kind: "turn", turn: turns[i]!, n: i + 1 }); + i++; + } + } + return out; +} + +/** 整次会话的内容都已超过保留期限 */ +export function allLost(turns: readonly TranscriptTurn[]): boolean { + return turns.length > 0 && turns.every(lost); +} + +/** + * 一轮里显示不出来的部分,写成哪几句话、写在哪儿(请求的写在输入前面,响应的写在 + * 回答后面)。 + * + * · 请求和响应都没有了:并成一句「此轮内容已超过保留期限」。 + * · **失败的、客户端先断开的,说的是结局,不是缺口**:没有响应是因为根本没有,不是 + * 超过了保留期限 —— 写失败的原因。回答写了一半就断的,也要在那一半后面说一句,不然 + * 读起来像是模型话说到一半自己停了。 + */ +export type Note = + | "lost" + | "request_missing" + | "request_truncated" + | "response_missing" + | "response_failed" + | "response_cancelled" + | "response_truncated" + | "response_unreadable"; + +export function notesOf(t: TranscriptTurn, outcome: Outcome): { request: Note[]; response: Note[] } { + const has = (g: TranscriptGap) => t.gaps.includes(g); + if (has("request_missing") && has("response_missing")) return { request: ["lost"], response: [] }; + const request: Note[] = []; + if (has("request_missing")) request.push("request_missing"); + if (has("request_truncated")) request.push("request_truncated"); + const response: Note[] = []; + if (outcome === "failed") response.push("response_failed"); + else if (outcome === "cancelled") response.push("response_cancelled"); + else if (has("response_missing")) response.push("response_missing"); + if (has("response_truncated")) response.push("response_truncated"); + if (has("response_unreadable")) response.push("response_unreadable"); + return { request, response }; +} + +/** + * 工具调用收起时那一行的提要。**参数里第一个非空的字符串**:多半就是那个最说明问题的值 + * —— 路径、命令、搜索词。不按工具名挑字段:工具是客户端自己定义的,挑不完。字符串数组 + * (OpenAI 的 shell 把命令拆成几段)连成一行。都没有就把 JSON 压成一行。 + * + * 只看开头一截:Write 的参数里是整个文件,没必要整段处理。 + */ +export function argsPreview(input: string, max = 160): string { + let v: unknown; + try { + v = JSON.parse(input); + } catch { + return oneLine(input, max); + } + if (v !== null && typeof v === "object" && !Array.isArray(v)) { + const values = Object.values(v); + if (values.length === 0) return ""; + for (const x of values) { + if (typeof x === "string" && x.trim() !== "") return oneLine(x, max); + if (Array.isArray(x) && x.length > 0 && x.every((s) => typeof s === "string")) return oneLine(x.join(" "), max); + } + } + return oneLine(JSON.stringify(v), max); +} + +function oneLine(s: string, max: number): string { + const flat = s.slice(0, max * 4).replace(/\s+/g, " ").trim(); + return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; +} + +/** + * 长文本先显示开头。返回开头那一截;不用截就返回 `null`。 + * + * **超过上限一半以上才截**:只多出几行的也收起来,点开看到的就是那几行,白点一下。 + * 截的时候按字数和行数取短的那个;切在一行中间时,往回退到最近的换行(不退太远)。 + */ +export function clip(text: string, limit: { chars: number; lines: number }): string | null { + const lineCap = Math.ceil(limit.lines * 1.5); + if (text.length <= limit.chars * 1.5 && !hasMoreLines(text, lineCap)) return null; + let end = Math.min(text.length, limit.chars); + // 第 `lines` 行的行尾 + let at = -1; + for (let i = 0; i < limit.lines; i++) { + at = text.indexOf("\n", at + 1); + if (at < 0 || at >= end) break; + if (i === limit.lines - 1) end = at; + } + const nl = text.lastIndexOf("\n", end); + if (end < text.length && text.charAt(end) !== "\n" && nl > end * 0.7) end = nl; + // 不把一个字拆成两半(代理对) + const c = text.charCodeAt(end - 1); + if (c >= 0xd800 && c <= 0xdbff) end -= 1; + return text.slice(0, end).trimEnd(); +} + +/** 换行多于 `n` 个。数到 `n` 就停:一个几 MB 的结果不用整段数完 */ +function hasMoreLines(text: string, n: number): boolean { + let at = -1; + for (let i = 0; i <= n; i++) { + at = text.indexOf("\n", at + 1); + if (at < 0) return false; + } + return true; +} + +/** + * 一轮的指纹,用来判断重新取回来的这一轮是不是还是原来那一份。 + * + * **落了库的一轮不会改写**:会变的只有「还在不在、全不全」—— 过了保留期限,内容没了、 + * 缺口多了;历史重新开始、系统提示是否变了,是相对上一条可读的那一轮说的,前面的轮次 + * 没了它也会跟着变。所以比这几样和各段的长度,不逐字比正文。 + */ +function signature(t: TranscriptTurn): string { + let parts = 0; + let size = 0; + const count = (ps: readonly TranscriptPart[]) => { + for (const p of ps) { + parts += 1; + size += partSize(p); + } + }; + for (const m of t.input) count(m.parts); + count(t.output); + return [t.restart ? 1 : 0, t.system_changed?.length ?? -1, t.gaps.join(","), t.input.length, parts, size].join("|"); +} + +function partSize(p: TranscriptPart): number { + switch (p.kind) { + case "text": + case "thinking": + return p.text.length; + case "tool_call": + return p.name.length + p.input.length; + case "tool_result": + return p.text.length + (p.is_error ? 1 : 0); + case "image": + return p.bytes ?? 0; + case "other": + return p.label.length; + } +} + +/** + * 重新取回来的对话里,**没变的轮次换回原来那个对象**。 + * + * 会话还在进行时,每落一轮就重取一次整段对话;回来的每一轮都是新对象,按引用比较的 + * 话几百轮一起重画,而真正新的只有末尾那一轮。整段都没变就返回原来那一份。 + */ +export function keepTurns(prev: Transcript | undefined, next: Transcript): Transcript { + if (!prev || prev.session !== next.session) return next; + const old = new Map(prev.turns.map((t) => [t.id, t])); + let same = prev.system === next.system && prev.turns.length === next.turns.length; + const turns = next.turns.map((t, i) => { + const o = old.get(t.id); + if (o && signature(o) === signature(t)) { + if (prev.turns[i] !== o) same = false; + return o; + } + same = false; + return t; + }); + return same ? prev : { ...next, turns }; +} From 510de3ce956d413ae82484ea9a567e05f9f7281c Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:59:03 +0800 Subject: [PATCH 03/21] feat(plugins): native confirmation for install, replace and approve Installing a plugin, replacing its code and approving a changed file now go through three Tauri commands (plugin_install, plugin_replace_source, plugin_approve). Each one re-reads the code through core's PluginInspect instead of trusting what the webview says about it, shows a native OS dialog with the plugin name, its permissions in plain words and the SHA-256 prefix, and only then calls CreatePlugin, ReplacePluginSource or ApprovePluginFile. Cancel is the default button, Esc cancels, and only one confirmation can be open at a time. macOS uses NSAlert, Windows MessageBoxW and Linux a GTK MessageDialog, all already linked by the app, so there is no new dependency. Plugin names are stripped of control, zero-width and bidi characters before they reach the dialog. The other plugin endpoints join the webview whitelist through a provisional group in call.rs, described in plugins/wire.rs until core ships them in tw-api. CreatePlugin, ReplacePluginSource and ApprovePluginFile stay out of it, and a test says so. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/call.rs | 31 +- src-tauri/src/lib.rs | 4 + src-tauri/src/plugins/confirm/linux.rs | 30 ++ src-tauri/src/plugins/confirm/linux/dialog.rs | 38 ++ src-tauri/src/plugins/confirm/macos.rs | 67 +++ src-tauri/src/plugins/confirm/mod.rs | 112 +++++ src-tauri/src/plugins/confirm/windows.rs | 38 ++ src-tauri/src/plugins/mod.rs | 331 +++++++++++++++ src-tauri/src/plugins/wire.rs | 282 +++++++++++++ src-tauri/src/plugins/words.rs | 392 ++++++++++++++++++ src/control.ts | 28 +- 11 files changed, 1348 insertions(+), 5 deletions(-) create mode 100644 src-tauri/src/plugins/confirm/linux.rs create mode 100644 src-tauri/src/plugins/confirm/linux/dialog.rs create mode 100644 src-tauri/src/plugins/confirm/macos.rs create mode 100644 src-tauri/src/plugins/confirm/mod.rs create mode 100644 src-tauri/src/plugins/confirm/windows.rs create mode 100644 src-tauri/src/plugins/mod.rs create mode 100644 src-tauri/src/plugins/wire.rs create mode 100644 src-tauri/src/plugins/words.rs diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index 2de91495..520b84c8 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -11,6 +11,10 @@ //! 命令拼好再给)。路径参数由 `tw_api::fill` 做百分号编码,所以界面给的名字 //! 只能是一段,拼不出别的路径。 //! +//! **插件的三步有意不给**:安装(`CreatePlugin`)、更换代码(`ReplacePluginSource`)、确认变了 +//! 的文件(`ApprovePluginFile`)。界面里的脚本自己就能点网页上的「确定」,所以这三步只能 +//! 经过 `plugins` 里的命令,在系统原生对话框里确认(I12)。 +//! //! 做的事不止转发的命令(打开浏览器、写剪贴板、拼概览)仍然各是一个命令。 //! 其中有三个端点**只能经过那些命令**,因为这台机器上的客户端要一起照顾到:删密钥 //! (接管着的客户端的那把删不得)、换密钥(新值要同步进它的配置)、为客户端发密钥 @@ -24,10 +28,10 @@ use crate::control::ControlClient; use crate::error::{CmdError, Out}; macro_rules! webview_endpoints { - ($($name:ident),* $(,)?) => { + (core: [$($name:ident),* $(,)?], provisional: [$($p:ident),* $(,)?] $(,)?) => { /// 界面能直接调的端点,按名字。和 `src/control.ts` 的 /// `WEBVIEW_ENDPOINTS` 是同一份(测试核对)。 - pub const ALLOWED: &[&str] = &[$(stringify!($name)),*]; + pub const ALLOWED: &[&str] = &[$(stringify!($name),)* $(stringify!($p),)*]; /// 调一个控制面端点。`params` 按顺序填路径参数,`req` 是请求(没有就是 /// `null`)。 @@ -41,6 +45,7 @@ macro_rules! webview_endpoints { let params: Vec<&str> = params.iter().map(String::as_str).collect(); match endpoint.as_str() { $(stringify!($name) => relay::(&state.control, ¶ms, req).await,)* + $(stringify!($p) => relay::(&state.control, ¶ms, req).await,)* _ => Err(CmdError::plain(format!( "The interface cannot call the control-plane endpoint `{endpoint}`." ))), @@ -49,7 +54,8 @@ macro_rules! webview_endpoints { }; } -webview_endpoints![ +webview_endpoints! { + core: [ // 进程与概览 Interfaces, Overview, @@ -132,7 +138,20 @@ webview_endpoints![ ChatgptResets, UseChatgptReset, ZaiLoginStatus, -]; + ], + // PROVISIONAL:插件。钉着的 tw-api 里还没有这几个端点,描述在 `plugins::wire`;core 发版、 + // 钉点升上去之后挪进上面那一组(`ep::Plugins` …),删掉这一组和 `plugins::wire` 里的端点 + provisional: [ + Plugins, + PluginInspect, + UpdatePlugin, + PluginSourceDiff, + DeletePlugin, + ReorderPlugins, + TrialPlugin, + PluginLogs, + ], +} /// 请求按这个端点的类型读一遍再发:**界面发来的形状不对,在这里就停下**, /// 不把一个 core 读不了的请求送过去。 @@ -169,6 +188,10 @@ mod tests { "DeleteKey", "RotateKey", "ClientKey", + // 插件的三步要在原生对话框里确认(I12),见模块说明 + "CreatePlugin", + "ReplacePluginSource", + "ApprovePluginFile", ] { assert!(!ALLOWED.contains(&name), "{name}"); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3a36687a..86c093ad 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,6 +45,7 @@ pub mod mcp; pub mod memcheck; pub mod menubar; pub mod notices; +pub mod plugins; pub mod prefs; /// 建只有自己能读的数据目录,见模块头上 pub mod private_dir; @@ -245,6 +246,9 @@ pub fn run() { mcp::mcp_targets, mcp::plan_mcp, mcp::apply_mcp, + plugins::plugin_install, + plugins::plugin_replace_source, + plugins::plugin_approve, scan::scan_clients, diagnostics::save_diagnostics, ]) diff --git a/src-tauri/src/plugins/confirm/linux.rs b/src-tauri/src/plugins/confirm/linux.rs new file mode 100644 index 00000000..5dba9519 --- /dev/null +++ b/src-tauri/src/plugins/confirm/linux.rs @@ -0,0 +1,30 @@ +//! Linux:GTK 的 `MessageDialog`(Tauri 在 Linux 上本来就是 GTK 的窗口)。 +//! +//! 主文字和次要文字都是纯文本:`MessageDialog::new` 按 `%s` 填,`secondary-text` 不开 +//! `secondary-use-markup`,插件名里写的标记不会被解释。 +//! +//! 弹框那一段在 [`dialog`] 里(`linux/dialog.rs`),只用 gtk。 + +use tauri::Manager; + +use super::super::words::Ask; + +mod dialog; + +/// 在主线程上调(`run_on_main_thread`)。主窗口是对话框的主人:对话框开着时它不接受点击 +pub(super) fn confirm(app: &tauri::AppHandle, ask: &Ask) -> bool { + let parent = app + .get_webview_window("main") + .and_then(|w| w.gtk_window().ok()); + dialog::run( + parent.as_ref(), + &dialog::Text { + title: &ask.title, + message: &ask.message, + detail: &ask.detail, + accept: &ask.accept, + cancel: tr!("取消", "Cancel"), + }, + ask.danger, + ) +} diff --git a/src-tauri/src/plugins/confirm/linux/dialog.rs b/src-tauri/src/plugins/confirm/linux/dialog.rs new file mode 100644 index 00000000..506ca705 --- /dev/null +++ b/src-tauri/src/plugins/confirm/linux/dialog.rs @@ -0,0 +1,38 @@ +//! 弹框那一段,**只用 gtk**(不引 Tauri、不引 `crate::`):在别的平台上能原样摘进一个小 +//! crate 做类型检查(GTK 的库不在这台机器上时,用假的 pkg-config 描述文件就够 `cargo check`)。 + +use gtk::prelude::*; + +pub struct Text<'a> { + pub title: &'a str, + pub message: &'a str, + pub detail: &'a str, + pub accept: &'a str, + pub cancel: &'a str, +} + +/// 弹出来,`run` 自己转一个消息循环,直到用户回答。**默认是取消**:回车不会变成一次确认 +pub fn run(parent: Option<>k::ApplicationWindow>, text: &Text<'_>, danger: bool) -> bool { + let dialog = gtk::MessageDialog::new( + parent, + gtk::DialogFlags::MODAL | gtk::DialogFlags::DESTROY_WITH_PARENT, + if danger { + gtk::MessageType::Error + } else { + gtk::MessageType::Warning + }, + gtk::ButtonsType::None, + text.message, + ); + dialog.set_title(text.title); + dialog.set_secondary_text(Some(text.detail)); + dialog.add_button(text.cancel, gtk::ResponseType::Cancel); + let accept = dialog.add_button(text.accept, gtk::ResponseType::Accept); + if danger { + accept.style_context().add_class("destructive-action"); + } + dialog.set_default_response(gtk::ResponseType::Cancel); + let answer = dialog.run(); + dialog.close(); + answer == gtk::ResponseType::Accept +} diff --git a/src-tauri/src/plugins/confirm/macos.rs b/src-tauri/src/plugins/confirm/macos.rs new file mode 100644 index 00000000..a5819be0 --- /dev/null +++ b/src-tauri/src/plugins/confirm/macos.rs @@ -0,0 +1,67 @@ +//! macOS:`NSAlert`。和退出前的确认(`menubar::macos::confirm_quit`)同一个做法。 + +use std::ptr::NonNull; + +use block2::RcBlock; +use objc2::MainThreadMarker; +use objc2_app_kit::{ + NSAlert, NSAlertFirstButtonReturn, NSAlertSecondButtonReturn, NSAlertStyle, NSApplication, + NSEvent, NSEventMask, +}; +use objc2_foundation::NSString; + +use super::super::words::Ask; + +/// Esc 的键码 +const KEY_ESCAPE: u16 = 53; + +/// 投到主线程上问。**走 GCD 的主队列**:菜单栏的菜单开着时主线程在事件跟踪模式, +/// 别的投递方式要等菜单关了才执行 +pub(super) fn show(ask: Ask, tx: tokio::sync::oneshot::Sender) { + dispatch2::DispatchQueue::main().exec_async(move || { + let mtm = MainThreadMarker::new().expect("主队列就在主线程上"); + let _ = tx.send(run(mtm, &ask)); + }); +} + +fn run(mtm: MainThreadMarker, ask: &Ask) -> bool { + let app = NSApplication::sharedApplication(mtm); + #[allow(deprecated)] + app.activateIgnoringOtherApps(true); + let alert = NSAlert::new(mtm); + alert.setAlertStyle(if ask.danger { + NSAlertStyle::Critical + } else { + NSAlertStyle::Warning + }); + // 两段都是纯文本:NSAlert 不解释标记,插件名里写什么都只是字 + alert.setMessageText(&NSString::from_str(&ask.message)); + alert.setInformativeText(&NSString::from_str(&ask.detail)); + // **取消是默认按钮**(回车)。要明说:标题恰好是英文「Cancel」时,AppKit 给它配的是 + // Esc,对话框里就没有默认按钮了 + let cancel = alert.addButtonWithTitle(&NSString::from_str(tr!("取消", "Cancel"))); + cancel.setKeyEquivalent(&NSString::from_str("\r")); + let accept = alert.addButtonWithTitle(&NSString::from_str(&ask.accept)); + // 确认没有快捷键:只能用鼠标点(或者 Tab 过去按空格) + accept.setKeyEquivalent(&NSString::from_str("")); + if ask.danger { + accept.setHasDestructiveAction(true); + } + // Esc 也是取消。一个按钮只有一个快捷键,所以在对话框开着的这段时间里单独接住它 + let esc = RcBlock::new(move |event: NonNull| -> *mut NSEvent { + if unsafe { event.as_ref() }.keyCode() == KEY_ESCAPE { + NSApplication::sharedApplication(mtm).stopModalWithCode(NSAlertFirstButtonReturn); + return std::ptr::null_mut(); + } + event.as_ptr() + }); + let monitor = unsafe { + NSEvent::addLocalMonitorForEventsMatchingMask_handler(NSEventMask::KeyDown, &esc) + }; + let answer = alert.runModal(); + if let Some(monitor) = monitor { + unsafe { NSEvent::removeMonitor(&monitor) }; + } + // 只有点了确认才算:模态被系统收掉之类别的返回值一律当没点 + answer == NSAlertSecondButtonReturn +} diff --git a/src-tauri/src/plugins/confirm/mod.rs b/src-tauri/src/plugins/confirm/mod.rs new file mode 100644 index 00000000..9bb9a57a --- /dev/null +++ b/src-tauri/src/plugins/confirm/mod.rs @@ -0,0 +1,112 @@ +//! 系统原生的确认对话框(I12)。 +//! +//! **为什么不能在网页里问。**安装插件、更换代码、确认文件变更,是把一段会改写每一个请求的 +//! 代码放进网关。网页里的「确定」,网页里的脚本自己就能点 —— 一段混进页面的脚本可以一声 +//! 不响地装上一个插件。系统的对话框画在网页之外,脚本点不到、键盘事件也伪造不到。 +//! +//! 三个平台各用自己的:macOS 是 `NSAlert`,Windows 是 `MessageBoxW`,Linux 是 GTK 的 +//! `MessageDialog`(都是应用本来就链接着的东西,不多一个依赖)。 +//! +//! 共同的规矩: +//! +//! - **默认按钮是「取消」**:对话框弹出的那一刻,用户的手可能正按在回车上。确认要明确地点。 +//! - Esc、关窗、对话框没弹出来(主线程不接、窗口不在)一律算取消。 +//! - **一次只问一件事**:上一个还开着时,再来的请求直接失败,不在背后排队 —— 否则一段脚本 +//! 能连发十次,用户关掉一个又冒出一个。 + +use std::sync::atomic::{AtomicBool, Ordering}; + +use super::words::Ask; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +// 只用 std 和 windows-sys,不引 `crate::`:在别的平台上能摘进一个小 crate 交叉编译检查 +#[cfg(windows)] +mod windows; + +/// 正在问。一次只问一件事(见模块说明) +static ASKING: AtomicBool = AtomicBool::new(false); + +/// 问的时候占着,问完(包括中途出错、future 被丢掉)放开 +struct Turn; + +impl Turn { + fn take() -> Option { + ASKING + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + .then_some(Turn) + } +} + +impl Drop for Turn { + fn drop(&mut self) { + ASKING.store(false, Ordering::SeqCst); + } +} + +/// 已经有一个确认对话框开着 +#[derive(Debug)] +pub struct Busy; + +/// 问一句,等用户回答。`Ok(true)` 是点了确认;取消、Esc、弹不出来都是 `Ok(false)`。 +pub async fn ask(app: &tauri::AppHandle, ask: Ask) -> Result { + let Some(_turn) = Turn::take() else { + return Err(Busy); + }; + let (tx, rx) = tokio::sync::oneshot::channel::(); + show(app, ask, tx); + // 发送端被丢掉(主线程没接、窗口没了)就是没点确认 + Ok(rx.await.unwrap_or(false)) +} + +#[cfg(target_os = "macos")] +fn show(_app: &tauri::AppHandle, ask: Ask, tx: tokio::sync::oneshot::Sender) { + macos::show(ask, tx); +} + +#[cfg(windows)] +fn show(app: &tauri::AppHandle, ask: Ask, tx: tokio::sync::oneshot::Sender) { + use tauri::Manager; + // 主窗口是对话框的主人:对话框开着时它不接受点击,对话框也浮在它上面 + let owner = app + .get_webview_window("main") + .and_then(|w| w.hwnd().ok()) + .map_or(0, |h| h.0 as isize); + let text = format!("{}\n\n{}\n\n{}", ask.message, ask.detail, ask.ok_hint); + // 消息框自己转一个消息循环,阻塞调用它的线程:不占异步运行时的线程 + tauri::async_runtime::spawn_blocking(move || { + let _ = tx.send(windows::confirm(owner, &ask.title, &text, ask.danger)); + }); +} + +#[cfg(target_os = "linux")] +fn show(app: &tauri::AppHandle, ask: Ask, tx: tokio::sync::oneshot::Sender) { + let a = app.clone(); + // GTK 只能在主线程上用。投递失败时 `tx` 跟着闭包一起被丢掉,那边就当取消 + let _ = app.run_on_main_thread(move || { + let _ = tx.send(linux::confirm(&a, &ask)); + }); +} + +#[cfg(not(any(target_os = "macos", windows, target_os = "linux")))] +fn show(_app: &tauri::AppHandle, _ask: Ask, tx: tokio::sync::oneshot::Sender) { + // 没有原生对话框的平台:不确认就不写 + let _ = tx.send(false); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_one_question_at_a_time() { + let first = Turn::take(); + assert!(first.is_some()); + assert!(Turn::take().is_none(), "开着一个的时候第二个要失败"); + drop(first); + assert!(Turn::take().is_some(), "关掉之后又能问"); + } +} diff --git a/src-tauri/src/plugins/confirm/windows.rs b/src-tauri/src/plugins/confirm/windows.rs new file mode 100644 index 00000000..bf386a00 --- /dev/null +++ b/src-tauri/src/plugins/confirm/windows.rs @@ -0,0 +1,38 @@ +//! Windows:`MessageBoxW`。 +//! +//! **只用 std 和 windows-sys**(不引 `crate::`):在 macOS 上能原样摘进一个小 crate,对着 +//! Windows 的目标跑 clippy。 +//! +//! 不用 `TaskDialogIndirect`(能把按钮写成「安装」):它只在 Common Controls v6 里有, +//! 进程没带那份清单时(测试程序就没有)连启动都失败。消息框的按钮是系统语言的「确定 / +//! 取消」,所以正文最后补一句选「确定」是做什么(`Ask::ok_hint`)。 + +use windows_sys::Win32::UI::WindowsAndMessaging::{ + IDOK, MB_DEFBUTTON2, MB_ICONERROR, MB_ICONWARNING, MB_OKCANCEL, MB_SETFOREGROUND, MessageBoxW, +}; + +/// 问一句,阻塞到用户回答。`owner` 是主窗口的 HWND(没有就是 0):对话框开着时它不接受 +/// 点击。**默认按钮是「取消」**(`MB_DEFBUTTON2`),回车不会变成一次确认。 +pub fn confirm(owner: isize, title: &str, text: &str, danger: bool) -> bool { + let title = wide(title); + let text = wide(text); + let icon = if danger { MB_ICONERROR } else { MB_ICONWARNING }; + // SAFETY: 两段都是以 0 结尾的 UTF-16,活到调用返回之后;owner 是一个窗口句柄或 0 + let answer = unsafe { + MessageBoxW( + owner as _, + text.as_ptr(), + title.as_ptr(), + MB_OKCANCEL | MB_DEFBUTTON2 | MB_SETFOREGROUND | icon, + ) + }; + answer == IDOK +} + +/// 以 0 结尾的 UTF-16。**文字里的 0 换成空格**:不然消息框只显示到那里为止 +fn wide(s: &str) -> Vec { + s.encode_utf16() + .map(|u| if u == 0 { u16::from(b' ') } else { u }) + .chain(std::iter::once(0)) + .collect() +} diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs new file mode 100644 index 00000000..796f6329 --- /dev/null +++ b/src-tauri/src/plugins/mod.rs @@ -0,0 +1,331 @@ +//! 插件:要在系统原生对话框里确认的那三步(I12)。 +//! +//! 安装插件(`CreatePlugin`)、更换代码(`ReplacePluginSource`)、确认变了的文件 +//! (`ApprovePluginFile`)**不在网页的白名单里**(见 `call.rs`)。网页只能请这里去做, +//! 而这里不信网页给的任何关于插件的说法: +//! +//! 1. **自己再读一遍代码**:交给 core 的 `PluginInspect`(不写任何东西),名字、权限、SHA-256 +//! 都从这一次读出来。网页给的只有代码本身和用户的选择(ID、范围、设置项、出错时)。 +//! 2. 在原生对话框里写明插件名、权限、SHA-256 的前几位(审核窗口里写的是同一段,对得上 +//! 就是同一份代码),**默认按钮是取消**。 +//! 3. 用户点了确认,才把**读过的那同一份代码**交给 core 写配置。 +//! +//! 用户在对话框里取消不是失败:回执是 `cancelled`,网页那边什么都不用报。 +//! +//! 其余插件端点(列出、读代码、设置、删除、排序、试运行、日志)网页直接经过 `call` 走 +//! (白名单的 `provisional` 那一组)。它们的类型现在在 [`wire`],core 发版之后换成生成的。 + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::AppState; +use crate::control::ControlClient; +use crate::error::{CmdError, Out, text}; + +mod confirm; +pub mod wire; +pub mod words; + +use wire::{Inspection, Installed, OnError, Permission, PluginScope, SourceView}; + +/// 插件文件的上限,和 core 一样 +const MAX_SOURCE: usize = 1024 * 1024; + +/// 网页给的安装请求:代码,和用户在审核窗口里选的。**没有清单** —— 名字和权限这里自己读 +#[derive(Debug, Deserialize)] +pub struct InstallRequest { + source: String, + id: Option, + enabled: bool, + on_error: OnError, + scope: PluginScope, + settings: BTreeMap, + base_version: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ReplaceRequest { + id: String, + source: String, + base_version: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ApproveRequest { + id: String, + base_version: Option, +} + +/// 写成了(配置的新版本),或者用户在原生对话框里取消了(什么都没写) +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Written { + Done { version: String }, + Cancelled, +} + +/// 安装一个插件 +#[tauri::command] +pub async fn plugin_install( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + req: InstallRequest, +) -> Out { + let c = &state.control; + let read = inspect(c, &req.source).await?; + let ask = words::install(&read.name, &read.permissions, &req.scope, &read.sha256); + if !confirmed(&app, ask).await? { + return Ok(Written::Cancelled); + } + let w = c + .call::( + &[], + &wire::PluginCreate { + source: req.source, + id: req.id, + enabled: req.enabled, + on_error: req.on_error, + scope: req.scope, + settings: req.settings, + base_version: req.base_version, + }, + ) + .await + .map_err(text)?; + Ok(Written::Done { version: w.version }) +} + +/// 更换一个插件的代码 +#[tauri::command] +pub async fn plugin_replace_source( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + req: ReplaceRequest, +) -> Out { + let c = &state.control; + let before = installed(c, &req.id).await?; + let read = inspect(c, &req.source).await?; + let ask = words::replace( + &before.name, + &read.name, + &read.permissions, + &before.permissions, + &read.sha256, + ); + if !confirmed(&app, ask).await? { + return Ok(Written::Cancelled); + } + let w = c + .call::( + &[&req.id], + &wire::PluginSourceReplace { + source: req.source, + base_version: req.base_version, + }, + ) + .await + .map_err(text)?; + Ok(Written::Done { version: w.version }) +} + +/// 确认一个插件变了的文件。**文件由这里自己去取**(`PluginSourceDiff`),读的、给人看的、 +/// 交给 core 认的是同一个 SHA-256;在这期间文件又变了的话,core 那边对不上就不认 +#[tauri::command] +pub async fn plugin_approve( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + req: ApproveRequest, +) -> Out { + let c = &state.control; + let before = installed(c, &req.id).await?; + let source: SourceView = decode( + c.call::(&[&req.id], &()) + .await + .map_err(text)?, + )?; + let (Some(current), Some(sha)) = (source.current, source.current_sha256) else { + return Err(CmdError::plain(tr!( + "插件文件已不存在或无法读取。", + "The plugin file no longer exists or cannot be read." + ))); + }; + let read = inspect(c, ¤t).await?; + // 两次问 core 之间文件又变了:读的不是要认的那一份 + if read.sha256 != sha { + return Err(changed_meanwhile()); + } + let ask = words::approve( + &before.name, + &read.name, + &read.permissions, + &before.permissions, + &source.approved_sha256, + &sha, + ); + if !confirmed(&app, ask).await? { + return Ok(Written::Cancelled); + } + let w = c + .call::( + &[&req.id], + &wire::PluginApprove { + sha256: sha, + base_version: req.base_version, + }, + ) + .await + .map_err(text)?; + Ok(Written::Done { version: w.version }) +} + +/// 读过一遍的代码:名字、权限、SHA-256(都是 core 读出来的,不是网页说的) +struct Read { + name: String, + permissions: Vec, + sha256: String, +} + +/// 交给 core 读一遍。读不了(语法、清单不对)就停在这里:审核窗口里已经说过原因, +/// 走到这一步只可能是网页没照规矩来 +async fn inspect(c: &ControlClient, source: &str) -> Out { + if source.len() > MAX_SOURCE { + return Err(CmdError::plain(tr!( + "插件文件超过 1 MB 的上限。", + "The plugin file is over the 1 MB limit." + ))); + } + let i: Inspection = decode( + c.call::( + &[], + &wire::PluginSource { + source: source.to_string(), + }, + ) + .await + .map_err(text)?, + )?; + if let Some(e) = i.error { + let at = match (e.line, e.column) { + (Some(l), Some(col)) => tr!( + format!("(第 {l} 行第 {col} 列)"), + format!(" (line {l}, column {col})") + ), + (Some(l), None) => tr!(format!("(第 {l} 行)"), format!(" (line {l})")), + _ => String::new(), + }; + return Err(CmdError::plain(tr!( + format!("代码无法加载{at}:{}", e.message), + format!("The code cannot be loaded{at}: {}", e.message) + ))); + } + let m = i.manifest.ok_or_else(|| { + CmdError::plain(tr!( + "代码里没有可用的插件清单。", + "The code has no usable plugin manifest." + )) + })?; + Ok(Read { + name: m.name, + permissions: m.permissions, + sha256: i.sha256, + }) +} + +/// 装着的那一个插件原来的样子(更换代码、确认变更时和它比权限) +async fn installed(c: &ControlClient, id: &str) -> Out { + let all: Vec = decode(c.call::(&[], &()).await.map_err(text)?)?; + all.into_iter().find(|p| p.id == id).ok_or_else(|| { + CmdError::plain(tr!( + format!("插件「{id}」不存在,可能已被删除。"), + format!("Plugin “{id}” does not exist; it may have been deleted.") + )) + }) +} + +/// 问一句。**已经有一个确认窗口开着时直接失败**,不在背后排队 +async fn confirmed(app: &tauri::AppHandle, ask: words::Ask) -> Out { + confirm::ask(app, ask).await.map_err(|confirm::Busy| { + CmdError::plain(tr!( + "另一个确认窗口尚未关闭。", + "Another confirmation dialog is still open." + )) + }) +} + +fn changed_meanwhile() -> CmdError { + CmdError::plain(tr!( + "插件文件在确认过程中再次被改动,请重新打开审核窗口。", + "The plugin file changed again during the review. Open the review again." + )) +} + +/// core 的响应按这里要的样子读。读不了说明 core 和这份约定对不上 +fn decode(v: Value) -> Out { + serde_json::from_value(v).map_err(|e| { + CmdError::plain(tr!( + format!("core 返回的插件信息无法识别:{e}"), + format!("The plugin information from core is not in the expected shape: {e}") + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_receipt_says_done_or_cancelled() { + let done = serde_json::to_value(Written::Done { + version: "v9".into(), + }) + .unwrap(); + assert_eq!(done, serde_json::json!({ "kind": "done", "version": "v9" })); + let no = serde_json::to_value(Written::Cancelled).unwrap(); + assert_eq!(no, serde_json::json!({ "kind": "cancelled" })); + } + + /// 网页给的安装请求里**没有清单**:多给了也不读(名字、权限由这里自己读) + #[test] + fn an_install_request_carries_no_manifest() { + let req: InstallRequest = serde_json::from_value(serde_json::json!({ + "source": "export const manifest = {}", + "id": "x", + "enabled": true, + "on_error": "reject", + "scope": { "clients": [], "models": [], "upstreams": [] }, + "settings": {}, + "base_version": null, + "manifest": { "name": "伪造的名字", "permissions": [] } + })) + .unwrap(); + assert_eq!(req.id.as_deref(), Some("x")); + assert!(!format!("{req:?}").contains("伪造的名字")); + } + + #[test] + fn core_inspection_is_read_like_the_contract() { + let i: Inspection = decode(serde_json::json!({ + "manifest": { + "name": "附加当前日期", "description": null, "permissions": ["system", "reply_tool_calls"], + "scope": { "clients": [], "models": [], "upstreams": [] }, "reply_mode": "block", + "settings_schema": [], "hooks": { "request": true, "reply_text": false, "tool_call": true } + }, + "sha256": "6f1c", + "error": null + })) + .unwrap(); + let m = i.manifest.unwrap(); + assert_eq!( + m.permissions, + [Permission::System, Permission::ReplyToolCalls] + ); + let bad: Inspection = decode(serde_json::json!({ + "manifest": null, "sha256": "00", + "error": { "message": "SyntaxError: unexpected token", "line": 3, "column": 7 } + })) + .unwrap(); + assert_eq!(bad.error.unwrap().line, Some(3)); + } +} diff --git a/src-tauri/src/plugins/wire.rs b/src-tauri/src/plugins/wire.rs new file mode 100644 index 00000000..eec0fbbc --- /dev/null +++ b/src-tauri/src/plugins/wire.rs @@ -0,0 +1,282 @@ +//! PROVISIONAL:插件的控制面端点和类型,照 v1 约定(plugins-contract §6)手写。 +//! +//! **core 发版之前只能这样**:钉着的那版 `tw_api` 里还没有它们。`tw_api::Endpoint` 是公开的 +//! trait,这里照它给每个端点写一份描述(方法、路径、参数、请求和响应的类型),`call` 命令 +//! 和下面的原生确认命令就能照常走 `ControlClient::call`。 +//! +//! 接上正式版(core 带着这些端点发版、钉点升上去之后): +//! +//! 1. `call.rs` 里 `provisional: [...]` 那一组挪进上面那一组(变成 `ep::Plugins` 等); +//! 2. `plugins/mod.rs` 里的 `wire::X` 换成 `tw_api::ep::X`,请求类型换成 `tw_api` 里生成的; +//! 这里那几个只用来读响应的结构(`Inspection` 等)换成生成的类型; +//! 3. `gateway.rs` 里接 `plugin_failed` 的那一段换成 `tw_api::Event::PluginFailed`(见那里); +//! 4. 删掉这个文件。 +//! +//! **网页能经过 `call` 走到的那几个端点,响应一律是 `serde_json::Value`**:原样转给网页, +//! 这里少写一个字段也不会把它从网页那边吞掉(网页的类型在 `src/plugins/api.provisional.ts`)。 + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tw_api::{BaseVersion, ConfigWritten, Endpoint, Format, Method}; + +/// 插件申请的权限 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Permission { + System, + Messages, + Tools, + Params, + ReplyText, + ReplyToolCalls, +} + +impl Permission { + /// 约定里的顺序,也是列给人看的顺序 + pub const ALL: [Permission; 6] = [ + Permission::System, + Permission::Messages, + Permission::Tools, + Permission::Params, + Permission::ReplyText, + Permission::ReplyToolCalls, + ]; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OnError { + Reject, + Skip, +} + +/// 生效的适用范围。每一项是通配,空的就是全部 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginScope { + pub clients: Vec, + pub models: Vec, + pub upstreams: Vec, +} + +// ------------------------------------------------------------- 请求 + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginSource { + pub source: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginCreate { + pub source: String, + pub id: Option, + pub enabled: bool, + pub on_error: OnError, + pub scope: PluginScope, + pub settings: BTreeMap, + pub base_version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginUpdate { + pub enabled: bool, + pub on_error: OnError, + pub scope: PluginScope, + pub settings: BTreeMap, + pub base_version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginSourceReplace { + pub source: String, + pub base_version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginApprove { + pub sha256: String, + pub base_version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginOrder { + pub ids: Vec, + pub base_version: Option, +} + +/// 约定里没写 `request_id` 的类型;请求的编号在别处(`HistoryRow.id`)都是数 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginTrial { + pub request_id: u64, +} + +// ------------------------------------------------------------- 只读的那几样响应 + +/// `PluginInspect` 的结果里原生确认要用的部分(其余照约定,这里不读) +#[derive(Debug, Clone, Deserialize)] +pub struct Inspection { + pub manifest: Option, + pub sha256: String, + pub error: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Manifest { + pub name: String, + pub permissions: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct InspectError { + pub message: String, + pub line: Option, + pub column: Option, +} + +/// `PluginSourceDiff` 里确认文件变更要用的部分 +#[derive(Debug, Clone, Deserialize)] +pub struct SourceView { + pub approved_sha256: String, + pub current: Option, + pub current_sha256: Option, +} + +/// `Plugins` 里一个插件,原生确认要用的部分:它原来叫什么、要了哪些权限 +#[derive(Debug, Clone, Deserialize)] +pub struct Installed { + pub id: String, + pub name: String, + pub permissions: Vec, +} + +// ------------------------------------------------------------- 端点 + +macro_rules! endpoints { + ($($name:ident: $method:ident $path:literal [$($param:literal),*] $req:ty => $res:ty;)*) => { + $( + pub struct $name; + impl Endpoint for $name { + const METHOD: Method = Method::$method; + const PATH: &'static str = $path; + const PARAMS: &'static [&'static str] = &[$($param),*]; + const FORMAT: Format = Format::Json; + const NAME: &'static str = stringify!($name); + type Req = $req; + type Res = $res; + } + )* + }; +} + +endpoints! { + // 网页能经过 `call` 走到的(`call.rs` 的 `provisional` 那一组) + Plugins: Get "/plugins" [] () => Value; + PluginInspect: Post "/plugins/inspect" [] PluginSource => Value; + UpdatePlugin: Put "/plugins/{id}" ["id"] PluginUpdate => ConfigWritten; + PluginSourceDiff: Get "/plugins/{id}/source" ["id"] () => Value; + DeletePlugin: Delete "/plugins/{id}" ["id"] BaseVersion => ConfigWritten; + ReorderPlugins: Put "/plugins/order" [] PluginOrder => ConfigWritten; + TrialPlugin: Post "/plugins/{id}/trial" ["id"] PluginTrial => Value; + PluginLogs: Get "/plugins/{id}/logs" ["id"] () => Value; + // **网页走不到的三个**(I12):只有 `plugins` 里的原生确认命令调它们 + CreatePlugin: Post "/plugins" [] PluginCreate => ConfigWritten; + ReplacePluginSource: Put "/plugins/{id}/source" ["id"] PluginSourceReplace => ConfigWritten; + ApprovePluginFile: Post "/plugins/{id}/approve" ["id"] PluginApprove => ConfigWritten; +} + +/// 事件流上的 `plugin_failed`(约定 §6)。钉着的 `tw_api::Event` 认不得它,所以在那一层 +/// 解析失败的事件里再按它试一次(见 `control::subscribe_events_with`) +#[derive(Debug, Clone, Deserialize)] +pub struct PluginFailed { + pub plugin_id: String, + pub plugin_name: String, + /// 约定没写类型:数或者字符串都收 + pub request_id: Option, +} + +impl PluginFailed { + /// 事件流上的一条原文,是 `plugin_failed` 就解出来 + pub fn parse(raw: &Value) -> Option { + (raw.get("kind")?.as_str()? == "plugin_failed") + .then(|| serde_json::from_value(raw.clone()).ok()) + .flatten() + } + + /// 请求的编号,写成一段字 + pub fn request(&self) -> Option { + match self.request_id.as_ref()? { + Value::Number(n) => Some(n.to_string()), + Value::String(s) if !s.is_empty() => Some(s.clone()), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_paths_and_methods_are_the_contracts() { + assert_eq!( + (PluginInspect::METHOD, PluginInspect::PATH), + (Method::Post, "/plugins/inspect") + ); + assert_eq!(UpdatePlugin::PARAMS, &["id"]); + assert_eq!( + (DeletePlugin::METHOD, DeletePlugin::PATH), + (Method::Delete, "/plugins/{id}") + ); + assert_eq!( + ( + ReplacePluginSource::METHOD, + ReplacePluginSource::PATH, + ReplacePluginSource::NAME + ), + (Method::Put, "/plugins/{id}/source", "ReplacePluginSource") + ); + assert_eq!( + tw_api::fill(ApprovePluginFile::PATH, &[("id", "add date")]), + "/plugins/add%20date/approve" + ); + } + + #[test] + fn permissions_are_written_like_the_contract() { + let all: Vec = Permission::ALL + .iter() + .map(|p| { + serde_json::to_value(p) + .unwrap() + .as_str() + .unwrap() + .to_string() + }) + .collect(); + assert_eq!( + all, + [ + "system", + "messages", + "tools", + "params", + "reply_text", + "reply_tool_calls" + ] + ); + } + + #[test] + fn a_plugin_failure_on_the_event_stream_is_recognised() { + let raw = serde_json::json!({ + "kind": "plugin_failed", "plugin_id": "add-date", "plugin_name": "附加日期", + "request_id": 50463, "message": "boom", "at_ms": 1 + }); + let f = PluginFailed::parse(&raw).unwrap(); + assert_eq!(f.plugin_id, "add-date"); + assert_eq!(f.request().as_deref(), Some("50463")); + let other = serde_json::json!({ "kind": "config_reloaded", "version": "x" }); + assert!(PluginFailed::parse(&other).is_none()); + } +} diff --git a/src-tauri/src/plugins/words.rs b/src-tauri/src/plugins/words.rs new file mode 100644 index 00000000..66c1766d --- /dev/null +++ b/src-tauri/src/plugins/words.rs @@ -0,0 +1,392 @@ +//! 原生确认对话框里的话:权限说成它允许做的事、插件名去掉能骗人的字符、SHA-256 的前几位。 +//! +//! **和界面上的说法是同一套**(`src/plugins/labels.i18n.ts`):审核窗口里看到的权限,在系统 +//! 对话框里要认得出是同一样东西。改一边要改另一边。 +//! +//! 这里没有平台的东西,测试在哪个平台都跑。 + +use super::wire::{Permission, PluginScope}; + +/// 一次确认要问的话 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Ask { + /// 窗口标题(Windows、Linux 上有;macOS 的提示框没有标题栏) + pub title: String, + /// 第一行,加粗的那一句 + pub message: String, + /// 下面的正文:权限、适用范围、SHA-256 + pub detail: String, + /// 确认按钮上的字 + pub accept: String, + /// 按钮只有「确定 / 取消」的平台(Windows 的消息框)上,正文最后补的一句 + pub ok_hint: String, + /// 申请了高风险的权限:图标和按钮换成警示的那一种 + pub danger: bool, +} + +/// 一项权限允许做的事 +pub fn permission_text(p: Permission) -> &'static str { + match p { + Permission::System => tr!("读取和修改系统提示词", "Read and change the system prompt"), + Permission::Messages => tr!( + "读取和修改对话消息中的文字和工具结果(可以向对话中加入指令)", + "Read and change the text and tool results in conversation messages (can add instructions to the conversation)" + ), + Permission::Tools => tr!( + "读取和修改工具定义(会改变模型可用的工具)", + "Read and change tool definitions (changes which tools the model can use)" + ), + Permission::Params => tr!( + "读取和修改模型名、max_tokens、温度等参数(可能改变处理请求的上游和产生的费用)", + "Read and change the model, max_tokens, temperature and other parameters (may change which upstream serves the request and what it costs)" + ), + Permission::ReplyText => tr!( + "读取和修改回答中的文字", + "Read and change the text of replies" + ), + Permission::ReplyToolCalls => tr!( + "修改、删除和新增回答中的工具调用。高风险:可以改写客户端将要执行的命令和文件路径", + "Change, remove and add tool calls in replies. High risk: can rewrite the commands and file paths a client is about to run" + ), + } +} + +/// 插件名放进对话框之前:**去掉能让一句话读起来和实际不一样的字符**。 +/// +/// 名字是插件自己写的。换行、制表这类控制字符能在对话框里伪造出「权限:无」这样的一行; +/// 双向文本的控制符(U+202E 之类)能把后面的字倒过来;零宽字符能让两个名字看起来一样。 +/// 控制字符换成空格,看不见的那几类写成码位(``),连续的空白并成一个,最长 64 个字。 +pub fn clean_name(raw: &str) -> String { + let mut out = String::new(); + for c in raw.chars() { + if c.is_control() { + out.push(' '); + } else if invisible(c) { + out.push_str(&format!("", c as u32)); + } else { + out.push(c); + } + } + let joined = out.split_whitespace().collect::>().join(" "); + let mut chars = joined.chars(); + let short: String = chars.by_ref().take(64).collect(); + if chars.next().is_some() { + format!("{short}…") + } else if short.is_empty() { + tr!("(未命名)", "(unnamed)").to_string() + } else { + short + } +} + +/// 看不见、却会改变一段字读法的字符:零宽、双向文本的控制符、BOM +fn invisible(c: char) -> bool { + matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x2069 | 0xFEFF) +} + +/// SHA-256 的前 16 位,四个一组。审核窗口里写的是同一段,对得上就是同一份代码 +pub fn sha_prefix(hex: &str) -> String { + let head: Vec = hex.chars().take(16).collect(); + head.chunks(4) + .map(|c| c.iter().collect::()) + .collect::>() + .join(" ") +} + +/// 按约定的顺序列出权限;`previous` 给了的话,这一版新增的标出来 +fn permission_lines(perms: &[Permission], previous: Option<&[Permission]>) -> String { + let mut lines = Vec::new(); + for p in Permission::ALL { + if !perms.contains(&p) { + continue; + } + let added = previous.is_some_and(|prev| !prev.contains(&p)); + let mark = if added { + tr!("(新增)", " (new)") + } else { + "" + }; + lines.push(format!("• {}{mark}", permission_text(p))); + } + if lines.is_empty() { + lines.push(format!("• {}", tr!("未申请任何权限", "No permissions"))); + } + lines.join("\n") +} + +/// 适用范围写成一行。什么都没限的是「全部请求」 +fn scope_line(scope: &PluginScope) -> String { + let sep = tr!("、", ", "); + let parts: Vec = [ + (tr!("客户端", "clients"), &scope.clients), + (tr!("模型", "models"), &scope.models), + (tr!("上游", "upstreams"), &scope.upstreams), + ] + .into_iter() + .filter(|(_, list)| !list.is_empty()) + .map(|(what, list)| { + let names: Vec = list.iter().map(|x| clean_name(x)).collect(); + tr!( + format!("{what} {}", names.join(sep)), + format!("{what} {}", names.join(sep)) + ) + }) + .collect(); + if parts.is_empty() { + tr!("全部请求", "all requests").to_string() + } else { + parts.join(tr!(";", "; ")) + } +} + +fn check_line() -> &'static str { + tr!( + "请核对 SHA-256 与审核窗口中显示的一致。", + "Check that the SHA-256 matches the one shown in the review window." + ) +} + +/// 安装一个新插件 +pub fn install(name: &str, perms: &[Permission], scope: &PluginScope, sha256: &str) -> Ask { + let name = clean_name(name); + let detail = tr!( + format!( + "此插件可以:\n{}\n\n适用范围:{}\nSHA-256:{}\n\n{}", + permission_lines(perms, None), + scope_line(scope), + sha_prefix(sha256), + check_line() + ), + format!( + "This plugin can:\n{}\n\nApplies to: {}\nSHA-256: {}\n\n{}", + permission_lines(perms, None), + scope_line(scope), + sha_prefix(sha256), + check_line() + ) + ); + Ask { + title: tr!("安装插件", "Install Plugin").to_string(), + message: tr!( + format!("安装插件「{name}」"), + format!("Install Plugin “{name}”") + ), + detail, + accept: tr!("安装", "Install").to_string(), + ok_hint: tr!( + "选择「确定」安装此插件。", + "Choose OK to install the plugin." + ) + .to_string(), + danger: perms.contains(&Permission::ReplyToolCalls), + } +} + +/// 换了代码之后插件改了名字:正文第一行说出新名字。标题里写的是**现在装着的那个名字** +/// —— 用户点开的是它,换上来的代码自称什么由它自己说 +fn renamed(current: &str, next: &str) -> String { + if current == next { + return String::new(); + } + tr!( + format!("新代码中的名称:「{next}」\n\n"), + format!("Name in the new code: “{next}”\n\n") + ) +} + +/// 更换一个插件的代码。`name`:现在装着的那个的名字;`new_name`、`perms`:新代码里的; +/// `previous`:原来那一版申请的权限 +pub fn replace( + name: &str, + new_name: &str, + perms: &[Permission], + previous: &[Permission], + sha256: &str, +) -> Ask { + let (name, new_name) = (clean_name(name), clean_name(new_name)); + let renamed = renamed(&name, &new_name); + let detail = tr!( + format!( + "{renamed}新的代码可以:\n{}\n\nSHA-256:{}\n\n{}", + permission_lines(perms, Some(previous)), + sha_prefix(sha256), + check_line() + ), + format!( + "{renamed}The new code can:\n{}\n\nSHA-256: {}\n\n{}", + permission_lines(perms, Some(previous)), + sha_prefix(sha256), + check_line() + ) + ); + Ask { + title: tr!("更换插件代码", "Replace Plugin Code").to_string(), + message: tr!( + format!("更换插件「{name}」的代码"), + format!("Replace the Code of Plugin “{name}”") + ), + detail, + accept: tr!("更换", "Replace").to_string(), + ok_hint: tr!("选择「确定」更换代码。", "Choose OK to replace the code.").to_string(), + danger: perms.contains(&Permission::ReplyToolCalls), + } +} + +/// 确认一个插件变了的文件。参数同 [`replace`],`from` / `to` 是确认过的和现在的 SHA-256 +pub fn approve( + name: &str, + new_name: &str, + perms: &[Permission], + previous: &[Permission], + from: &str, + to: &str, +) -> Ask { + let (name, new_name) = (clean_name(name), clean_name(new_name)); + let renamed = renamed(&name, &new_name); + let detail = tr!( + format!( + "{renamed}更改后的文件可以:\n{}\n\nSHA-256:{} → {}\n\n{}", + permission_lines(perms, Some(previous)), + sha_prefix(from), + sha_prefix(to), + check_line() + ), + format!( + "{renamed}The changed file can:\n{}\n\nSHA-256: {} → {}\n\n{}", + permission_lines(perms, Some(previous)), + sha_prefix(from), + sha_prefix(to), + check_line() + ) + ); + Ask { + title: tr!("确认文件更改", "Approve File Changes").to_string(), + message: tr!( + format!("确认插件「{name}」的文件更改"), + format!("Approve the Changed File of Plugin “{name}”") + ), + detail, + accept: tr!("确认", "Approve").to_string(), + ok_hint: tr!("选择「确定」确认更改。", "Choose OK to approve the change.").to_string(), + danger: perms.contains(&Permission::ReplyToolCalls), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_name_cannot_forge_lines_or_turn_text_around() { + // 换行伪造出一行「权限:无」,U+202E 把后面倒过来,零宽空格藏在中间 + let raw = "日期\n\n权限:无\u{202E}txt.exe\u{200B}"; + let clean = clean_name(raw); + assert!(!clean.contains('\n'), "{clean}"); + assert!( + !clean.contains('\u{202E}') && !clean.contains('\u{200B}'), + "{clean}" + ); + assert!( + clean.contains("") && clean.contains(""), + "{clean}" + ); + } + + #[test] + fn a_long_name_is_cut_and_an_empty_one_is_named() { + let long = "x".repeat(200); + assert_eq!(clean_name(&long).chars().count(), 65); + assert!(!clean_name(" \n\t").is_empty()); + } + + #[test] + fn the_sha_prefix_is_four_groups_of_four() { + assert_eq!( + sha_prefix("6f1c9a0277be41d0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + "6f1c 9a02 77be 41d0" + ); + assert_eq!(sha_prefix("abc"), "abc"); + } + + #[test] + fn the_install_dialog_names_every_permission_and_the_hash() { + let a = install( + "附加当前日期", + &[Permission::ReplyToolCalls, Permission::System], + &PluginScope::default(), + "6f1c9a0277be41d0ffff", + ); + // 两项都在,按约定的顺序:系统提示词在前 + let sys = a.detail.find(permission_text(Permission::System)).unwrap(); + let tools = a + .detail + .find(permission_text(Permission::ReplyToolCalls)) + .unwrap(); + assert!(sys < tools, "{}", a.detail); + assert!(a.detail.contains("6f1c 9a02 77be 41d0"), "{}", a.detail); + assert!(a.danger); + assert!(a.message.contains("附加当前日期")); + } + + #[test] + fn a_new_permission_in_a_changed_file_is_marked() { + let a = approve( + "p", + "p", + &[Permission::System, Permission::Params], + &[Permission::System], + "aaaa", + "bbbb", + ); + let params = a + .detail + .lines() + .find(|l| l.contains(permission_text(Permission::Params))) + .unwrap(); + let system = a + .detail + .lines() + .find(|l| l.contains(permission_text(Permission::System))) + .unwrap(); + assert_ne!(params, format!("• {}", permission_text(Permission::Params))); + assert_eq!(system, format!("• {}", permission_text(Permission::System))); + assert!(!a.danger); + } + + #[test] + fn new_code_under_another_name_says_so() { + let same = replace( + "附加日期", + "附加日期", + &[Permission::System], + &[Permission::System], + "aa", + ); + let other = replace( + "附加日期", + "清空系统提示", + &[Permission::System], + &[Permission::System], + "aa", + ); + // 标题是装着的那个名字,新名字写在正文里 + assert!(other.message.contains("附加日期"), "{}", other.message); + assert!(other.detail.contains("清空系统提示"), "{}", other.detail); + assert!(!same.detail.contains("附加日期"), "{}", same.detail); + } + + #[test] + fn the_scope_line_says_all_when_nothing_is_limited() { + let all = scope_line(&PluginScope::default()); + let some = scope_line(&PluginScope { + clients: vec!["claude-code".into()], + models: vec!["claude-*".into()], + upstreams: vec![], + }); + assert_ne!(all, some); + assert!( + some.contains("claude-code") && some.contains("claude-*"), + "{some}" + ); + } +} diff --git a/src/control.ts b/src/control.ts index 32df9451..b2c94cae 100644 --- a/src/control.ts +++ b/src/control.ts @@ -90,9 +90,35 @@ export const WEBVIEW_ENDPOINTS = [ "ChatgptResets", "UseChatgptReset", "ZaiLoginStatus", + "Plugins", + "PluginInspect", + "UpdatePlugin", + "PluginSourceDiff", + "DeletePlugin", + "ReorderPlugins", + "TrialPlugin", + "PluginLogs", ] as const; -export type WebviewEndpoint = (typeof WEBVIEW_ENDPOINTS)[number]; +/** + * PROVISIONAL:插件的端点在白名单里,类型还不在生成的 `tw-api.ts` 里(core 发版之前)。 + * 它们走 `src/plugins/api.provisional.ts` 的 `pluginCall`。core 发版、重新生成之后删掉 + * 这一行和下面的 `Exclude`,插件页改用 `call`。 + * + * 安装、更换代码、确认文件变更(`CreatePlugin`、`ReplacePluginSource`、`ApprovePluginFile`) + * **有意不在白名单里**:只能经过 Rust 的原生确认(`plugin_install` 等命令)。 + */ +type Provisional = + | "Plugins" + | "PluginInspect" + | "UpdatePlugin" + | "PluginSourceDiff" + | "DeletePlugin" + | "ReorderPlugins" + | "TrialPlugin" + | "PluginLogs"; + +export type WebviewEndpoint = Exclude<(typeof WEBVIEW_ENDPOINTS)[number], Provisional>; /** 模板里每个参数名换成一个值 */ type Values = T extends readonly [unknown, ...infer Rest] ? [string | number, ...Values] : []; From 244ca62e363b99f400d0b5e258dbcd4748b950a9 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:00:02 +0800 Subject: [PATCH 04/21] feat(plugins): Plugins page A new Plugins page after MCP lists the installed plugins in run order, each with its status (active, disabled, file changed, failed to load), permission chips (tool calls in replies in red), scope and run statistics, and its actions written as words: Settings, Trial run, Logs and Delete, plus Review changes or Replace code when the file changed or cannot load. Enabled plugins that are not running and reject requests in their scope get a banner until they are dealt with. - Add: choose a local .js file or paste code, then review the full code (read-only CodeMirror with a small JavaScript highlighter, long lines wrapped and invisible characters marked), every permission with its consequence, syntax errors with line and column, the ID, scope, settings and what to do on error. Install hands over to the native confirmation. - Review a changed file: line diff against the approved copy, new permissions marked, then the native approval. - Settings, trial run (a recent request in the plugin's scope, before/after diff for the request and reply, logs), logs, reorder and delete. - Plugin-provided strings are rendered as plain text, isolated with . Shortcuts: there are ten pages now, so Cmd/Ctrl+1 to 9 cover the first nine pages other than Settings, and Settings is Cmd/Ctrl+, only. The plugin types are provisional (src/plugins/api.provisional.ts) until core ships them in the generated tw-api.ts. Co-Authored-By: Claude Opus 5.5 --- src/App.i18n.tsx | 2 + src/App.tsx | 44 ++- src/nav.tsx | 7 +- src/palette/ShortcutSheet.tsx | 11 +- src/palette/items.test.ts | 6 +- src/palette/items.tsx | 11 +- src/palette/keys.tsx | 18 +- src/palette/palette.i18n.ts | 6 + src/plugins/ChangedDialog.i18n.tsx | 39 ++ src/plugins/ChangedDialog.tsx | 171 +++++++++ src/plugins/CodeView.tsx | 122 +++++++ src/plugins/ListDialogs.tsx | 168 +++++++++ src/plugins/LogsDialog.i18n.tsx | 31 ++ src/plugins/LogsDialog.tsx | 123 +++++++ src/plugins/PluginsPage.i18n.tsx | 117 ++++++ src/plugins/PluginsPage.tsx | 556 +++++++++++++++++++++++++++++ src/plugins/SettingsDialog.i18n.ts | 20 ++ src/plugins/SettingsDialog.tsx | 142 ++++++++ src/plugins/SourceDialog.i18n.tsx | 71 ++++ src/plugins/SourceDialog.tsx | 443 +++++++++++++++++++++++ src/plugins/TrialDialog.i18n.tsx | 47 +++ src/plugins/TrialDialog.tsx | 176 +++++++++ src/plugins/api.provisional.ts | 228 ++++++++++++ src/plugins/diff.ts | 110 ++++++ src/plugins/fields.i18n.ts | 42 +++ src/plugins/fields.tsx | 271 ++++++++++++++ src/plugins/jsLanguage.ts | 124 +++++++ src/plugins/labels.i18n.ts | 163 +++++++++ src/plugins/model.test.ts | 119 ++++++ src/plugins/model.ts | 122 +++++++ src/plugins/parts.i18n.ts | 29 ++ src/plugins/parts.tsx | 351 ++++++++++++++++++ src/plugins/plaintext.test.ts | 40 +++ src/ui/README.md | 7 +- src/ui/icons.tsx | 1 + 35 files changed, 3907 insertions(+), 31 deletions(-) create mode 100644 src/plugins/ChangedDialog.i18n.tsx create mode 100644 src/plugins/ChangedDialog.tsx create mode 100644 src/plugins/CodeView.tsx create mode 100644 src/plugins/ListDialogs.tsx create mode 100644 src/plugins/LogsDialog.i18n.tsx create mode 100644 src/plugins/LogsDialog.tsx create mode 100644 src/plugins/PluginsPage.i18n.tsx create mode 100644 src/plugins/PluginsPage.tsx create mode 100644 src/plugins/SettingsDialog.i18n.ts create mode 100644 src/plugins/SettingsDialog.tsx create mode 100644 src/plugins/SourceDialog.i18n.tsx create mode 100644 src/plugins/SourceDialog.tsx create mode 100644 src/plugins/TrialDialog.i18n.tsx create mode 100644 src/plugins/TrialDialog.tsx create mode 100644 src/plugins/api.provisional.ts create mode 100644 src/plugins/diff.ts create mode 100644 src/plugins/fields.i18n.ts create mode 100644 src/plugins/fields.tsx create mode 100644 src/plugins/jsLanguage.ts create mode 100644 src/plugins/labels.i18n.ts create mode 100644 src/plugins/model.test.ts create mode 100644 src/plugins/model.ts create mode 100644 src/plugins/parts.i18n.ts create mode 100644 src/plugins/parts.tsx create mode 100644 src/plugins/plaintext.test.ts diff --git a/src/App.i18n.tsx b/src/App.i18n.tsx index b4903055..f48060ad 100644 --- a/src/App.i18n.tsx +++ b/src/App.i18n.tsx @@ -26,6 +26,7 @@ export const appText = messages( clients: "客户端", keys: "密钥", mcp: "MCP", + plugins: "插件", settings: "设置", }, newFindings: (label: string, n: number) => `${label} · ${n} 项新发现`, @@ -74,6 +75,7 @@ export const appText = messages( clients: "Clients", keys: "Keys", mcp: "MCP", + plugins: "Plugins", settings: "Settings", }, newFindings: (label: string, n: number) => `${label} · ${count(n, "new finding", "new findings")}`, diff --git a/src/App.tsx b/src/App.tsx index 4d74a319..69fc2729 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import { useBusyKeys } from "./keys/live"; import RoutingPage from "./routing/RoutingPage"; import SecurityPage, { type LogFocus } from "./security/SecurityPage"; import McpPage from "./mcp/McpPage"; +import PluginsPage from "./plugins/PluginsPage"; import TrafficPage from "./traffic/TrafficPage"; import { useTrafficView } from "./traffic/view"; import { useSessions } from "./traffic/useSessions"; @@ -31,6 +32,7 @@ import { IconGuard, IconKey, IconMcp, + IconPlugin, IconRoute, IconServer, IconSettings, @@ -63,7 +65,7 @@ import { invalidateAll, resetResources } from "@/lib/resource"; import { cn } from "@/lib/utils"; import { Palette } from "./palette/Palette"; import { paletteText } from "./palette/palette.i18n"; -import { COMBOS, Keys, comboText, isTyping, modalOpen, pageCombo } from "./palette/keys"; +import { COMBOS, DIGIT_PAGES, Keys, comboText, isTyping, modalOpen, pageCombo } from "./palette/keys"; import { Sidebar, SidebarContent, @@ -99,7 +101,7 @@ const COALESCE_MS = 100; const LAUNCH_CAP_MS = 8_000; /** 编辑 config.yaml 的几页。工具栏上的「配置文件」「版本历史」只在这几页出现 */ -const CONFIG_PAGES = new Set(["upstreams", "keys", "routing", "security"]); +const CONFIG_PAGES = new Set(["upstreams", "keys", "routing", "security", "plugins"]); /** 配置文件里的一段由哪一页管理 */ function surfaceOf(section: string | null): Surface { @@ -116,6 +118,8 @@ function surfaceOf(section: string | null): Surface { return "routing"; case "security": return "security"; + case "plugins": + return "plugins"; default: // 监听、日志保留在设置页;辅助请求在路由页,但它没有自己的段名 return "settings"; @@ -127,7 +131,7 @@ function surfaceOf(section: string | null): Surface { * * **源列表,不是标签栏。**原生客户端用左侧源列表:它能分组、能挂角标,加一项 * 不会把别的挤窄。分组的判据是打开频率:上面那组每天看,越往下越是配一次就不动的。 - * 顺序和 `SURFACES` 一致,⌘1…⌘9 按它数。 + * 顺序和 `SURFACES` 一致,⌘1…⌘9 按它数(设置是 ⌘,,见 `palette/keys.tsx` 的 `DIGIT_PAGES`)。 */ const SOURCES: { group: string; items: { id: Surface; icon: LucideIcon }[] }[] = [ { @@ -148,13 +152,14 @@ const SOURCES: { group: string; items: { id: Surface; icon: LucideIcon }[] }[] = ], }, { - // 网关的配置。安全和 MCP 也在这一组:它们是要去动的开关和规则,不是看板 + // 网关的配置。安全、MCP 和插件也在这一组:它们是要去动的开关和规则,不是看板 group: "config", items: [ { id: "upstreams", icon: IconServer }, { id: "routing", icon: IconRoute }, { id: "security", icon: IconGuard }, { id: "mcp", icon: IconMcp }, + { id: "plugins", icon: IconPlugin }, ], }, { @@ -453,7 +458,8 @@ function Shell({ first }: { first: boolean }) { * 搜索框,在输入框里按它该重选。行内的方向键导航在流量页里(`TrafficPage`)。 * 键位和界面上显示的键帽在 `palette/keys.tsx`,改一边要改另一边。 * - * · ⌘K 命令面板 · ⌘1…⌘9 按源列表的顺序换页 · ⌘F 流量搜索 · ⌘, 设置 · ⌘R 刷新 + * · ⌘K 命令面板 · ⌘1…⌘9 按源列表的顺序换页(设置之外的前九页,`DIGIT_PAGES`) + * · ⌘F 流量搜索 · ⌘, 设置 · ⌘R 刷新 * · `?` 快捷键一览(在打字时不接管) * · ⌘⌥S 收起/展开源列表(访达、邮件、备忘录都是这个键;判 `code` 不判 `key`: * ⌥ 会把 s 变成 ß)。**只在 macOS 上有**:Windows 上 Ctrl+Alt 常是 AltGr, @@ -495,7 +501,7 @@ function Shell({ first }: { first: boolean }) { return; } if (/^[1-9]$/.test(e.key) && !e.shiftKey) { - const s = SURFACES[Number(e.key) - 1]; + const s = DIGIT_PAGES[Number(e.key) - 1]; if (!s) return; e.preventDefault(); if (!busy && (linked || s === "settings")) go(s); @@ -711,8 +717,8 @@ function Shell({ first }: { first: boolean }) { {g.items.map((it) => { const on = tab === it.id; - /** 这一页的快捷键:按在源列表里的位置数,⌘1…⌘9 */ - const combo = pageCombo(SURFACES.indexOf(it.id)); + /** 这一页的快捷键:⌘1…⌘9 按在源列表里的位置数,设置是 ⌘, */ + const combo = pageCombo(it.id); // 客户端配置里出现了新东西:挂个角标,直到去看过 const badge = it.id === "mcp" ? alerts.length : 0; const Icon = it.icon; @@ -735,7 +741,7 @@ function Shell({ first }: { first: boolean }) { children: ( <> {badge > 0 ? t.newFindings(label, badge) : label} - + {combo && } ), }} @@ -757,12 +763,14 @@ function Shell({ first }: { first: boolean }) { 的名字(`aria-hidden`):读屏从悬浮说明拿,说明收着也还是按钮的描述。 */} - - {comboText(combo)} - + {combo && ( + + {comboText(combo)} + + )} {badge > 0 && ( {badge} @@ -1053,6 +1061,12 @@ function Shell({ first }: { first: boolean }) { ) : ( skeleton ) + ) : tab === "plugins" ? ( + ov ? ( + + ) : ( + skeleton + ) ) : tab === "settings" ? (
- {SURFACES.map((s, i) => ( - - ))} + {SURFACES.map((s) => { + const combo = pageCombo(s); + return ; + })}
diff --git a/src/palette/items.test.ts b/src/palette/items.test.ts index 97e0d3dc..16261a6e 100644 --- a/src/palette/items.test.ts +++ b/src/palette/items.test.ts @@ -70,7 +70,7 @@ function sources(over: Partial = {}): Sources { const ids = (s: Sources) => buildItems(s).map((i) => i.id); describe("命令面板的条目", () => { - it("页面按源列表的顺序,带 ⌘1…⌘9", () => { + it("页面按源列表的顺序,带 ⌘1…⌘9 和 ⌘,", () => { const pages = buildItems(sources()).filter((i) => i.group === "pages"); expect(pages.map((p) => p.id)).toEqual([ "page:dashboard", @@ -81,9 +81,13 @@ describe("命令面板的条目", () => { "page:routing", "page:security", "page:mcp", + "page:plugins", "page:settings", ]); expect(pages[3]!.combo).toEqual(["mod", "4"]); + // 十页:前九页占数字,设置是 ⌘, + expect(pages[8]!.combo).toEqual(["mod", "9"]); + expect(pages[9]!.combo).toEqual(["mod", ","]); }); it("连着、可写:新建和测速都在", () => { diff --git a/src/palette/items.tsx b/src/palette/items.tsx index b45db9e3..f01f7499 100644 --- a/src/palette/items.tsx +++ b/src/palette/items.tsx @@ -26,6 +26,7 @@ import { IconKey, IconLocal, IconMcp, + IconPlugin, IconRemote, IconRoute, IconServer, @@ -97,6 +98,7 @@ const PAGE_ICONS: Record = { routing: , security: , mcp: , + plugins: , settings: , }; @@ -145,7 +147,7 @@ export function buildItems(s: Sources): Item[] { const items: Item[] = []; // ── 页面:源列表的顺序,⌘1…⌘9 - SURFACES.forEach((surface, i) => { + SURFACES.forEach((surface) => { if (!linked && surface !== "settings") return; items.push({ id: `page:${surface}`, @@ -153,7 +155,7 @@ export function buildItems(s: Sources): Item[] { title: app.surfaces[surface], keywords: [appText.zh.surfaces[surface], appText.en.surfaces[surface], ...both((x) => x.pageAliases[surface])], icon: PAGE_ICONS[surface], - combo: pageCombo(i), + combo: pageCombo(surface), verb: "open", run: () => nav.open(surface), }); @@ -268,6 +270,11 @@ export function buildItems(s: Sources): Item[] { keywords: other((x) => x.newSheet), searchOnly: true, }); + action("new-plugin", t.newPlugin, , () => nav.open("plugins", { add: true }), { + keywords: other((x) => x.newPlugin), + alias: "newPlugin", + searchOnly: true, + }); } action("add-connection", t.addConnection, , s.addConnection, { keywords: other((x) => x.addConnection), diff --git a/src/palette/keys.tsx b/src/palette/keys.tsx index 63dbe12d..3f70f134 100644 --- a/src/palette/keys.tsx +++ b/src/palette/keys.tsx @@ -1,6 +1,7 @@ import { Kbd, KbdGroup } from "@/ui/kbd"; import { cn } from "@/lib/utils"; import { isMac } from "@/platform"; +import { SURFACES, type Surface } from "@/nav"; /** * 快捷键:**一处定义,三处显示**(命令面板每一项右端的键帽、快捷键一览、源列表 —— @@ -68,9 +69,20 @@ export const COMBOS = { shortcuts: ["?"], } as const satisfies Record; -/** 源列表第 i 项(从 0 数)的键:⌘1…⌘9 */ -export function pageCombo(i: number): Combo { - return ["mod", String(i + 1)]; +/** + * 占数字键的那几页:源列表从上往下,**设置除外**,最多九页。 + * + * 页数过了九(加了插件页之后是十页),数字不够分。设置让出来:它有自己的键 ⌘, + * (macOS 上每个应用的「设置…」都在这个键上,Windows、Linux 上是 Ctrl+,),而且是源列表 + * 最后一项,挪走它不会让别的页换键。于是 ⌘1…⌘9 仍是从上往下数,一页一个。 + */ +export const DIGIT_PAGES: readonly Surface[] = SURFACES.filter((s) => s !== "settings").slice(0, 9); + +/** 一页的键:前九页 ⌘1…⌘9,设置 ⌘,。再往后加的页没有键(`undefined`) */ +export function pageCombo(surface: Surface): Combo | undefined { + if (surface === "settings") return COMBOS.settings; + const i = DIGIT_PAGES.indexOf(surface); + return i >= 0 ? ["mod", String(i + 1)] : undefined; } /** diff --git a/src/palette/palette.i18n.ts b/src/palette/palette.i18n.ts index 132635f0..9064bdb5 100644 --- a/src/palette/palette.i18n.ts +++ b/src/palette/palette.i18n.ts @@ -47,6 +47,7 @@ export const paletteText = messages( newUpstream: "新建上游…", newProxy: "新建代理…", newSheet: "新建价目表…", + newPlugin: "添加插件…", speedTest: "推理测速…", linkTest: "链路测速…", newKey: "新建密钥…", @@ -96,6 +97,7 @@ export const paletteText = messages( routing: "规则 策略组 试算 辅助请求", security: "防护 脱敏 扫描 日志", mcp: "扩展 服务器 工具", + plugins: "脚本 改写 扩展", settings: "偏好 选项", } as Record, sectionAliases: { @@ -126,6 +128,7 @@ export const paletteText = messages( addConnection: "远程 服务器", searchTraffic: "查找 请求", notices: "通知 铃铛", + newPlugin: "脚本 javascript", } as Record, // 快捷键一览 @@ -185,6 +188,7 @@ export const paletteText = messages( newUpstream: "New upstream…", newProxy: "New proxy…", newSheet: "New price sheet…", + newPlugin: "Add plugin…", speedTest: "Inference test…", linkTest: "Connection test…", newKey: "New key…", @@ -231,6 +235,7 @@ export const paletteText = messages( routing: "rules groups dry run probes", security: "guard redaction scan log", mcp: "extensions servers tools", + plugins: "scripts rewrite javascript", settings: "preferences options", } as Record, sectionAliases: { @@ -261,6 +266,7 @@ export const paletteText = messages( addConnection: "remote server", searchTraffic: "find requests", notices: "notifications bell", + newPlugin: "script javascript", } as Record, sheet: { diff --git a/src/plugins/ChangedDialog.i18n.tsx b/src/plugins/ChangedDialog.i18n.tsx new file mode 100644 index 00000000..3b91f7ad --- /dev/null +++ b/src/plugins/ChangedDialog.i18n.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import { messages } from "@/i18n"; + +export const changedDialogText = messages( + { + title: "审核文件更改", + lead: (name: ReactNode) => <>插件「{name}」的文件在确认之后被改动过。确认之前,此插件不运行。, + rejecting: "出错时设为拒绝这次请求:在此期间,适用范围内的请求将被拒绝。", + hashes: (from: string, to: string) => `SHA-256 ${from} → ${to}`, + changes: "更改", + fullCode: "完整代码", + code: "代码", + cannotLoad: "更改后的代码无法加载", + at: (where: string) => `位置:${where}`, + missing: "插件文件已不存在或无法读取。", + missingHint: "可以更换为新的代码,或删除此插件。", + loadFailed: "文件内容读取失败", + approve: "确认更改", + replace: "更换代码…", + cancelled: "已取消,配置未改动。", + }, + { + title: "Review file changes", + lead: (name: ReactNode) => <>The file of plugin “{name}” was changed after it was approved. Until the change is approved, the plugin does not run., + rejecting: "On error is set to reject the request: in the meantime, requests it applies to are rejected.", + hashes: (from: string, to: string) => `SHA-256 ${from} → ${to}`, + changes: "Changes", + fullCode: "Full code", + code: "Code", + cannotLoad: "The changed code cannot be loaded", + at: (where: string) => `At ${where}.`, + missing: "The plugin file no longer exists or cannot be read.", + missingHint: "Replace it with new code, or delete the plugin.", + loadFailed: "The file could not be read", + approve: "Approve changes", + replace: "Replace code…", + cancelled: "Cancelled. The configuration was not changed.", + }, +); diff --git a/src/plugins/ChangedDialog.tsx b/src/plugins/ChangedDialog.tsx new file mode 100644 index 00000000..b227f060 --- /dev/null +++ b/src/plugins/ChangedDialog.tsx @@ -0,0 +1,171 @@ +import { useState } from "react"; +import { Banner } from "@/ui/banner"; +import { Button } from "@/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; +import { Segmented } from "@/ui/segmented"; +import { Skeleton } from "@/ui/skeleton"; +import { ErrorState } from "@/ui/states"; +import { useResource } from "@/lib/resource"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { focusSelf } from "@/keys/parts"; +import { DialogError } from "@/upstreams/parts"; +import { approvePluginFile, pluginCall, type PluginView } from "./api.provisional"; +import { changedDialogText } from "./ChangedDialog.i18n"; +import { shaPrefix } from "./model"; +import { CodeBox, PermissionList, PluginText, SourceDiff } from "./parts"; +import { pluginPartsText } from "./parts.i18n"; +import type { NativeWrite } from "./SourceDialog"; + +/** + * 插件文件在确认之后被改过(状态「文件已更改」):看清改了什么,再确认。 + * + * 给人看的三样:**和确认过的那一份逐行对比**(也可以看全文);**这一版申请的权限**,比原来 + * 多要的标成「新增」;两个 SHA-256。确认由 Rust 去做(`plugin_approve`):它自己再取一次 + * 文件、再读一遍,在系统原生对话框里写明插件名、权限和新的 SHA-256,点了才算数(I12)。 + * + * 文件没了、或者改坏了读不了的,确认不了:给「更换代码」。 + */ +export function ChangedDialog({ + plugin, + native, + onClose, + onApproved, + onReplace, +}: { + plugin: PluginView; + native: NativeWrite; + onClose: () => void; + onApproved: () => void; + onReplace: () => void; +}) { + const t = useText(changedDialogText); + const pt = useText(pluginPartsText); + const common = useText(commonText); + const diff = useResource(`plugin-source:${plugin.id}`, () => pluginCall("PluginSourceDiff", null, plugin.id)); + const current = diff.data?.current ?? null; + const read = useResource(current != null ? `plugin-inspect:${plugin.id}:${diff.data?.current_sha256 ?? ""}` : null, () => + pluginCall("PluginInspect", { source: current! }), + ); + const [view, setView] = useState<"changes" | "code">("changes"); + const [writing, setWriting] = useState(false); + const [error, setError] = useState(null); + const [cancelled, setCancelled] = useState(false); + + const manifest = read.data?.manifest ?? null; + const loadError = read.data?.error ?? null; + const ready = current != null && manifest != null && loadError == null; + + async function approve() { + setWriting(true); + setError(null); + setCancelled(false); + try { + const r = await native((base) => approvePluginFile({ id: plugin.id, base_version: base })); + if (r === "done") onApproved(); + else setCancelled(true); + } catch (e) { + setError(errorText(e)); + } finally { + setWriting(false); + } + } + + return ( + !o && !writing && onClose()}> + + + {t.title} + {t.lead()} + + +
+ {plugin.on_error === "reject" && plugin.enabled && ( + + {t.rejecting} + + )} + + {diff.data === undefined ? ( + diff.error !== undefined && !diff.loading ? ( + void diff.reload()} compact /> + ) : ( +
+ + + +
+ ) + ) : current == null ? ( + + {t.missingHint} + + ) : ( + <> +

+ + {t.hashes(shaPrefix(diff.data.approved_sha256), shaPrefix(diff.data.current_sha256 ?? ""))} + +

+ + {loadError && ( + +

+ +

+ {loadError.line != null &&

{t.at(pt.errorAt(loadError.line, loadError.column ?? null))}

} +
+ )} + + {manifest && ( +
+

{pt.permissions}

+ +
+ )} + {read.error !== undefined && !read.loading && !read.data && } + +
+
+

{t.code}

+ + label={t.code} + value={view} + options={[ + { id: "changes", label: t.changes }, + { id: "code", label: t.fullCode }, + ]} + onChange={setView} + /> +
+ {view === "changes" ? ( + + ) : ( + + )} +
+ + )} +
+ + + {cancelled &&

{t.cancelled}

} + + + {(current == null || loadError != null) && diff.data !== undefined && ( + + )} + + + +
+
+ ); +} diff --git a/src/plugins/CodeView.tsx b/src/plugins/CodeView.tsx new file mode 100644 index 00000000..c54b85d3 --- /dev/null +++ b/src/plugins/CodeView.tsx @@ -0,0 +1,122 @@ +import { useEffect, useRef } from "react"; +import { EditorState, RangeSetBuilder, StateEffect, StateField } from "@codemirror/state"; +import { Decoration, EditorView, highlightSpecialChars, lineNumbers, type DecorationSet } from "@codemirror/view"; +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { tags as t } from "@lezer/highlight"; +import { javascript } from "./jsLanguage"; + +/** + * 语法色走 CSS 变量,跟着深浅色切换(和配置文件编辑器同一套变量)。关键字用正文色加粗: + * 审核时要读的是字符串和数据,不是关键字 + */ +const highlight = HighlightStyle.define([ + { tag: t.keyword, color: "var(--code-key)", fontWeight: "600" }, + { tag: [t.propertyName], color: "var(--code-key)" }, + { tag: [t.string, t.special(t.string)], color: "var(--code-string)" }, + { tag: [t.number, t.atom, t.bool, t.null], color: "var(--code-atom)" }, + { tag: [t.comment, t.lineComment, t.blockComment], color: "var(--muted-foreground)", fontStyle: "italic" }, + { tag: [t.punctuation, t.brace, t.operator], color: "var(--muted-foreground)" }, +]); + +/** 报错的那一行:换成一层浅红底 */ +const setError = StateEffect.define(); +const errorLine = StateField.define({ + create: () => Decoration.none, + update(deco, tr) { + for (const e of tr.effects) { + if (!e.is(setError)) continue; + if (e.value == null || e.value < 1 || e.value > tr.state.doc.lines) return Decoration.none; + const b = new RangeSetBuilder(); + b.add(tr.state.doc.line(e.value).from, tr.state.doc.line(e.value).from, Decoration.line({ class: "cm-tw-error" })); + return b.finish(); + } + return deco; + }, + provide: (f) => EditorView.decorations.from(f), +}); + +/** + * 插件代码,只读。安装、更换代码、确认文件变更之前给人看全文的那一块。 + * + * **长行折行。**审核时最常见的藏法是在一行末尾隔一大段空格再写一句:不折行的话那一句 + * 在横向滚动条的另一头。**看不见的字符画出来**:零宽字符和双向文本的控制符会让一段 + * 代码读起来和执行起来不一样(`highlightSpecialChars` 把它们画成红点,悬停是码位)。 + * + * 按需加载(`lazy`),理由同配置文件编辑器:CodeMirror 只在这几个对话框里用。 + */ +export default function CodeView({ + code, + errorAt, + maxHeight = 320, +}: { + code: string; + /** 报错的那一行(1 起),滚到那里并标出来 */ + errorAt?: number | null; + maxHeight?: number; +}) { + const host = useRef(null); + const view = useRef(null); + + useEffect(() => { + if (!host.current) return; + const v = new EditorView({ + parent: host.current, + state: EditorState.create({ + doc: code, + extensions: [ + lineNumbers(), + highlightSpecialChars({ addSpecialChars: /[\u200c\u200d\u202a-\u202c\u2060-\u2064\u2068]/ }), + javascript, + syntaxHighlighting(highlight), + EditorState.readOnly.of(true), + EditorView.editable.of(false), + EditorView.lineWrapping, + errorLine, + EditorView.theme({ + "&": { fontSize: "12px", maxHeight: `${maxHeight}px`, backgroundColor: "transparent", color: "var(--foreground)" }, + ".cm-scroller": { overflow: "auto", fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }, + ".cm-gutters": { + backgroundColor: "transparent", + color: "var(--muted-foreground)", + borderRight: "1px solid var(--border)", + }, + ".cm-content": { cursor: "text" }, + "&.cm-focused": { outline: "none" }, + ".cm-selectionBackground, ::selection": { + backgroundColor: "color-mix(in oklab, var(--chart-2) 35%, transparent)", + }, + ".cm-tw-error": { backgroundColor: "color-mix(in oklab, var(--destructive) 14%, transparent)" }, + ".cm-specialChar": { color: "var(--destructive)" }, + }), + ], + }), + }); + view.current = v; + return () => { + v.destroy(); + view.current = null; + }; + // 文档换了由下面那个 effect 同步;高度只在建的时候定 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const v = view.current; + if (!v || v.state.doc.toString() === code) return; + v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: code } }); + }, [code]); + + useEffect(() => { + const v = view.current; + if (!v) return; + const line = errorAt != null && errorAt >= 1 && errorAt <= v.state.doc.lines ? errorAt : null; + v.dispatch({ + effects: [ + setError.of(line), + ...(line ? [EditorView.scrollIntoView(v.state.doc.line(line).from, { y: "center" })] : []), + ], + }); + }, [errorAt, code]); + + return
; +} diff --git a/src/plugins/ListDialogs.tsx b/src/plugins/ListDialogs.tsx new file mode 100644 index 00000000..859ce7e5 --- /dev/null +++ b/src/plugins/ListDialogs.tsx @@ -0,0 +1,168 @@ +import { useState } from "react"; +import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/ui/alert-dialog"; +import { Banner } from "@/ui/banner"; +import { Button } from "@/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { ConfirmAction, focusSelf } from "@/keys/parts"; +import { DialogError } from "@/upstreams/parts"; +import type { PluginView } from "./api.provisional"; +import { PluginText, StatusOf } from "./parts"; +import { pluginsPageText } from "./PluginsPage.i18n"; + +/** + * 删除一个插件的确认。按下「删除」之后对话框留着、按钮转圈,直到 core 回话:成功了才关 + * (那一行随之淡出),失败了原因写在这里(和删除密钥同一个做法)。 + */ +export function DeleteDialog({ + target, + onDelete, + onClose, +}: { + target: PluginView; + onDelete: () => Promise; + onClose: () => void; +}) { + const t = useText(pluginsPageText); + const common = useText(commonText); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + async function run() { + setPending(true); + setError(null); + try { + await onDelete(); + onClose(); + } catch (e) { + setError(e); + setPending(false); + } + } + + return ( + !o && !pending && onClose()}> + + + {t.deleteTitle()} + {t.deleteDescription} + + + {error !== null && errorText(error)} + + + {common.cancel} + void run()}> + {common.delete} + + + + + ); +} + +/** + * 调整运行顺序。**顺序有意义**:前一个插件改过的内容交给后一个。上下移好了一次保存,写成 + * 一个配置版本(`ReorderPlugins`),不是每挪一下写一次。 + */ +export function ReorderDialog({ + list, + onSave, + onClose, +}: { + list: PluginView[]; + onSave: (ids: string[]) => Promise; + onClose: () => void; +}) { + const t = useText(pluginsPageText); + const common = useText(commonText); + const [order, setOrder] = useState(() => list.map((p) => p.id)); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const byId = new Map(list.map((p) => [p.id, p])); + const dirty = order.some((id, i) => id !== list[i]?.id); + + const move = (i: number, d: -1 | 1) => + setOrder((o) => { + const j = i + d; + if (j < 0 || j >= o.length) return o; + const next = [...o]; + [next[i], next[j]] = [next[j]!, next[i]!]; + return next; + }); + + async function save() { + setSaving(true); + setError(null); + try { + await onSave(order); + } catch (e) { + setError(errorText(e)); + setSaving(false); + } + } + + return ( + !o && !saving && onClose()}> + + + {t.reorderTitle} + {t.reorderDescription} + +
    + {order.map((id, i) => { + const p = byId.get(id); + if (!p) return null; + return ( +
  1. + {i + 1} + + + + + + +
  2. + ); + })} +
+ + + + + +
+
+ ); +} diff --git a/src/plugins/LogsDialog.i18n.tsx b/src/plugins/LogsDialog.i18n.tsx new file mode 100644 index 00000000..a67fa966 --- /dev/null +++ b/src/plugins/LogsDialog.i18n.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; +import { messages } from "@/i18n"; + +export const logsDialogText = messages( + { + title: "日志", + lead: (name: ReactNode) => <>插件「{name}」通过 console 写下的内容,新的在前。, + refresh: "刷新", + empty: "尚无日志", + emptyHint: "插件调用 console.log 等方法时,写下的内容显示在这里。", + loadFailed: "日志读取失败", + levels: { log: "日志", info: "信息", warn: "警告", error: "错误" } as Record, + hooks: { request: "请求", reply: "回答" } as Record, + request: (id: string) => `请求 #${id}`, + openRequest: (id: string) => `打开请求 #${id}`, + close: "关闭", + }, + { + title: "Logs", + lead: (name: ReactNode) => <>What plugin “{name}” wrote through console, newest first., + refresh: "Refresh", + empty: "No logs yet", + emptyHint: "What the plugin writes with console.log and similar methods appears here.", + loadFailed: "The logs could not be loaded", + levels: { log: "log", info: "info", warn: "warn", error: "error" } as Record, + hooks: { request: "Request", reply: "Reply" } as Record, + request: (id: string) => `Request #${id}`, + openRequest: (id: string) => `Open request #${id}`, + close: "Close", + }, +); diff --git a/src/plugins/LogsDialog.tsx b/src/plugins/LogsDialog.tsx new file mode 100644 index 00000000..a01588da --- /dev/null +++ b/src/plugins/LogsDialog.tsx @@ -0,0 +1,123 @@ +import { RefreshCwIcon, ScrollTextIcon } from "lucide-react"; +import { Badge } from "@/ui/badge"; +import { Button } from "@/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; +import { EmptyState, ListSkeleton, Loadable } from "@/ui/states"; +import { useResource } from "@/lib/resource"; +import { cn } from "@/lib/utils"; +import { useText } from "@/i18n"; +import { clock } from "@/security/labels"; +import { focusSelf } from "@/keys/parts"; +import { PLUGIN_FAILED, pluginCall, type PluginLogEntry, type PluginView } from "./api.provisional"; +import { logsDialogText } from "./LogsDialog.i18n"; +import { PluginText } from "./parts"; + +/** + * 一个插件的日志:它在每次运行里用 `console.log/info/warn/error` 写下的东西(core 在内存里 + * 留着最近的一段,`PluginLogs`),新的在前。**写的是什么都只按纯文本画**。哪一条请求写下的, + * 点过去是那条请求的详情。 + */ +export function LogsDialog({ + plugin, + onClose, + onOpenRequest, +}: { + plugin: PluginView; + onClose: () => void; + onOpenRequest: (id: number) => void; +}) { + const t = useText(logsDialogText); + const logs = useResource(`plugin-logs:${plugin.id}`, () => pluginCall("PluginLogs", null, plugin.id), { + events: [PLUGIN_FAILED], + }); + return ( + !o && onClose()}> + + + {t.title} + {t.lead()} + +
+ } + errorTitle={t.loadFailed} + isEmpty={(d) => d.length === 0} + empty={} title={t.empty} description={t.emptyHint} />} + > + {(data) => b.at_ms - a.at_ms)} onOpenRequest={onOpenRequest} />} + +
+ + + + +
+
+ ); +} + +const LEVEL_VARIANT: Record = { + log: "secondary", + info: "secondary", + warn: "warning", + error: "destructive", +}; + +/** + * 几行日志:时刻、级别、请求或回答、哪条请求,下面是写下的话(等宽,保留换行)。 + * 试运行的结果里也用它(那里没有时刻和请求号)。 + */ +export function LogLines({ + logs, + onOpenRequest, + bare, +}: { + logs: PluginLogEntry[]; + onOpenRequest?: (id: number) => void; + /** 试运行里:不写时刻和请求 */ + bare?: boolean; +}) { + const t = useText(logsDialogText); + return ( +
    + {logs.map((l, i) => { + const id = l.request_id != null && /^\d+$/.test(String(l.request_id)) ? String(l.request_id) : null; + return ( +
  • +
    + {!bare && {clock(l.at_ms)}} + + {t.levels[l.level] ?? l.level} + + {t.hooks[l.hook] ?? l.hook} + {!bare && id && onOpenRequest && ( + + )} +
    +
    +              
    +            
    +
  • + ); + })} +
+ ); +} diff --git a/src/plugins/PluginsPage.i18n.tsx b/src/plugins/PluginsPage.i18n.tsx new file mode 100644 index 00000000..65575389 --- /dev/null +++ b/src/plugins/PluginsPage.i18n.tsx @@ -0,0 +1,117 @@ +import type { ReactNode } from "react"; +import { messages } from "@/i18n"; + +export const pluginsPageText = messages( + { + pluginsUnit: (_n: number) => "个插件", + active: "生效中", + disabled: "已停用", + changed: "文件已更改", + failed: "加载失败", + + reorder: "调整顺序", + add: "添加插件", + + emptyTitle: "尚无插件", + emptyDescription: + "插件是一段 JavaScript,在请求发往上游之前改写请求,在回答交给客户端之前改写回答。插件在沙箱中运行,无法联网、读写文件,也看不到密钥。", + loadFailed: "插件列表读取失败", + + order: (n: number) => `第 ${n} 个运行`, + appliesTo: "适用于", + + // 行上的操作:写成字 + settings: "设置", + trial: "试运行", + logs: "日志", + remove: "删除", + review: "审核更改", + replace: "更换代码", + // 同一份操作在右键菜单里:打开对话框的带「…」 + menu: { + settings: "设置…", + trial: "试运行…", + logs: "日志…", + review: "审核更改…", + replace: "更换代码…", + remove: "删除…", + }, + actionsFor: (name: string) => `「${name}」的操作`, + toggleFor: (name: string) => `启用「${name}」`, + turnedOn: (name: ReactNode) => <>已启用插件「{name}」, + turnedOff: (name: ReactNode) => <>已停用插件「{name}」, + + // 不在运行、又会拒绝请求的插件:一直挂着的横幅 + changedTitle: (name: ReactNode) => <>插件「{name}」的文件已更改, + failedTitle: (name: ReactNode) => <>插件「{name}」加载失败, + changedRejecting: "确认更改之前,适用范围内的请求将被拒绝。", + changedSkipping: "确认更改之前,此插件不运行。", + failedRejecting: "修复之前,适用范围内的请求将被拒绝。", + failedSkipping: "修复之前,此插件不运行。", + + deleteTitle: (name: ReactNode) => <>删除插件「{name}」, + deleteDescription: "插件和它的设置将从配置中删除,此后不再运行。", + + reorderTitle: "调整顺序", + reorderDescription: "插件按此顺序依次运行,后一个插件处理的是前一个改写后的内容。", + moveUp: (name: string) => `上移「${name}」`, + moveDown: (name: string) => `下移「${name}」`, + }, + { + pluginsUnit: (n: number) => (n === 1 ? "plugin" : "plugins"), + active: "active", + disabled: "disabled", + changed: "with a changed file", + failed: "failed to load", + + reorder: "Reorder", + add: "Add plugin", + + emptyTitle: "No plugins yet", + emptyDescription: + "A plugin is a piece of JavaScript that rewrites requests before they go upstream and replies before they reach the client. Plugins run in a sandbox with no network, no file access and no view of secrets.", + loadFailed: "The plugin list could not be loaded", + + order: (n: number) => `Runs ${ordinal(n)}`, + appliesTo: "Applies to", + + settings: "Settings", + trial: "Trial run", + logs: "Logs", + remove: "Delete", + review: "Review changes", + replace: "Replace code", + menu: { + settings: "Settings…", + trial: "Trial run…", + logs: "Logs…", + review: "Review changes…", + replace: "Replace code…", + remove: "Delete…", + }, + actionsFor: (name: string) => `Actions for “${name}”`, + toggleFor: (name: string) => `Enable “${name}”`, + turnedOn: (name: ReactNode) => <>Plugin “{name}” enabled, + turnedOff: (name: ReactNode) => <>Plugin “{name}” disabled, + + changedTitle: (name: ReactNode) => <>The file of plugin “{name}” changed, + failedTitle: (name: ReactNode) => <>Plugin “{name}” failed to load, + changedRejecting: "Until the change is approved, requests it applies to are rejected.", + changedSkipping: "Until the change is approved, the plugin does not run.", + failedRejecting: "Until it is fixed, requests it applies to are rejected.", + failedSkipping: "Until it is fixed, the plugin does not run.", + + deleteTitle: (name: ReactNode) => <>Delete plugin “{name}”, + deleteDescription: "The plugin and its settings are removed from the configuration and it no longer runs.", + + reorderTitle: "Reorder", + reorderDescription: "Plugins run in this order. Each plugin works on what the one before it produced.", + moveUp: (name: string) => `Move “${name}” up`, + moveDown: (name: string) => `Move “${name}” down`, + }, +); + +function ordinal(n: number): string { + const s = n % 100 >= 11 && n % 100 <= 13 ? "th" : (["th", "st", "nd", "rd"][n % 10] ?? "th"); + return `${n}${n % 10 > 3 ? "th" : s}`; +} diff --git a/src/plugins/PluginsPage.tsx b/src/plugins/PluginsPage.tsx new file mode 100644 index 00000000..ab366c08 --- /dev/null +++ b/src/plugins/PluginsPage.tsx @@ -0,0 +1,556 @@ +import { useCallback, useEffect, useMemo, useState, type KeyboardEvent, type MouseEvent } from "react"; +import { PlusIcon } from "lucide-react"; +import { Banner } from "@/ui/banner"; +import { Button } from "@/ui/button"; +import { IconPlugin } from "@/ui/icons"; +import { AnimatedNumber, rowMotion, usePresentList } from "@/ui/motion"; +import { undoable } from "@/ui/notify"; +import { Page, PageHeader, SummaryItem } from "@/ui/page"; +import { RowMenu, type MenuItems } from "@/ui/row-menu"; +import { Skeleton } from "@/ui/skeleton"; +import { EmptyState, ListSkeleton, Loadable } from "@/ui/states"; +import { StatusDot } from "@/ui/status-dot"; +import { Switch } from "@/ui/switch"; +import { Tip } from "@/ui/tip"; +import { useResource } from "@/lib/resource"; +import { writeQueue } from "@/lib/writeQueue"; +import { cn } from "@/lib/utils"; +import { useText } from "@/i18n"; +import { useNav, useNavParams } from "@/nav"; +import { useConfigVersion } from "@/keys/data"; +import type { Overview } from "@/types"; +import { PLUGIN_FAILED, pluginCall, type PluginView, type PluginWrite } from "./api.provisional"; +import { ChangedDialog } from "./ChangedDialog"; +import { DeleteDialog, ReorderDialog } from "./ListDialogs"; +import { LogsDialog } from "./LogsDialog"; +import { PermissionChips, PluginText, ScopeSummary, StatsCell, StatusOf } from "./parts"; +import { pluginsPageText } from "./PluginsPage.i18n"; +import { SettingsDialog } from "./SettingsDialog"; +import { SourceDialog, type NativeWrite } from "./SourceDialog"; +import { TrialDialog } from "./TrialDialog"; + +type DialogState = + | null + | { kind: "add" } + | { kind: "settings"; id: string } + | { kind: "replace"; id: string } + | { kind: "review"; id: string } + | { kind: "trial"; id: string } + | { kind: "logs"; id: string } + | { kind: "reorder" } + /** 删的那一个连同它的样子一起记下:删成功之后它从列表里拿掉了,对话框还要放完收起动画 */ + | { kind: "delete"; target: PluginView }; + +/** 插件写回去时的样子:照原样,改其中几项 */ +export function updateOf(p: PluginView) { + return { enabled: p.enabled, on_error: p.on_error, scope: p.scope, settings: p.settings }; +} + +/** + * 插件页。 + * + * 插件是一段 JavaScript:请求发往上游之前改写请求,回答交给客户端之前改写回答。它只在 + * core 的沙箱里运行,**从不在这个界面里运行**。这一页列出装着的插件(按运行的顺序)、各自 + * 的状态和权限,以及进入其余一切的入口:安装、设置、试运行、日志、确认文件变更、删除。 + * + * 几条纪律: + * + * - **插件写的字一律按纯文本画**(名字、说明、设置项的标签、日志、报错),见 `PluginText`。 + * - **安装、更换代码、确认文件变更要在系统原生对话框里点头**:这三步的端点不在网页的 + * 白名单里,只能请 Rust 去做(`plugin_install` 等)。网页里的「安装」只是发起。 + * - 配置的改动都进对话框;启用、停用可以撤销,一按就写;删除要确认。 + * - **不在运行、又会拒绝请求的插件挂一条横幅**:文件变了或者加载不了的插件不运行,出错时 + * 选了「拒绝」的,适用范围内的请求全部被拒 —— 这件事要一直看得见,直到处理掉。 + */ +export default function PluginsPage({ ov, onChanged }: { ov: Overview; onChanged: () => void }) { + const t = useText(pluginsPageText); + const nav = useNav(); + const version = useConfigVersion(ov.config_version); + /** 这一页上的写入排成一队:连着拨两个开关,第二次带第一次写完的版本(见 writeQueue) */ + const queue = useMemo(() => writeQueue(version), [version]); + /* + **统计跟着请求走。**运行次数、改写次数在请求落地时变,所以请求事件也让它重读(节流 + 2.5 秒);core 那边是内存里的数,读一次很便宜。 + */ + const plugins = useResource("plugins", () => pluginCall("Plugins", null), { + events: ["config_reloaded", "request_finished", "request_failed", PLUGIN_FAILED], + deps: [ov.config_version], + }); + const [dialog, setDialog] = useState(null); + const [pending, setPending] = useState>({}); + const list = plugins.data; + const byId = (id: string) => list?.find((p) => p.id === id); + + /** 写完一次:记下新版本,重读列表,告诉外壳(概览跟着重读) */ + const wrote = useCallback( + (v: string) => { + version.set(v); + void plugins.reload(); + onChanged(); + }, + [version, plugins, onChanged], + ); + + /** + * 要原生确认的写入:照样排进队里。用户在系统对话框里取消的,版本号不变、什么都没写 + */ + const native: NativeWrite = useCallback( + async (run: (base: string) => Promise) => { + let cancelled = false; + const w = await queue(async (base) => { + const r = await run(base); + if (r.kind === "cancelled") { + cancelled = true; + return { version: base }; + } + return { version: r.version }; + }); + if (cancelled) return "cancelled"; + wrote(w.version); + return "done"; + }, + [queue, wrote], + ); + + // 从别处来的:定位一个插件、添加、审核文件变更(命令面板、通知) + const [focus, setFocus] = useState(null); + const [highlight, setHighlight] = useState(null); + useNavParams("plugins", (p) => { + setFocus(p.plugin ?? null); + if (p.add) setDialog({ kind: "add" }); + else if (p.review) setDialog({ kind: "review", id: p.review }); + }); + useEffect(() => { + if (!focus || !list?.some((p) => p.id === focus)) return; + document.querySelector(`[data-plugin="${CSS.escape(focus)}"]`)?.scrollIntoView({ block: "center" }); + setHighlight(focus); + setFocus(null); + }, [focus, list]); + useEffect(() => { + if (!highlight) return; + const h = setTimeout(() => setHighlight(null), 1600); + return () => clearTimeout(h); + }, [highlight]); + + async function tracked(id: string, run: () => Promise): Promise { + setPending((p) => ({ ...p, [id]: (p[id] ?? 0) + 1 })); + try { + return await run(); + } finally { + setPending((p) => ({ ...p, [id]: Math.max(0, (p[id] ?? 0) - 1) })); + } + } + + /** 启用、停用。**可以撤销**:先拨过去,写完给「撤销」 */ + function toggle(p: PluginView, enabled: boolean) { + const send = (on: boolean) => + tracked(p.id, async () => { + const w = await queue((base) => + pluginCall("UpdatePlugin", { ...updateOf(p), enabled: on, base_version: base }, p.id), + ); + wrote(w.version); + }); + const name = ; + void undoable({ + message: enabled ? t.turnedOn(name) : t.turnedOff(name), + apply: () => plugins.mutate((ps) => (ps ?? []).map((x) => (x.id === p.id ? { ...x, enabled } : x))), + do: () => send(enabled), + undo: () => send(!enabled), + }); + } + + const taken = (list ?? []).map((p) => p.id); + const at = (d: { id: string }) => byId(d.id); + + return ( + + } + actions={ + <> + {list && list.length > 1 && ( + + )} + + + } + /> + + } + errorTitle={t.loadFailed} + isEmpty={(d) => d.length === 0} + empty={ + } + title={t.emptyTitle} + description={t.emptyDescription} + action={ + + } + /> + } + > + {(data) => ( +
+ setDialog({ kind: "review", id })} onReplace={(id) => setDialog({ kind: "replace", id })} /> + (pending[id] ?? 0) > 0} + onToggle={toggle} + onOpen={(kind, p) => setDialog(kind === "delete" ? { kind, target: p } : { kind, id: p.id })} + /> +
+ )} +
+ + {dialog?.kind === "add" && ( + setDialog(null)} + onDone={(id) => { + setDialog(null); + setFocus(id); + }} + /> + )} + {dialog?.kind === "replace" && at(dialog) && ( + setDialog(null)} + onDone={() => setDialog(null)} + /> + )} + {dialog?.kind === "settings" && at(dialog) && ( + setDialog(null)} + onSaved={(v) => { + wrote(v); + setDialog(null); + }} + onReplace={() => setDialog({ kind: "replace", id: dialog.id })} + /> + )} + {dialog?.kind === "review" && at(dialog) && ( + setDialog(null)} + onApproved={() => setDialog(null)} + onReplace={() => setDialog({ kind: "replace", id: dialog.id })} + /> + )} + {dialog?.kind === "trial" && at(dialog) && setDialog(null)} />} + {dialog?.kind === "logs" && at(dialog) && ( + setDialog(null)} + onOpenRequest={(id) => { + setDialog(null); + nav.open("requests", { request: id }); + }} + /> + )} + {dialog?.kind === "reorder" && list && ( + setDialog(null)} + onSave={async (ids) => { + const w = await queue((base) => pluginCall("ReorderPlugins", { ids, base_version: base })); + plugins.mutate((ps) => ids.map((id) => (ps ?? []).find((p) => p.id === id)!).filter(Boolean)); + wrote(w.version); + setDialog(null); + }} + /> + )} + {dialog?.kind === "delete" && ( + setDialog(null)} + onDelete={async () => { + const w = await queue((base) => pluginCall("DeletePlugin", { base_version: base }, dialog.target.id)); + // 先从列表里拿掉(那一行淡出),再去取真值 + plugins.mutate((ps) => (ps ?? []).filter((p) => p.id !== dialog.target.id)); + wrote(w.version); + }} + /> + )} +
+ ); +} + +/** + * 页头一行:几个插件,几个生效中、停用、文件已更改、加载失败(和行上的状态点同色)。 + */ +function Summary({ list, loading }: { list: PluginView[] | undefined; loading: boolean }) { + const t = useText(pluginsPageText); + if (!list) return loading ? : null; + const count = (kind: PluginView["status"]["kind"]) => list.filter((p) => p.status.kind === kind).length; + const items: [number, "ok" | "idle" | "warn" | "error", string][] = [ + [count("ok"), "ok", t.active], + [count("disabled"), "idle", t.disabled], + [count("changed"), "warn", t.changed], + [count("error"), "error", t.failed], + ]; + return ( + <> + } label={t.pluginsUnit(list.length)} /> + {items + .filter(([n]) => n > 0) + .map(([n, tone, label]) => ( + } value={n} label={label} /> + ))} + + ); +} + +/** + * 启用着、却没在运行的插件:文件变了,或者加载不了。**选了「拒绝这次请求」的,适用范围内的 + * 请求此刻全被拒绝** —— 一条一直在的横幅,按钮直接通到处理它的那个对话框。选了「跳过」的 + * 不拒请求,只是没在运行,用灰色的那一档。 + */ +function Stopped({ + list, + onReview, + onReplace, +}: { + list: PluginView[]; + onReview: (id: string) => void; + onReplace: (id: string) => void; +}) { + const t = useText(pluginsPageText); + const stopped = list.filter((p) => p.enabled && (p.status.kind === "changed" || p.status.kind === "error")); + if (stopped.length === 0) return null; + return ( +
+ {stopped.map((p) => { + const reject = p.on_error === "reject"; + const name = ; + if (p.status.kind === "changed") { + return ( + onReview(p.id)}> + {t.review} + + } + > + {reject ? t.changedRejecting : t.changedSkipping} + + ); + } + return ( + onReplace(p.id)}> + {t.replace} + + } + > + {p.status.kind === "error" && ( +

+ +

+ )} +

{reject ? t.failedRejecting : t.failedSkipping}

+
+ ); + })} +
+ ); +} + +type RowAction = "settings" | "trial" | "logs" | "review" | "replace" | "delete"; + +/** + * 插件列表,**按运行的顺序**:前一个改过的内容交给后一个,所以行首写着它是第几个。 + * + * 每一行三行字:名字和状态;说明(加载失败的是原因);权限、适用范围、运行统计。右边是 + * 启用的开关,和**写成字的几个操作**(设置、试运行、日志、删除)—— 不用图标:这一页上 + * 每个操作都要一眼认得出,`…` 里藏着的东西用户想不到去找。文件变了的、加载不了的, + * 处理它的那个操作排在最前面。点一行(或回车)打开设置;右键是同一份操作。 + */ +function PluginList({ + list, + highlight, + pending, + onToggle, + onOpen, +}: { + list: PluginView[]; + highlight: string | null; + pending: (id: string) => boolean; + onToggle: (p: PluginView, enabled: boolean) => void; + onOpen: (kind: RowAction, p: PluginView) => void; +}) { + const t = useText(pluginsPageText); + const rows = usePresentList(list, (p) => p.id); + const menu = (p: PluginView): MenuItems => [ + ...(p.status.kind === "changed" ? [{ kind: "item" as const, label: t.menu.review, onSelect: () => onOpen("review", p) }] : []), + { kind: "item", label: t.menu.settings, onSelect: () => onOpen("settings", p) }, + { kind: "item", label: t.menu.trial, onSelect: () => onOpen("trial", p), disabled: p.status.kind === "error" }, + { kind: "item", label: t.menu.logs, onSelect: () => onOpen("logs", p) }, + { kind: "item", label: t.menu.replace, onSelect: () => onOpen("replace", p) }, + { kind: "sep" }, + { kind: "item", label: t.menu.remove, onSelect: () => onOpen("delete", p), danger: true }, + ]; + return ( +
    + {rows.map(({ item: p, key, presence }, i) => ( + +
  • onOpen("settings", p))} + > + + {i + 1} + +
    +
    + + +
    + {p.status.kind === "error" ? ( +

    + +

    + ) : ( + p.description && ( +

    + +

    + ) + )} + {/* + 权限、适用范围、统计在左,写成字的操作在右。**窗口窄时操作整组折到下一行**(靠右), + 不去挤名字和状态 + */} +
    + + + {t.appliesTo} + + + + + {p.status.kind === "changed" && ( + + )} + {p.status.kind === "error" && ( + + )} + onOpen("settings", p)}>{t.settings} + onOpen("trial", p)} disabled={p.status.kind === "error"}> + {t.trial} + + onOpen("logs", p)}>{t.logs} + onOpen("delete", p)} danger> + {t.remove} + + +
    +
    +
    + onToggle(p, v)} + /> +
    +
  • +
    + ))} +
+ ); +} + +/** 行上写成字的一个操作。灰字,悬停变实;删除悬停是红的 */ +function RowWord({ + onClick, + disabled, + danger, + children, +}: { + onClick: () => void; + disabled?: boolean; + danger?: boolean; + children: string; +}) { + return ( + + ); +} + +/** 可以点开的一行:单击、回车、空格打开。**选中文字不算点击**;行里的按钮不冒泡上来 */ +function openable(open: () => void) { + return { + tabIndex: 0, + onClick: (e: MouseEvent) => { + if (e.defaultPrevented) return; + const sel = window.getSelection(); + if (sel && !sel.isCollapsed && e.currentTarget.contains(sel.anchorNode)) return; + open(); + }, + onKeyDown: (e: KeyboardEvent) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + open(); + } + }, + }; +} + +/** 行里自己能点的那一块(开关、操作):点击和按键不再冒泡成「打开这一行」 */ +const keepInRow = { + onClick: (e: MouseEvent) => e.stopPropagation(), + onKeyDown: (e: KeyboardEvent) => e.stopPropagation(), +}; diff --git a/src/plugins/SettingsDialog.i18n.ts b/src/plugins/SettingsDialog.i18n.ts new file mode 100644 index 00000000..8cf9fb12 --- /dev/null +++ b/src/plugins/SettingsDialog.i18n.ts @@ -0,0 +1,20 @@ +import { messages } from "@/i18n"; + +export const settingsDialogText = messages( + { + title: "插件设置", + enabled: "启用", + enabledHint: "停用后此插件不运行,适用范围内的请求照常转发。", + id: (id: string) => `ID ${id}`, + sha: (prefix: string) => `SHA-256 ${prefix}`, + replace: "更换代码…", + }, + { + title: "Plugin settings", + enabled: "Enabled", + enabledHint: "When disabled, the plugin does not run and the requests it applies to are forwarded as usual.", + id: (id: string) => `ID ${id}`, + sha: (prefix: string) => `SHA-256 ${prefix}`, + replace: "Replace code…", + }, +); diff --git a/src/plugins/SettingsDialog.tsx b/src/plugins/SettingsDialog.tsx new file mode 100644 index 00000000..5b280002 --- /dev/null +++ b/src/plugins/SettingsDialog.tsx @@ -0,0 +1,142 @@ +import { useState } from "react"; +import { Button } from "@/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; +import { Switch } from "@/ui/switch"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { focusSelf, useDialogFocus } from "@/keys/parts"; +import { DialogError } from "@/upstreams/parts"; +import { pluginCall, type OnError, type PluginView } from "./api.provisional"; +import { draftOf, scopeOf, scopeProblem, ScopeFields, settingsDraftOf, settingsOf, SettingsFields } from "./fields"; +import { pluginFieldsText } from "./fields.i18n"; +import { pluginLabelsText } from "./labels.i18n"; +import { shaPrefix } from "./model"; +import { PermissionChips, PluginText } from "./parts"; +import { settingsDialogText } from "./SettingsDialog.i18n"; +import { OnErrorField } from "./SourceDialog"; + +/** + * 一个插件的设置:启用、出错时、适用范围、插件自己的设置项。**改完点保存才写** + * (`UpdatePlugin`,一次写成一个配置版本)。 + * + * 保存带的是**打开时的版本号**:对话框开着的时候别处改了配置,core 会说「版本不一致」, + * 而不是让这份旧表单把别人的改动盖掉(和密钥对话框同一条)。 + * + * 代码不在这里改:「更换代码」走另一个对话框,最后要在系统对话框里确认。 + */ +export function SettingsDialog({ + plugin, + version, + onClose, + onSaved, + onReplace, +}: { + plugin: PluginView; + version: { get: () => string }; + onClose: () => void; + onSaved: (version: string) => void; + onReplace: () => void; +}) { + const t = useText(settingsDialogText); + const lt = useText(pluginLabelsText); + const ft = useText(pluginFieldsText); + const common = useText(commonText); + const dialogFocus = useDialogFocus(); + const [base] = useState(() => version.get()); + const [enabled, setEnabled] = useState(plugin.enabled); + const [onError, setOnError] = useState(plugin.on_error); + const [scope, setScope] = useState(() => draftOf(plugin.scope)); + const [settings, setSettings] = useState(() => settingsDraftOf(plugin.settings_schema, plugin.settings)); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const check = settingsOf(plugin.settings_schema, settings); + const nextScope = scopeOf(scope); + const dirty = + enabled !== plugin.enabled || + onError !== plugin.on_error || + JSON.stringify(nextScope) !== JSON.stringify(plugin.scope) || + JSON.stringify(check.values) !== JSON.stringify(settingsOf(plugin.settings_schema, settingsDraftOf(plugin.settings_schema, plugin.settings)).values); + const missing = scopeProblem(scope); + const problem = missing ? ft.needOne(lt.scopeParts[missing]) : check.bad.length > 0 ? ft.numberBad(check.bad[0]!) : null; + + async function save() { + setSaving(true); + setError(null); + try { + const w = await pluginCall( + "UpdatePlugin", + { enabled, on_error: onError, scope: nextScope, settings: check.values, base_version: base }, + plugin.id, + ); + onSaved(w.version); + } catch (e) { + setError(errorText(e)); + setSaving(false); + } + } + + return ( + !o && !saving && onClose()}> + + + {t.title} + +
+ + + + + {t.id(plugin.id)} + {t.sha(shaPrefix(plugin.sha256))} + + +
+
+
+ +
+
+
+ +

{t.enabledHint}

+
+ +
+ + + +
+

{lt.scope}

+ +
+ + {plugin.settings_schema.length > 0 && ( +
+

{ft.settings}

+ +
+ )} +
+ + + + + + {problem && {problem}} + + + +
+
+ ); +} diff --git a/src/plugins/SourceDialog.i18n.tsx b/src/plugins/SourceDialog.i18n.tsx new file mode 100644 index 00000000..c78cd54d --- /dev/null +++ b/src/plugins/SourceDialog.i18n.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from "react"; +import { messages } from "@/i18n"; + +export const sourceDialogText = messages( + { + addTitle: "添加插件", + replaceTitle: (name: ReactNode) => <>更换「{name}」的代码, + sourceDescription: "选择本地的 .js 文件,或粘贴代码。", + fromFile: "选择文件", + fromPaste: "粘贴代码", + chooseFile: "选择 .js 文件", + chooseAgain: "重新选择", + fileHint: "单个 ES 模块文件,不超过 1 MB。", + pastePlaceholder: "export const manifest = { name: \"…\", api: 1, permissions: [\"system\"] };\n\nexport function onRequest(req, ctx) {\n …\n}", + tooLarge: (size: string) => `文件为 ${size},超过 1 MB 的上限。`, + readFailed: "无法读取此文件。", + next: "下一步", + back: "返回", + + reviewTitle: "审核插件", + cannotLoad: "代码无法加载", + at: (where: string) => `位置:${where}`, + sha: "SHA-256", + code: "代码", + fullCode: "完整代码", + compare: "与当前代码对比", + options: "安装选项", + id: "插件 ID", + idHint: "小写字母、数字和连字符,最多 40 个。", + idBad: "只能使用小写字母、数字和连字符,最多 40 个。", + idTaken: "已有插件使用此 ID。", + install: "安装", + replace: "更换代码", + /** 原生对话框里点了「取消」:什么都没写 */ + cancelled: "已取消,配置未改动。", + /** 只有回答钩子的插件,没有申请改请求的权限 */ + noPermissions: "未申请任何权限。", + }, + { + addTitle: "Add plugin", + replaceTitle: (name: ReactNode) => <>Replace the code of “{name}”, + sourceDescription: "Choose a local .js file, or paste the code.", + fromFile: "Choose a file", + fromPaste: "Paste code", + chooseFile: "Choose a .js file", + chooseAgain: "Choose another", + fileHint: "A single ES module file, up to 1 MB.", + pastePlaceholder: "export const manifest = { name: \"…\", api: 1, permissions: [\"system\"] };\n\nexport function onRequest(req, ctx) {\n …\n}", + tooLarge: (size: string) => `The file is ${size}, over the 1 MB limit.`, + readFailed: "The file could not be read.", + next: "Next", + back: "Back", + + reviewTitle: "Review plugin", + cannotLoad: "The code cannot be loaded", + at: (where: string) => `At ${where}.`, + sha: "SHA-256", + code: "Code", + fullCode: "Full code", + compare: "Compare with current code", + options: "Install options", + id: "Plugin ID", + idHint: "Lowercase letters, digits and hyphens, up to 40.", + idBad: "Use only lowercase letters, digits and hyphens, up to 40.", + idTaken: "Another plugin already uses this ID.", + install: "Install", + replace: "Replace code", + cancelled: "Cancelled. The configuration was not changed.", + noPermissions: "No permissions requested.", + }, +); diff --git a/src/plugins/SourceDialog.tsx b/src/plugins/SourceDialog.tsx new file mode 100644 index 00000000..2ffb3116 --- /dev/null +++ b/src/plugins/SourceDialog.tsx @@ -0,0 +1,443 @@ +import { useRef, useState, type ReactNode } from "react"; +import { FileCodeIcon } from "lucide-react"; +import { Banner } from "@/ui/banner"; +import { Button } from "@/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; +import { Input } from "@/ui/input"; +import { Segmented } from "@/ui/segmented"; +import { Textarea } from "@/ui/textarea"; +import { useResource } from "@/lib/resource"; +import { cn } from "@/lib/utils"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { size } from "@/format"; +import { focusSelf } from "@/keys/parts"; +import { DialogError, FormItem } from "@/upstreams/parts"; +import { + installPlugin, + pluginCall, + replacePluginSource, + type OnError, + type PluginInspection, + type PluginView, + type PluginWrite, +} from "./api.provisional"; +import { draftOf, scopeOf, scopeProblem, ScopeFields, settingsDraftOf, settingsOf, SettingsFields, type ScopeDraft, type SettingsDraft } from "./fields"; +import { pluginFieldsText } from "./fields.i18n"; +import { pluginLabelsText } from "./labels.i18n"; +import { ID_RE, shaPrefix, suggestId } from "./model"; +import { CodeBox, PermissionList, PluginText, SourceDiff } from "./parts"; +import { pluginPartsText } from "./parts.i18n"; +import { sourceDialogText } from "./SourceDialog.i18n"; + +/** 插件文件的上限,和 core 一样 */ +export const MAX_SOURCE = 1024 * 1024; + +/** + * 一次要原生确认的写入,排进这一页的写入队列(见 `PluginsPage`)。`done` 是写成了, + * `cancelled` 是用户在系统对话框里点了取消 —— 什么都没写,不是失败。 + */ +export type NativeWrite = (run: (base: string) => Promise) => Promise<"done" | "cancelled">; + +type Mode = { kind: "add" } | { kind: "replace"; plugin: PluginView }; + +/** + * 添加插件、更换插件的代码:两步。 + * + * 1. **代码从哪儿来**:选一个本地的 `.js` 文件,或者粘贴。只从本地来 —— 不从链接装, + * 没有插件市场。选好之后交给 core 读一遍(`PluginInspect`,不写任何东西)。 + * 2. **审核**:完整的代码、申请的每一项权限和它的后果、适用范围、设置项、ID、出错时的 + * 处置;读不了的说清第几行第几列。按「安装」之后,**由 Rust 再读一遍这份代码**,在 + * 系统原生对话框里写明插件名、权限和 SHA-256 的前几位,点了那里的「安装」才写配置 + * (I12:网页自己完成不了这一步)。这里显示的 SHA-256 和系统对话框里的是同一段, + * 对得上就是同一份代码。 + * + * 更换代码时没有安装选项(ID、范围、设置项都留着),权限和原来的对比:新增的标出来; + * 代码可以和现在确认过的那一份对比。 + */ +export function SourceDialog({ + mode, + taken, + native, + onClose, + onDone, +}: { + mode: Mode; + /** 已有的插件 ID */ + taken: readonly string[]; + native: NativeWrite; + onClose: () => void; + /** 写成了:插件的 ID */ + onDone: (id: string) => void; +}) { + const t = useText(sourceDialogText); + const ft = useText(pluginFieldsText); + const common = useText(commonText); + const [step, setStep] = useState<"source" | "review">("source"); + const [how, setHow] = useState<"file" | "paste">("file"); + const [file, setFile] = useState<{ name: string; size: number; text: string } | null>(null); + const [paste, setPaste] = useState(""); + const [inputError, setInputError] = useState(null); + const [error, setError] = useState(null); + const [inspecting, setInspecting] = useState(false); + /** core 读过的那一份:结果,和读的是哪段代码 */ + const [read, setRead] = useState<{ result: PluginInspection; source: string; fileName: string | null } | null>(null); + const [writing, setWriting] = useState(false); + const [cancelled, setCancelled] = useState(false); + const picker = useRef(null); + + // 安装选项(只有添加时有) + const [id, setId] = useState(""); + const [onError, setOnError] = useState("reject"); + const [scope, setScope] = useState(() => draftOf({ clients: [], models: [], upstreams: [] })); + const [settings, setSettings] = useState({}); + + const source = how === "file" ? (file?.text ?? "") : paste; + const replacing = mode.kind === "replace" ? mode.plugin : null; + + async function choose(f: File | undefined) { + setInputError(null); + setError(null); + if (!f) return; + if (f.size > MAX_SOURCE) { + setFile(null); + setInputError(t.tooLarge(size(f.size))); + return; + } + try { + setFile({ name: f.name, size: f.size, text: await f.text() }); + } catch { + setFile(null); + setInputError(t.readFailed); + } + } + + async function inspect() { + if (new Blob([source]).size > MAX_SOURCE) { + setInputError(t.tooLarge(size(new Blob([source]).size))); + return; + } + setInspecting(true); + setError(null); + try { + const result = await pluginCall("PluginInspect", { source }); + const fileName = how === "file" ? (file?.name ?? null) : null; + setRead({ result, source, fileName }); + const m = result.manifest; + if (m && !replacing) { + setId((cur) => cur || suggestId(m.name, fileName, taken)); + setScope(draftOf(m.scope)); + setSettings(settingsDraftOf(m.settings_schema, {})); + } + setCancelled(false); + setStep("review"); + } catch (e) { + setError(errorText(e)); + } finally { + setInspecting(false); + } + } + + const manifest = read?.result.manifest ?? null; + const loadError = read?.result.error ?? null; + const idProblem = replacing ? null : !ID_RE.test(id) ? t.idBad : taken.includes(id) ? t.idTaken : null; + const settingsCheck = manifest ? settingsOf(manifest.settings_schema, settings) : { values: {}, bad: [] }; + const blocked = + !manifest || loadError != null || idProblem != null || (!replacing && scopeProblem(scope) != null) || settingsCheck.bad.length > 0; + + async function write() { + if (!read || blocked) return; + setWriting(true); + setError(null); + setCancelled(false); + try { + const r = replacing + ? await native((base) => replacePluginSource({ id: replacing.id, source: read.source, base_version: base })) + : await native((base) => + installPlugin({ + source: read.source, + id, + enabled: true, + on_error: onError, + scope: scopeOf(scope), + settings: settingsCheck.values, + base_version: base, + }), + ); + if (r === "done") onDone(replacing ? replacing.id : id); + else setCancelled(true); + } catch (e) { + setError(errorText(e)); + } finally { + setWriting(false); + } + } + + // 名字是插件写的:按纯文本画(`PluginText`),不拼进字符串 + const title = replacing ? t.replaceTitle() : step === "source" ? t.addTitle : t.reviewTitle; + + return ( + !o && !writing && onClose()}> + + + +{title} + + {step === "source" ? ( + {t.sourceDescription} + ) : ( + {t.reviewTitle} + )} + + +
+ {step === "source" ? ( +
+ + value={how} + options={[ + { id: "file", label: t.fromFile }, + { id: "paste", label: t.fromPaste }, + ]} + onChange={(v) => { + setHow(v); + setInputError(null); + }} + /> + {how === "file" ? ( +
+ {file ? ( + <> + + + {file.name} + {size(file.size)} + + + + ) : ( + <> + + {t.fileHint} + + )} + { + void choose(e.target.files?.[0]); + // 同一个文件再选一次也要触发 + e.target.value = ""; + }} + /> +
+ ) : ( +