From cd0e0af60b665c646a1a6a025e34988bcea3a495 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:12:24 +0800 Subject: [PATCH 1/8] plugins: the JS file is the source of truth, one editor, fewer confirmations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins contract addendum 4 (core v0.59.0, protocol 35). On error, scope and setting values live in the plugin's manifest; config keeps only the file, its hash and the switch. Built against provisional types until core ships. Editor - Clicking a plugin opens one editor with two tabs and a single Save: - Settings: the enable switch (an app toggle, not in the code), on error (Segmented), scope and the plugin's settings form. - Code: the current code in an editable CodeMirror, with "Import from file…" at the top right. - Form edits go through PluginRewrite into the code. Code edits go through PluginInspect (debounced) into the form. Syntax and manifest errors show inline under the line, with line and column, and lock the form until the code loads again. Late answers never overwrite newer edits; switching tabs and saving flush both directions first. - Unsaved changes survive tab switches (both panes stay mounted, so the code keeps its undo history). Closing (Esc, ×, Cancel, outside click) or leaving for Review changes with unsaved changes asks once, in the app. - Scope inputs take `*` patterns and suggest known clients, models and upstreams; a typed pattern lists the known names it matches. - The header shows the SHA-256 of the code being edited (the native dialog shows the same) and marks permissions the edited code adds. - The separate "Replace code" dialog and Settings dialog are gone. A plugin that fails to load, and a changed file that cannot load, offer "Edit code" instead (the latter edits the file on disk). Confirmations (§3) - A native dialog appears only when a plugin holding reply_tool_calls (in the old or new code) is installed, turned on, has its code changed or has an on-disk change approved. Data-only saves, other plugins, turning off, reordering never ask; Delete keeps its in-app confirmation. - The webview decides from the permissions it has: certain cases go straight to the native command, the rest call the plain endpoint (CreatePlugin, SavePlugin, ApprovePluginFile) and fall back to the native command on 403 control.plugin.needs_confirmation. - Install: the in-app review shows the code, permissions, the scope and on error written in the code, and the ID; one click on Install (plus the native dialog for a tool-call plugin). No second review. - The row switch sends the approved code with the new state (SavePlugin). Rust - plugin_install_confirmed, plugin_save_confirmed and plugin_approve_confirmed replace plugin_install, plugin_replace_source, plugin_approve and plugin_update_confirmed. Each re-inspects through core, shows one native dialog and calls the matching …Confirmed endpoint. The save dialog lists what changes: turning on, code (old → new SHA-256), or each data change when a rewrite of the approved code reproduces the save. - CreatePlugin, SavePlugin, ApprovePluginFile and PluginRewrite join both webview allow-lists; the three …Confirmed endpoints stay out (tested). Provisional - src/plugins/api.provisional.ts and src-tauri/src/plugins/wire.rs hold the addendum-4 types and endpoints, with the swap steps for integration. The control-plane test for tool-call plugins is ignored until v0.59.0. Co-Authored-By: Claude Opus 5.5 --- scripts/shots/mock/core.ts | 5 +- src-tauri/src/call.rs | 87 ++- src-tauri/src/lib.rs | 7 +- src-tauri/src/plugins/confirm/mod.rs | 7 +- src-tauri/src/plugins/mod.rs | 402 +++++------- src-tauri/src/plugins/wire.rs | 213 ++++++ src-tauri/src/plugins/words.rs | 214 +++--- src-tauri/src/wire.rs | 35 +- src-tauri/tests/control_plane.rs | 107 ++- src-tauri/tests/ts_bindings.rs | 3 +- src/control.ts | 18 +- src/generated/lite-api.ts | 25 +- src/labels.ts | 3 + src/plugins/ChangedDialog.i18n.tsx | 8 +- src/plugins/ChangedDialog.tsx | 32 +- src/plugins/CodeView.tsx | 191 +++++- src/plugins/Editor.i18n.ts | 82 +++ src/plugins/Editor.tsx | 616 ++++++++++++++++++ src/plugins/PluginsPage.i18n.tsx | 8 +- src/plugins/PluginsPage.tsx | 105 +-- src/plugins/SettingsDialog.i18n.ts | 23 - src/plugins/SettingsDialog.tsx | 162 ----- ...ceDialog.i18n.tsx => SourceDialog.i18n.ts} | 11 - src/plugins/SourceDialog.tsx | 259 ++------ src/plugins/api.provisional.ts | 124 ++++ src/plugins/fields.i18n.ts | 4 - src/plugins/fields.tsx | 222 +++++-- src/plugins/model.test.ts | 97 ++- src/plugins/model.ts | 76 ++- src/plugins/native.test.ts | 101 --- src/plugins/native.ts | 53 -- src/plugins/parts.i18n.ts | 3 + src/plugins/parts.tsx | 69 +- src/plugins/suggestions.ts | 26 + src/plugins/useEditing.ts | 278 ++++++++ src/plugins/write.i18n.ts | 12 + src/plugins/write.test.ts | 164 +++++ src/plugins/write.ts | 114 ++++ src/types.ts | 15 + src/ui/combobox.tsx | 19 + 40 files changed, 2792 insertions(+), 1208 deletions(-) create mode 100644 src-tauri/src/plugins/wire.rs create mode 100644 src/plugins/Editor.i18n.ts create mode 100644 src/plugins/Editor.tsx delete mode 100644 src/plugins/SettingsDialog.i18n.ts delete mode 100644 src/plugins/SettingsDialog.tsx rename src/plugins/{SourceDialog.i18n.tsx => SourceDialog.i18n.ts} (84%) create mode 100644 src/plugins/api.provisional.ts delete mode 100644 src/plugins/native.test.ts delete mode 100644 src/plugins/native.ts create mode 100644 src/plugins/suggestions.ts create mode 100644 src/plugins/useEditing.ts create mode 100644 src/plugins/write.i18n.ts create mode 100644 src/plugins/write.test.ts create mode 100644 src/plugins/write.ts diff --git a/scripts/shots/mock/core.ts b/scripts/shots/mock/core.ts index 118ef22f..7071c204 100644 --- a/scripts/shots/mock/core.ts +++ b/scripts/shots/mock/core.ts @@ -191,7 +191,10 @@ export const CORE: { [N in WebviewEndpoint]: Handler } = { // 产品图里没有插件:插件页是空的,写入一律拒绝 Plugins: () => [], PluginInspect: refuse, - UpdatePlugin: refuse, + PluginRewrite: refuse, + CreatePlugin: refuse, + SavePlugin: refuse, + ApprovePluginFile: refuse, PluginSourceDiff: refuse, DeletePlugin: refuse, ReorderPlugins: refuse, diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index 595ad4ed..b73fac8b 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -11,10 +11,11 @@ //! 命令拼好再给)。路径参数由 `tw_api::fill` 做百分号编码,所以界面给的名字 //! 只能是一段,拼不出别的路径。 //! -//! **插件的四步有意不给**:安装(`CreatePlugin`)、更换代码(`ReplacePluginSource`)、确认变了 -//! 的文件(`ApprovePluginFile`),以及确认过的改动(`UpdatePluginConfirmed`:打开改得了回答 -//! 里工具调用的插件、改它的设置或范围)。界面里的脚本自己就能点网页上的「确定」,所以这 -//! 几步只能经过 `plugins` 里的命令,在系统原生对话框里确认(I12)。 +//! **插件要点头的三步有意不给**:`CreatePluginConfirmed`、`SavePluginConfirmed`、 +//! `ApprovePluginFileConfirmed`(约定附录 4 §3:装上、打开改得了回答里工具调用的插件,改它的 +//! 代码,批准它磁盘上改过的文件)。界面里的脚本自己就能点网页上的「确定」,所以这几步只能 +//! 经过 `plugins` 里的命令,在系统原生对话框里确认。不必点头的装、存、批准(`CreatePlugin`、 +//! `SavePlugin`、`ApprovePluginFile`)在这里:要点头时 core 答 403。 //! //! 做的事不止转发的命令(打开浏览器、写剪贴板、拼概览)仍然各是一个命令。 //! 其中有三个端点**只能经过那些命令**,因为这台机器上的客户端要一起照顾到:删密钥 @@ -29,10 +30,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`)。 @@ -46,6 +47,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}`." ))), @@ -54,7 +56,8 @@ macro_rules! webview_endpoints { }; } -webview_endpoints![ +webview_endpoints! { + core: [ // 进程与概览 Interfaces, Overview, @@ -137,17 +140,24 @@ webview_endpoints![ ChatgptResets, UseChatgptReset, ZaiLoginStatus, - // 插件。装、换代码、批准、确认过的改动走 Rust 这边的命令,见下面的测试。改得了工具调用 - // 的插件,`UpdatePlugin` 在 core 那边只许停用、改出错时怎么办 - Plugins, - PluginInspect, - UpdatePlugin, + // 插件。要点头的三步(`…Confirmed`)走 Rust 这边的命令,见下面的测试 + ApprovePluginFile, PluginSourceDiff, DeletePlugin, ReorderPlugins, TrialPlugin, PluginLogs, -]; + ], + // PROVISIONAL:core v0.59.0 才有(或者形状变了)的插件端点,描述在 `plugins/wire.rs`, + // 接上正式版时并进上面那一组 + provisional: [ + Plugins, + PluginInspect, + PluginRewrite, + CreatePlugin, + SavePlugin, + ], +} /// 请求按这个端点的类型读一遍再发:**界面发来的形状不对,在这里就停下**, /// 不把一个 core 读不了的请求送过去。 @@ -184,30 +194,49 @@ mod tests { "DeleteKey", "RotateKey", "ClientKey", - // 插件的这几步要在原生对话框里确认(I12),见模块说明 - "CreatePlugin", - "ReplacePluginSource", - "ApprovePluginFile", - "UpdatePluginConfirmed", + // 插件要点头的三步只能在原生对话框里确认,见模块说明 + "CreatePluginConfirmed", + "SavePluginConfirmed", + "ApprovePluginFileConfirmed", ] { assert!(!ALLOWED.contains(&name), "{name}"); } } - /// 确认过的插件改动只有一条路:`plugin_update_confirmed` 先弹系统的确认框。界面的清单 - /// 里连这个名字都不该有 —— 有了,网页里的脚本就能替用户打开一个改工具调用的插件 + /// 要点头的插件写入只有一条路:`plugins` 里的命令先弹系统的确认框。界面的清单里连这几个 + /// 名字都不该有 —— 有了,网页里的脚本就能替用户装上、打开、改写一个改工具调用的插件 #[test] - fn a_confirmed_plugin_update_only_goes_through_the_native_dialog() { - assert!(!ALLOWED.contains(&"UpdatePluginConfirmed")); - // 它确实是 core 的一个端点(不是拼错了名字才「不在清单里」) - assert!( - ep::ALL - .iter() - .any(|e| e.name == "UpdatePluginConfirmed" && e.path == "/plugins/{id}/confirmed") - ); + fn the_confirmed_plugin_writes_only_go_through_the_native_dialog() { let ts = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../src/control.ts")) .unwrap(); - assert!(!ts.contains("\"UpdatePluginConfirmed\"")); + for (name, path) in [ + ("CreatePluginConfirmed", "/plugins/confirmed"), + ("SavePluginConfirmed", "/plugins/{id}/confirmed"), + ( + "ApprovePluginFileConfirmed", + "/plugins/{id}/approve/confirmed", + ), + ] { + assert!(!ALLOWED.contains(&name), "{name}"); + assert!(!ts.contains(&format!("\"{name}\"")), "{name}"); + // 它确实是一个端点(不是拼错了名字才「不在清单里」)。PROVISIONAL:接上 core + // v0.59.0 之后改成查 `ep::ALL` + assert!( + crate::plugins::wire::ALL + .iter() + .any(|(n, _, p)| *n == name && *p == path), + "{name}" + ); + } + // 不必点头的那几步在清单里:要点头时 core 答 403,界面再请 Rust + for name in [ + "CreatePlugin", + "SavePlugin", + "ApprovePluginFile", + "PluginRewrite", + ] { + assert!(ALLOWED.contains(&name), "{name}"); + } } /// 前端那份清单和这里一样。多一个,界面调了会被拒;少一个,界面上的类型 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f56cb7ba..7c43a07a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -246,10 +246,9 @@ pub fn run() { mcp::mcp_targets, mcp::plan_mcp, mcp::apply_mcp, - plugins::plugin_install, - plugins::plugin_replace_source, - plugins::plugin_approve, - plugins::plugin_update_confirmed, + plugins::plugin_install_confirmed, + plugins::plugin_save_confirmed, + plugins::plugin_approve_confirmed, scan::scan_clients, diagnostics::save_diagnostics, ]) diff --git a/src-tauri/src/plugins/confirm/mod.rs b/src-tauri/src/plugins/confirm/mod.rs index 9bb9a57a..01e72033 100644 --- a/src-tauri/src/plugins/confirm/mod.rs +++ b/src-tauri/src/plugins/confirm/mod.rs @@ -1,8 +1,9 @@ //! 系统原生的确认对话框(I12)。 //! -//! **为什么不能在网页里问。**安装插件、更换代码、确认文件变更,是把一段会改写每一个请求的 -//! 代码放进网关。网页里的「确定」,网页里的脚本自己就能点 —— 一段混进页面的脚本可以一声 -//! 不响地装上一个插件。系统的对话框画在网页之外,脚本点不到、键盘事件也伪造不到。 +//! **为什么不能在网页里问。**装上、打开一个改得了回答里工具调用的插件,改它的代码,确认它 +//! 改过的文件,是让一段代码改写客户端将要执行的命令。网页里的「确定」,网页里的脚本自己就能 +//! 点 —— 一段混进页面的脚本可以一声不响地装上这样一个插件。系统的对话框画在网页之外,脚本 +//! 点不到、键盘事件也伪造不到。 //! //! 三个平台各用自己的:macOS 是 `NSAlert`,Windows 是 `MessageBoxW`,Linux 是 GTK 的 //! `MessageDialog`(都是应用本来就链接着的东西,不多一个依赖)。 diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs index 17032b8d..5b26510c 100644 --- a/src-tauri/src/plugins/mod.rs +++ b/src-tauri/src/plugins/mod.rs @@ -1,48 +1,46 @@ -//! 插件:要在系统的确认框里点头的那几步(I12)。 +//! 插件:要在系统的确认框里点头的那几步(约定附录 4 §3)。 //! -//! 装插件(`CreatePlugin`)、更换代码(`ReplacePluginSource`)、确认变了的文件 -//! (`ApprovePluginFile`),以及打开改得了回答里工具调用的插件、改它的设置或范围 -//! (`UpdatePluginConfirmed`)**不在网页的白名单里**(见 `call.rs`)。网页只能请这里去做, +//! **只有一种情形要点头**:插件改得了回答里的工具调用(原来的或者新的代码里有 +//! `reply_tool_calls`),而这次要装上它、打开它、改它的代码,或者批准它磁盘上改过的文件。 +//! 这时网页那条路(`CreatePlugin`、`SavePlugin`、`ApprovePluginFile`)答 403 +//! `control.plugin.needs_confirmation`,点头之后发的那三个端点(`…Confirmed`)**不在网页的 +//! 白名单里**(见 `call.rs`):网页里的脚本自己就能点网页上的「确定」,所以只能请这里去做。 //! 而这里不信网页给的任何关于插件的说法: //! //! 1. **自己再读一遍**:代码交给 core 的 `PluginInspect`(不写任何东西);装着的插件从 -//! `Plugins` 读,读不出它要什么权限的(core 那边没有它的 manifest),把批准的那份代码 -//! 再交给 core 编一遍。名字、权限、处理哪几种请求、SHA-256 都从这一次读出来。网页给的 -//! 只有代码本身和用户的选择(ID、范围、设置项、出错时、开关)。 -//! 2. 在系统的确认框里写明插件名、它能做什么,以及 SHA-256 的前几位(审核窗口里写的是同一 -//! 段,对得上就是同一份代码)或者这次改什么。**默认按钮是取消**。 +//! `Plugins` 读,确认过的那一份代码从 `PluginSourceDiff` 取、再读一遍。名字、权限、处理 +//! 哪几种请求、SHA-256、这次改了什么都从这几次读出来。网页给的只有代码本身、ID 和开关。 +//! 2. 在系统的确认框里写明插件名、它能做什么、这次改什么,以及 SHA-256 的前几位(应用里 +//! 写的是同一段,对得上就是同一份代码)。**默认按钮是取消**。 //! 3. 用户点了确认,才把**给人看过的那同一份**交给 core。 //! //! 用户在对话框里取消不是失败:回执是 `cancelled`,网页那边什么都不用报,界面照原样。 //! -//! 其余插件端点(列出、读代码、开关和设置、删除、排序、试运行、日志)网页直接经过 `call` -//! 走;改得了工具调用的插件,core 在那条路上只许停用、改出错时怎么办,别的改动答 -//! `control.plugin.needs_confirmation`,网页再请这里。 +//! 端点和类型暂时是手写的(`wire`,PROVISIONAL):钉着的 core 还没有它们。 use std::collections::BTreeMap; -use tw_api::{ManifestView, PluginUpdate, PluginView, SettingSpecView, SettingValue, ep}; +use tw_api::{Permission, SettingValue, ep}; use crate::AppState; use crate::control::ControlClient; use crate::error::{CmdError, Out, text}; -use crate::wire::{ - PluginApproveRequest, PluginInstallRequest, PluginReplaceRequest, PluginUpdateRequest, - PluginWrite, -}; +use crate::wire::{PluginApproveRequest, PluginInstallRequest, PluginSaveRequest, PluginWrite}; mod confirm; pub mod defaults; +pub mod wire; pub mod words; -use words::{Can, Change, ScopePart}; +use wire::{Inspection, Installed, Manifest}; +use words::{Change, ScopePart}; /// 插件文件的上限,和 core 一样 const MAX_SOURCE: usize = 1024 * 1024; -/// 安装一个插件 +/// 装一个插件(改得了工具调用的那种) #[tauri::command] -pub async fn plugin_install( +pub async fn plugin_install_confirmed( app: tauri::AppHandle, state: tauri::State<'_, AppState>, req: PluginInstallRequest, @@ -54,22 +52,19 @@ pub async fn plugin_install( &defaults::name(req.id.as_deref(), &m.name), &m.permissions, &m.requests, - &req.scope, + &m.scope, &read.sha256, ); if !confirmed(&app, ask).await? { return Ok(PluginWrite::Cancelled); } let w = c - .call::( + .call::( &[], - &tw_api::PluginCreate { + &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, }, ) @@ -78,33 +73,70 @@ pub async fn plugin_install( Ok(PluginWrite::Done { version: w.version }) } -/// 更换一个插件的代码 +/// 保存一个插件:整份代码和开关。打开改得了工具调用的插件、改它的代码时用。 +/// +/// 这次改什么**由这里比出来**:开关和 core 那边现在的比;代码和确认过的那一份比 —— 按新代码 +/// 里的值改写确认过的那一份(`PluginRewrite`),正好得到交上来的这一份,就只是改了数据(出错 +/// 时、适用范围、设置的值),逐项写出来;不然就是改了代码,写出新旧两个 SHA-256。 #[tauri::command] -pub async fn plugin_replace_source( +pub async fn plugin_save_confirmed( app: tauri::AppHandle, state: tauri::State<'_, AppState>, - req: PluginReplaceRequest, + req: PluginSaveRequest, ) -> Out { let c = &state.control; let before = installed(c, &req.id).await?; let read = inspect(c, &req.source).await?; + let approved = approved_code(c, &before).await; + let old = match approved.as_deref() { + Some(src) => inspect(c, src).await.ok().map(|r| r.manifest), + None => None, + }; + let mut changes = Vec::new(); + match (before.enabled, req.enabled) { + (false, true) => changes.push(Change::TurnOn), + (true, false) => changes.push(Change::TurnOff), + _ => {} + } + if read.sha256 != before.sha256 { + let data_only = match (approved.as_deref(), &old) { + (Some(src), Some(_)) => rewrite(c, src, &read.manifest) + .await + .is_some_and(|s| s == req.source), + _ => false, + }; + if !data_only { + changes.push(Change::Code { + from: before.sha256.clone(), + to: read.sha256.clone(), + }); + } + if let Some(old) = &old { + changes.extend(data_changes(&before.id, old, &read.manifest)); + } + } + let previous = match &old { + Some(m) => Some(m.permissions.as_slice()), + None => known(&before), + }; let m = &read.manifest; - let ask = words::replace( + let ask = words::save( &shown_name(&before), &defaults::name(Some(&req.id), &m.name), &m.permissions, &m.requests, - known(&before), - &read.sha256, + previous, + &changes, ); if !confirmed(&app, ask).await? { return Ok(PluginWrite::Cancelled); } let w = c - .call::( + .call::( &[&req.id], - &tw_api::PluginSourceReplace { + &wire::PluginSave { source: req.source, + enabled: req.enabled, base_version: req.base_version, }, ) @@ -113,10 +145,10 @@ pub async fn plugin_replace_source( Ok(PluginWrite::Done { version: w.version }) } -/// 确认一个插件变了的文件。**文件由这里自己去取**(`PluginSourceDiff`),读的、给人看的、 +/// 批准一个插件磁盘上改过的文件。**文件由这里自己去取**(`PluginSourceDiff`),读的、给人看的、 /// 交给 core 认的是同一个 SHA-256;在这期间文件又变了的话,core 那边对不上就不认 #[tauri::command] -pub async fn plugin_approve( +pub async fn plugin_approve_confirmed( app: tauri::AppHandle, state: tauri::State<'_, AppState>, req: PluginApproveRequest, @@ -152,7 +184,7 @@ pub async fn plugin_approve( return Ok(PluginWrite::Cancelled); } let w = c - .call::( + .call::( &[&req.id], &tw_api::PluginApprove { sha256: sha, @@ -164,109 +196,31 @@ pub async fn plugin_approve( Ok(PluginWrite::Done { version: w.version }) } -/// 打开一个改得了回答里工具调用的插件,或者改它的设置、范围(addendum 1 B)。 -/// -/// 网页那条路(`UpdatePlugin`)对这种插件只许停用、改出错时怎么办:网页里注入的脚本要是 -/// 能打开它、改它的设置,就能借它改客户端要执行的命令。这里**从 core 读这个插件现在的 -/// 样子**,和网页交来的那一份比出这次改什么,连同它能做什么一起摆进系统的确认框,点了头 -/// 才发 `UpdatePluginConfirmed` —— 发的就是比过、给人看过的那一份。 -#[tauri::command] -pub async fn plugin_update_confirmed( - app: tauri::AppHandle, - state: tauri::State<'_, AppState>, - req: PluginUpdateRequest, -) -> Out { - let c = &state.control; - let before = installed(c, &req.id).await?; - // 读不出权限的(core 那边没有它的 manifest):把批准的那份代码再编一遍 - let reread = if before.permissions.is_empty() { - reread(c, &before).await - } else { - None - }; - let (name, perms, kinds, schema) = match &reread { - Some(m) => (&m.name, &m.permissions, &m.requests, &m.settings_schema), - None => ( - &before.name, - &before.permissions, - &before.requests, - &before.settings_schema, - ), - }; - let can = if perms.is_empty() { - Can::Unknown - } else { - Can::Known { perms, kinds } - }; - let ask = words::update( - &defaults::name(Some(&before.id), name), - can, - &changes(&before, name, schema, &req.update), - ); - if !confirmed(&app, ask).await? { - return Ok(PluginWrite::Cancelled); - } - let w = c - .call::(&[&req.id], &req.update) - .await - .map_err(text)?; - Ok(PluginWrite::Done { version: w.version }) -} - -/// 这次改了什么,按确认框里的先后:开关、设置、范围(每一项单独说)、出错时怎么办。 -/// -/// **设置按生效的值比**(和 core 一样):没写进去的按默认值算,所以表单原样交回来的默认值 -/// 不算一次改动。范围不看顺序、空白和重复。`name` 是 manifest 里的名字(认默认插件、 -/// 取它的标签用),`schema` 是它的设置项。 -fn changes( - before: &PluginView, - name: &str, - schema: &[SettingSpecView], - next: &PluginUpdate, -) -> Vec { +/// 两份 manifest 的数据改了什么,按确认框里的先后:设置(按声明的顺序)、适用范围(每一项 +/// 单独说)、出错时怎么办。设置的标签按新代码里写的(默认插件按界面语言);范围不看顺序、 +/// 空白和重复 +fn data_changes(id: &str, old: &Manifest, new: &Manifest) -> Vec { let mut out = Vec::new(); - match (before.enabled, next.enabled) { - (false, true) => out.push(Change::TurnOn), - (true, false) => out.push(Change::TurnOff), - _ => {} - } - let effective = |given: &BTreeMap| { - let mut all = given.clone(); - for s in schema { - all.entry(s.key.clone()) - .or_insert_with(|| s.default.clone()); - } - all - }; - let (was, will) = (effective(&before.settings), effective(&next.settings)); - // 声明的那几项按声明的顺序,声明之外的(读不出 manifest 时)跟在后面 - let mut keys: Vec<&String> = schema.iter().map(|s| &s.key).collect(); - for k in was.keys().chain(will.keys()) { - if !keys.contains(&k) { - keys.push(k); - } - } - for key in keys { - let (a, b) = (was.get(key), will.get(key)); - if a == b { + let was: BTreeMap<&str, &SettingValue> = old + .settings_schema + .iter() + .map(|s| (s.key.as_str(), &s.value)) + .collect(); + for s in &new.settings_schema { + let Some(&from) = was.get(s.key.as_str()) else { continue; - } - let label = schema - .iter() - .find(|s| &s.key == key) - .map(|s| defaults::label(&before.id, name, key, &s.label)) - .unwrap_or_else(|| key.clone()); - let value = |v: Option<&SettingValue>| { - words::setting_value(v.unwrap_or(&SettingValue::String(String::new()))) }; + if from == &s.value { + continue; + } out.push(Change::Setting { - label, - from: value(a), - to: value(b), + label: defaults::label(id, &new.name, &s.key, &s.label), + from: words::setting_value(from), + to: words::setting_value(&s.value), }); } for part in ScopePart::ALL { - let (a, b) = (norm(part.of(&before.scope)), norm(part.of(&next.scope))); + let (a, b) = (norm(part.of(&old.scope)), norm(part.of(&new.scope))); if a != b { out.push(Change::Scope { part, @@ -275,10 +229,10 @@ fn changes( }); } } - if before.on_error != next.on_error { + if old.on_error != new.on_error { out.push(Change::OnError { - from: before.on_error, - to: next.on_error, + from: old.on_error, + to: new.on_error, }); } out @@ -294,11 +248,11 @@ fn norm(list: &[String]) -> Vec { /// core 读过一遍的代码:manifest 和 SHA-256(都是 core 读出来的,不是网页说的) struct Read { - manifest: ManifestView, + manifest: Manifest, sha256: String, } -/// 交给 core 读一遍。读不了(语法、清单不对)就停在这里:审核窗口里已经说过原因, +/// 交给 core 读一遍。读不了(语法、清单不对)就停在这里:应用里已经说过原因, /// 走到这一步只可能是网页没照规矩来 async fn inspect(c: &ControlClient, source: &str) -> Out { if source.len() > MAX_SOURCE { @@ -307,8 +261,8 @@ async fn inspect(c: &ControlClient, source: &str) -> Out { "The plugin file is over the 1 MB limit." ))); } - let i = c - .call::( + let raw = c + .call::( &[], &tw_api::PluginSource { source: source.to_string(), @@ -316,6 +270,7 @@ async fn inspect(c: &ControlClient, source: &str) -> Out { ) .await .map_err(text)?; + let i: Inspection = serde_json::from_value(raw).map_err(|e| CmdError::plain(e.to_string()))?; if let Some(e) = i.error { // core 的那一句带着码(语法错还带行列),界面照码说 return Err(e.message.into()); @@ -332,30 +287,41 @@ async fn inspect(c: &ControlClient, source: &str) -> Out { }) } -/// 读不出权限的插件:把**批准的那一份**代码交给 core 再编一遍。只认哈希和配置里批准的 -/// 一样的那一份(底稿,或者没被改过的插件文件);读不成是 `None`,确认框按读不出说 -async fn reread(c: &ControlClient, p: &PluginView) -> Option { +/// 确认过的那一份代码:底稿,或者没被改过的插件文件(哈希和配置里确认的一样)。都没有是 `None` +async fn approved_code(c: &ControlClient, p: &Installed) -> Option { let src = c.call::(&[&p.id], &()).await.ok()?; - let code = if !src.approved.is_empty() && src.approved_sha256 == p.sha256 { - src.approved + if !src.approved.is_empty() && src.approved_sha256 == p.sha256 { + Some(src.approved) } else if src.current_sha256.as_deref() == Some(p.sha256.as_str()) { - src.current? + src.current } else { - return None; + None + } +} + +/// 按这份 manifest 里的值改写一段代码。没成是 `None` +async fn rewrite(c: &ControlClient, source: &str, m: &Manifest) -> Option { + let req = wire::PluginRewriteRequest { + source: source.to_string(), + on_error: m.on_error, + scope: m.scope.clone(), + settings: m + .settings_schema + .iter() + .map(|s| (s.key.clone(), s.value.clone())) + .collect(), }; - let i = c - .call::(&[], &tw_api::PluginSource { source: code }) + c.call::(&[], &req) .await - .ok()?; - if i.sha256 != p.sha256 { - return None; - } - i.manifest + .ok() + .map(|r| r.source) } /// 装着的那一个插件现在的样子(core 说的) -async fn installed(c: &ControlClient, id: &str) -> Out { - let all = c.call::(&[], &()).await.map_err(text)?; +async fn installed(c: &ControlClient, id: &str) -> Out { + let raw = c.call::(&[], &()).await.map_err(text)?; + let all: Vec = + serde_json::from_value(raw).map_err(|e| CmdError::plain(e.to_string()))?; all.into_iter().find(|p| p.id == id).ok_or_else(|| { CmdError::plain(tr!( format!("插件「{id}」不存在,可能已被删除。"), @@ -365,13 +331,13 @@ async fn installed(c: &ControlClient, id: &str) -> Out { } /// 装着的插件在确认框里叫什么:默认插件按界面语言说(和插件页上一样),别的照它自己写的 -fn shown_name(p: &PluginView) -> String { +fn shown_name(p: &Installed) -> String { defaults::name(Some(&p.id), &p.name) } /// 装着的那一版申请的权限。读不出来(core 那边没有它的 manifest)是 `None`:不知道哪一项 /// 是新的,就不标「新增」 -fn known(p: &PluginView) -> Option<&[tw_api::Permission]> { +fn known(p: &Installed) -> Option<&[Permission]> { (!p.permissions.is_empty()).then_some(p.permissions.as_slice()) } @@ -395,10 +361,8 @@ fn changed_meanwhile() -> CmdError { #[cfg(test)] mod tests { use super::*; - use tw_api::{ - OnError, Permission, PluginScope, PluginStats, PluginStatus, ReplyMode, RequestKind, - SettingKind, - }; + use tw_api::{OnError, PluginScope, RequestKind, SettingKind}; + use wire::SettingSpec; #[test] fn the_receipt_says_done_or_cancelled() { @@ -411,99 +375,63 @@ mod tests { assert_eq!(no, serde_json::json!({ "kind": "cancelled" })); } - /// 网页给的安装请求里**没有清单**:多给了也不读(名字、权限由这里自己读) + /// 网页给的安装请求里**没有清单**:多给了也不读(名字、权限、范围由这里自己读) #[test] fn an_install_request_carries_no_manifest() { let req: PluginInstallRequest = serde_json::from_value(serde_json::json!({ "source": "export const manifest = {}", "id": "x", "enabled": true, - "on_error": "reject", - "scope": { "clients": [], "models": [], "upstreams": [] }, - "settings": { "note": "今天", "n": 3, "on": true }, "base_version": null, - "manifest": { "name": "伪造的名字", "permissions": [] } + "manifest": { "name": "伪造的名字", "permissions": [] }, + "scope": { "clients": ["伪造的范围"], "models": [], "upstreams": [] } })) .unwrap(); assert_eq!(req.id.as_deref(), Some("x")); - assert!(!format!("{req:?}").contains("伪造的名字")); - assert_eq!(req.settings["n"], SettingValue::Number(3.0)); + let shown = format!("{req:?}"); + assert!( + !shown.contains("伪造的名字") && !shown.contains("伪造的范围"), + "{shown}" + ); } - fn view() -> PluginView { - PluginView { - id: "reply-language".into(), + fn manifest() -> Manifest { + Manifest { name: "Answer in a chosen language".into(), - description: None, - enabled: false, - on_error: OnError::Reject, permissions: vec![Permission::System], requests: vec![RequestKind::Conversation], scope: PluginScope::default(), - reply_mode: ReplyMode::Block, - settings_schema: vec![SettingSpecView { + on_error: OnError::Reject, + settings_schema: vec![SettingSpec { key: "language".into(), kind: SettingKind::String, - label: "回答语言".into(), - default: SettingValue::String("简体中文".into()), + label: "Answer language".into(), + value: SettingValue::String("简体中文".into()), }], - settings: BTreeMap::from([( - "language".to_string(), - SettingValue::String("简体中文".into()), - )]), - sha256: "aa".into(), - status: PluginStatus::Disabled, - stats: PluginStats::default(), - } - } - - fn update_of(p: &PluginView) -> PluginUpdate { - PluginUpdate { - enabled: p.enabled, - on_error: p.on_error, - scope: p.scope.clone(), - settings: p.settings.clone(), - base_version: None, } } - /// 只拨开关:确认框里只有「启用」这一项 - #[test] - fn turning_on_is_the_only_change_when_only_the_switch_moves() { - let p = view(); - let next = PluginUpdate { - enabled: true, - ..update_of(&p) - }; - assert_eq!( - changes(&p, &p.name, &p.settings_schema, &next), - [Change::TurnOn] - ); - } - - /// 设置按生效的值比:表单不交默认值、交回原样的默认值,都不算改动;范围不看顺序 + /// 只改了数据:每一项单独说,设置的标签按界面语言(默认插件),两个值都写出来; + /// 范围不看顺序和空白 #[test] - fn defaults_and_reordered_scopes_are_not_changes() { - let mut p = view(); - p.scope.models = vec!["b*".into(), "a*".into()]; - let mut next = update_of(&p); - next.settings.clear(); - next.scope.models = vec!["a*".into(), " b* ".into(), "a*".into()]; - assert!(changes(&p, &p.name, &p.settings_schema, &next).is_empty()); - } - - #[test] - fn a_changed_setting_names_its_label_and_both_values() { + fn data_changes_name_each_setting_scope_part_and_on_error() { crate::i18n::with_lang(crate::i18n::Lang::Zh, || { - let p = view(); - let mut next = update_of(&p); - next.settings - .insert("language".into(), SettingValue::String("English".into())); - next.scope.upstreams = vec!["deepseek".into()]; - next.on_error = OnError::Skip; - let c = changes(&p, &p.name, &p.settings_schema, &next); + let old = Manifest { + scope: PluginScope { + models: vec!["b*".into(), "a*".into()], + ..PluginScope::default() + }, + ..manifest() + }; + let mut new = manifest(); + new.scope.models = vec![" a*".into(), "b*".into(), "a*".into()]; + assert!(data_changes("reply-language", &old, &new).is_empty()); + + new.settings_schema[0].value = SettingValue::String("English".into()); + new.scope.upstreams = vec!["deepseek".into()]; + new.on_error = OnError::Skip; assert_eq!( - c, + data_changes("reply-language", &old, &new), [ Change::Setting { label: "回答语言".into(), @@ -523,4 +451,18 @@ mod tests { ); }); } + + /// 新代码里才有的设置项不算一项改动(它在「改了代码」里) + #[test] + fn a_new_setting_is_part_of_the_code_change() { + let old = manifest(); + let mut new = manifest(); + new.settings_schema.push(SettingSpec { + key: "tone".into(), + kind: SettingKind::String, + label: "Tone".into(), + value: SettingValue::String("formal".into()), + }); + assert!(data_changes("reply-language", &old, &new).is_empty()); + } } diff --git a/src-tauri/src/plugins/wire.rs b/src-tauri/src/plugins/wire.rs new file mode 100644 index 00000000..edd8b213 --- /dev/null +++ b/src-tauri/src/plugins/wire.rs @@ -0,0 +1,213 @@ +//! PROVISIONAL:插件的 JS 文件是唯一的真相(约定附录 4,core v0.59.0、协议 35)之后的端点和 +//! 类型,照约定手写。 +//! +//! **core 发版之前只能这样**:钉着的 core v0.58.0 的 `tw_api` 里还没有它们,而且那一版的 +//! `PluginView`、`PluginInspection` 读不了新的样子(设置项的 `value` 取代了 `default`,manifest +//! 多了 `on_error`)。`tw_api::Endpoint` 是公开的 trait,这里照它给每个端点写一份描述, +//! `call` 命令(`call.rs` 的 `provisional` 那一组)和原生确认的三个命令就能照常走 +//! `ControlClient::call`。 +//! +//! **网页能经过 `call` 走到的那两个读的端点(`Plugins`、`PluginInspect`),响应一律是 +//! `serde_json::Value`**:原样转给网页,这里少写一个字段也不会把它从网页那边吞掉。原生确认 +//! 的命令只读其中要用的几项(`Installed`、`Inspection`)。网页那一侧的类型在 +//! `src/plugins/api.provisional.ts`。 +//! +//! 接上正式版(core v0.59.0 发版之后): +//! +//! 1. `Cargo.toml` 的 core 钉点升到 v0.59.0,`cargo update -p tw-api`(几个 core crate 一起), +//! `scripts/fetch-core.sh` 取回同一版的 twcore; +//! 2. `call.rs` 里 `provisional: [...]` 那一组并进 core 那一组(`ep::Plugins` 等), +//! 三个 `…Confirmed` 照样不进; +//! 3. `plugins/mod.rs` 里的 `wire::X` 换成 `tw_api::ep::X`;`Installed`、`Inspection`、 +//! `Manifest`、`SettingSpec` 换成生成的 `PluginView`、`PluginInspection`、`ManifestView`、 +//! `SettingSpecView`,`PluginCreate`、`PluginSave`、`PluginRewriteRequest` 换成 `tw_api` 里的; +//! 4. `tests/control_plane.rs` 里插件那一条去掉 `#[ignore]`; +//! 5. 删掉这个文件,`cargo clippy --all-targets`:名字或形状不一样的地方会在用到它的那一处报错。 + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tw_api::{ + ConfigWritten, Endpoint, Format, Method, OnError, Permission, PluginApprove, PluginLoadError, + PluginScope, PluginSource, RequestKind, SettingKind, SettingValue, +}; + +// ------------------------------------------------------------- 请求 + +/// 装一个插件:代码、ID 和开关。出错时怎么办、适用范围、设置都写在代码里 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginCreate { + pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_version: Option, +} + +/// 保存一个插件:整份代码和开关 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginSave { + pub source: String, + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_version: Option, +} + +/// 按这些值改写代码里的 manifest。没有副作用 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginRewriteRequest { + pub source: String, + pub on_error: OnError, + pub scope: PluginScope, + pub settings: BTreeMap, +} + +/// 改写之后的代码 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginRewritten { + pub source: String, +} + +// ------------------------------------------------------------- 原生确认要读的那几项 + +/// `Plugins` 里的一个插件,原生确认要用的部分 +#[derive(Debug, Clone, Deserialize)] +pub struct Installed { + pub id: String, + /// 读不出 manifest 时是 id + pub name: String, + pub enabled: bool, + /// 读不出 manifest 时是空的 + pub permissions: Vec, + pub requests: Vec, + /// 确认过的那一份的 SHA-256 + pub sha256: String, +} + +/// `PluginInspect` 的结果 +#[derive(Debug, Clone, Deserialize)] +pub struct Inspection { + pub manifest: Option, + pub sha256: String, + pub error: Option, +} + +/// 代码里的 manifest,原生确认要用的部分 +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct Manifest { + pub name: String, + pub permissions: Vec, + pub requests: Vec, + pub scope: PluginScope, + pub on_error: OnError, + pub settings_schema: Vec, +} + +/// 一个设置项,`value` 是写在代码里的值 +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct SettingSpec { + pub key: String, + pub kind: SettingKind, + pub label: String, + pub value: SettingValue, +} + +// ------------------------------------------------------------- 端点 + +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; + } + )* + + /// 这里描述的每个端点:名字、方法、路径(测试核对用) + pub const ALL: &[(&str, Method, &str)] = &[$((stringify!($name), Method::$method, $path)),*]; + }; +} + +endpoints! { + // 网页能经过 `call` 走到的(`call.rs` 的 `provisional` 那一组) + Plugins: Get "/plugins" [] () => Value; + PluginInspect: Post "/plugins/inspect" [] PluginSource => Value; + PluginRewrite: Post "/plugins/rewrite" [] PluginRewriteRequest => PluginRewritten; + CreatePlugin: Post "/plugins" [] PluginCreate => ConfigWritten; + SavePlugin: Put "/plugins/{id}" ["id"] PluginSave => ConfigWritten; + // **网页走不到的三个**:只有 `plugins` 里的原生确认命令调它们(约定附录 4 §3) + CreatePluginConfirmed: Post "/plugins/confirmed" [] PluginCreate => ConfigWritten; + SavePluginConfirmed: Put "/plugins/{id}/confirmed" ["id"] PluginSave => ConfigWritten; + ApprovePluginFileConfirmed: Post "/plugins/{id}/approve/confirmed" ["id"] PluginApprove => ConfigWritten; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_paths_and_methods_are_the_contracts() { + assert_eq!( + (SavePlugin::METHOD, SavePlugin::PATH, SavePlugin::PARAMS), + (Method::Put, "/plugins/{id}", &["id"][..]) + ); + assert_eq!( + (SavePluginConfirmed::METHOD, SavePluginConfirmed::PATH), + (Method::Put, "/plugins/{id}/confirmed") + ); + assert_eq!( + (CreatePluginConfirmed::METHOD, CreatePluginConfirmed::PATH), + (Method::Post, "/plugins/confirmed") + ); + assert_eq!( + tw_api::fill(ApprovePluginFileConfirmed::PATH, &[("id", "add date")]), + "/plugins/add%20date/approve/confirmed" + ); + assert_eq!( + (PluginRewrite::METHOD, PluginRewrite::PATH), + (Method::Post, "/plugins/rewrite") + ); + } + + /// 新的 manifest 读得出来:设置项带 `value`,有 `on_error`;读不出 manifest 的插件 + /// 权限是空的 + #[test] + fn the_new_shapes_read() { + let i: Inspection = serde_json::from_value(serde_json::json!({ + "manifest": { + "name": "Answer in a chosen language", "description": null, + "permissions": ["system"], "requests": ["conversation"], + "scope": { "clients": [], "models": ["claude-*"], "upstreams": [] }, + "reply_mode": "block", "on_error": "skip", + "settings_schema": [{ "key": "language", "kind": "string", "label": "Answer language", "value": "English" }], + "hooks": { "request": true, "reply_text": false, "tool_call": false } + }, + "sha256": "aa", + "error": null + })) + .unwrap(); + let m = i.manifest.unwrap(); + assert_eq!(m.on_error, OnError::Skip); + assert_eq!( + m.settings_schema[0].value, + SettingValue::String("English".into()) + ); + let p: Installed = serde_json::from_value(serde_json::json!({ + "id": "legacy", "name": "legacy", "description": null, "enabled": false, + "on_error": "reject", "permissions": [], "requests": ["conversation"], + "scope": { "clients": [], "models": [], "upstreams": [] }, "reply_mode": "block", + "settings_schema": [], "sha256": "bb", "status": { "kind": "disabled" }, + "stats": { "calls": 0, "changed": 0, "rejected": 0, "errors": 0, "avg_cpu_us": 0, "last_error": null } + })) + .unwrap(); + assert!(p.permissions.is_empty()); + } +} diff --git a/src-tauri/src/plugins/words.rs b/src-tauri/src/plugins/words.rs index 3838f39f..9ebca26c 100644 --- a/src-tauri/src/plugins/words.rs +++ b/src-tauri/src/plugins/words.rs @@ -239,8 +239,8 @@ fn scope_line(scope: &PluginScope) -> String { fn check_line() -> &'static str { tr!( - "请核对 SHA-256 与审核窗口中显示的一致。", - "Check that the SHA-256 matches the one shown in the review window." + "请核对 SHA-256 与应用中显示的一致。", + "Check that the SHA-256 matches the one shown in the app." ) } @@ -285,8 +285,8 @@ pub fn install( } } -/// 换了代码之后插件改了名字:正文第一行说出新名字。标题里写的是**现在装着的那个名字** -/// —— 用户点开的是它,换上来的代码自称什么由它自己说 +/// 改了代码之后插件改了名字:正文第一行说出新名字。标题里写的是**现在装着的那个名字** +/// —— 用户点开的是它,新代码自称什么由它自己说 fn renamed(current: &str, next: &str) -> String { if current == next { return String::new(); @@ -297,45 +297,9 @@ fn renamed(current: &str, next: &str) -> String { ) } -/// 更换一个插件的代码。`name`:现在装着的那个的名字;`new_name`、`perms`、`kinds`:新代码 -/// 里的;`previous`:原来那一版申请的权限(读不出来是 `None`,就不标新增) -pub fn replace( - name: &str, - new_name: &str, - perms: &[Permission], - kinds: &[RequestKind], - previous: Option<&[Permission]>, - sha256: &str, -) -> Ask { - let (name, new_name) = (clean_name(name), clean_name(new_name)); - let renamed = renamed(&name, &new_name); - let can = abilities(perms, previous, kinds); - let detail = tr!( - format!( - "{renamed}新的代码可以:\n{can}\n\nSHA-256:{}\n\n{}", - sha_prefix(sha256), - check_line() - ), - format!( - "{renamed}The new code can:\n{can}\n\nSHA-256: {}\n\n{}", - 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 +/// 确认一个插件磁盘上变了的文件。`name`:现在装着的那个的名字;`new_name`、`perms`、`kinds`: +/// 改过的文件里的;`previous`:原来那一版申请的权限(读不出来是 `None`,就不标新增); +/// `from` / `to` 是确认过的和现在的 SHA-256 pub fn approve( name: &str, new_name: &str, @@ -375,22 +339,16 @@ pub fn approve( } } -/// 插件能做什么。**读不出来的**(core 那边没有它的 manifest,再读一遍也读不成)按改得了 -/// 工具调用对待 —— core 拦它的理由正是这个 -#[derive(Debug, Clone, Copy)] -pub enum Can<'a> { - Known { - perms: &'a [Permission], - kinds: &'a [RequestKind], - }, - Unknown, -} - -/// 一次改动里的一项。值都已经写成给人看的样子([`setting_value`]) +/// 一次保存里的一项。值都已经写成给人看的样子([`setting_value`]) #[derive(Debug, Clone, PartialEq)] pub enum Change { TurnOn, TurnOff, + /// 改了代码(不只是出错时、范围、设置的值):确认过的和新的 SHA-256 + Code { + from: String, + to: String, + }, OnError { from: OnError, to: OnError, @@ -431,6 +389,18 @@ fn change_line(c: &Change) -> String { match c { Change::TurnOn => tr!("启用此插件", "Turn on the plugin").to_string(), Change::TurnOff => tr!("停用此插件", "Turn off the plugin").to_string(), + Change::Code { from, to } => tr!( + format!( + "修改代码(SHA-256:{} → {})", + sha_prefix(from), + sha_prefix(to) + ), + format!( + "Change the code (SHA-256: {} → {})", + sha_prefix(from), + sha_prefix(to) + ) + ), Change::OnError { from, to } => tr!( format!("出错时:{} → {}", on_error_text(*from), on_error_text(*to)), format!( @@ -463,44 +433,46 @@ fn change_line(c: &Change) -> String { } } -/// 打开一个改得了工具调用的插件,或者改它的设置、范围(`UpdatePluginConfirmed`)。 +/// 保存一个改得了工具调用的插件:打开它,或者改了它的代码(`SavePluginConfirmed`)。 /// -/// 先写**这次改什么**,再写**它能做什么**:用户点开的是一次改动,要确认的是这一次。 -/// 只是打开它的,标题和按钮都说「启用」 -pub fn update(name: &str, can: Can<'_>, changes: &[Change]) -> Ask { - let name = clean_name(name); +/// 先写**这次改什么**,再写**它能做什么**:用户点开的是一次保存,要确认的是这一次。只是 +/// 打开它的,标题和按钮都说「启用」。`name`:现在装着的那个的名字;`new_name`、`perms`、 +/// `kinds`:要保存的代码里的;`previous`:确认过的那一版申请的权限(读不出来是 `None`: +/// 不标新增,按改得了工具调用对待) +pub fn save( + name: &str, + new_name: &str, + perms: &[Permission], + kinds: &[RequestKind], + previous: Option<&[Permission]>, + changes: &[Change], +) -> Ask { + let (name, new_name) = (clean_name(name), clean_name(new_name)); let only_on = changes == [Change::TurnOn]; + let code = changes.iter().any(|c| matches!(c, Change::Code { .. })); + let renamed = if code { + renamed(&name, &new_name) + } else { + String::new() + }; let mut lines: Vec = changes .iter() .map(|c| format!("• {}", change_line(c))) .collect(); if lines.is_empty() { - // core 认为有要点头的改动、这里比不出来:照实说是一次保存 - lines.push(format!( - "• {}", - tr!("保存此插件的设置", "Save the plugin's settings") - )); - } - let (can_text, danger) = match can { - Can::Known { perms, kinds } => ( - abilities(perms, None, kinds), - perms.contains(&Permission::ReplyToolCalls), - ), - Can::Unknown => ( - format!( - "• {}", - tr!( - "申请的权限无法读取,可能包括修改回答中的工具调用(高风险)", - "Its permissions cannot be read; they may include changing tool calls in replies (high risk)" - ) - ), - true, - ), + // core 认为要点头、这里比不出改了什么:照实说是一次保存 + lines.push(format!("• {}", tr!("保存此插件", "Save the plugin"))); + } + let changes_text = lines.join("\n"); + let can = abilities(perms, previous, kinds); + let check = if code { + format!("\n\n{}", check_line()) + } else { + String::new() }; - let changes = lines.join("\n"); let detail = tr!( - format!("本次改动:\n{changes}\n\n此插件可以:\n{can_text}"), - format!("Changes:\n{changes}\n\nThis plugin can:\n{can_text}") + format!("{renamed}本次改动:\n{changes_text}\n\n此插件可以:\n{can}{check}"), + format!("{renamed}Changes:\n{changes_text}\n\nThis plugin can:\n{can}{check}") ); Ask { title: tr!("确认插件改动", "Confirm Plugin Changes").to_string(), @@ -511,8 +483,8 @@ pub fn update(name: &str, can: Can<'_>, changes: &[Change]) -> Ask { ) } else { tr!( - format!("更改插件「{name}」"), - format!("Change Plugin “{name}”") + format!("保存插件「{name}」"), + format!("Save Plugin “{name}”") ) }, detail, @@ -530,7 +502,8 @@ pub fn update(name: &str, can: Can<'_>, changes: &[Change]) -> Ask { } else { tr!("选择「确定」保存改动。", "Choose OK to save the changes.").to_string() }, - danger, + danger: perms.contains(&Permission::ReplyToolCalls) + || previous.is_none_or(|p| p.contains(&Permission::ReplyToolCalls)), } } @@ -606,7 +579,7 @@ mod tests { let perms = [Permission::Messages]; let asks = [ install("p", &perms, &all, &PluginScope::default(), "aa"), - replace("p", "p", &perms, &all, Some(&perms), "aa"), + save("p", "p", &perms, &all, Some(&perms), &[Change::TurnOn]), approve("p", "p", &perms, &all, Some(&perms), "aa", "bb"), ]; for a in &asks { @@ -645,30 +618,38 @@ mod tests { assert!(!a.danger); } - /// 原来那一版的权限读不出来:不标新增(不知道哪一项是新的) + /// 原来那一版的权限读不出来:不标新增(不知道哪一项是新的),按改得了工具调用对待 #[test] fn nothing_is_marked_new_when_the_old_permissions_are_unknown() { - let a = replace( + let a = save( "p", "p", &[Permission::System], &[RequestKind::Conversation], None, - "aa", + &[Change::TurnOn], ); assert!(!a.detail.contains("新增"), "{}", a.detail); + assert!(a.danger); } #[test] fn new_code_under_another_name_says_so() { let kinds = [RequestKind::Conversation]; let sys = [Permission::System]; - let same = replace("附加日期", "附加日期", &sys, &kinds, Some(&sys), "aa"); - let other = replace("附加日期", "清空系统提示", &sys, &kinds, Some(&sys), "aa"); + let code = [Change::Code { + from: "aaaa".into(), + to: "bbbb".into(), + }]; + let same = save("附加日期", "附加日期", &sys, &kinds, Some(&sys), &code); + let other = save("附加日期", "清空系统提示", &sys, &kinds, Some(&sys), &code); // 标题是装着的那个名字,新名字写在正文里 assert!(other.message.contains("附加日期"), "{}", other.message); assert!(other.detail.contains("清空系统提示"), "{}", other.detail); assert!(!same.detail.contains("附加日期"), "{}", same.detail); + // 改了代码:写新旧两个 SHA-256,请人核对 + assert!(same.detail.contains("aaaa → bbbb"), "{}", same.detail); + assert!(same.detail.contains(check_line()), "{}", same.detail); } #[test] @@ -691,12 +672,12 @@ mod tests { fn turning_a_tool_call_plugin_on_says_what_changes_and_what_it_can_do() { let perms = [Permission::Messages, Permission::ReplyToolCalls]; let kinds = [RequestKind::Conversation]; - let a = update( + let a = save( + "WSL 路径转换", "WSL 路径转换", - Can::Known { - perms: &perms, - kinds: &kinds, - }, + &perms, + &kinds, + Some(&perms), &[Change::TurnOn], ); assert_eq!(a.message, "启用插件「WSL 路径转换」"); @@ -708,14 +689,21 @@ mod tests { .find(permission_text(Permission::ReplyToolCalls)) .unwrap(); assert!(change < can, "{}", a.detail); + // 没改代码:不用核对 SHA-256 + assert!(!a.detail.contains(check_line()), "{}", a.detail); } #[test] - fn a_settings_change_lists_each_change_and_the_values_stay_on_one_line() { - let a = update( + fn a_data_change_lists_each_change_and_the_values_stay_on_one_line() { + let perms = [Permission::Messages, Permission::ReplyToolCalls]; + let a = save( + "p", "p", - Can::Unknown, + &perms, + &[RequestKind::Conversation], + Some(&perms), &[ + Change::TurnOn, Change::Setting { label: "替换表\n伪造的一行".into(), from: setting_value(&tw_api::SettingValue::String("a=b\nc=d".into())), @@ -729,7 +717,6 @@ mod tests { ], ); assert_eq!(a.accept, "保存"); - // 读不出权限的按高风险对待 assert!(a.danger); assert!( a.detail @@ -744,6 +731,29 @@ mod tests { ); } + /// 新代码多要了工具调用:标「新增」,警示的样子 + #[test] + fn new_code_that_adds_tool_calls_is_marked() { + let a = save( + "p", + "p", + &[Permission::System, Permission::ReplyToolCalls], + &[RequestKind::Conversation], + Some(&[Permission::System]), + &[Change::Code { + from: "aa".into(), + to: "bb".into(), + }], + ); + let line = a + .detail + .lines() + .find(|l| l.contains(permission_text(Permission::ReplyToolCalls))) + .unwrap(); + assert!(line.contains("新增"), "{line}"); + assert!(a.danger); + } + #[test] fn setting_values_read_like_the_form() { use tw_api::SettingValue::*; diff --git a/src-tauri/src/wire.rs b/src-tauri/src/wire.rs index 15b4db17..5cd18e31 100644 --- a/src-tauri/src/wire.rs +++ b/src-tauri/src/wire.rs @@ -751,15 +751,15 @@ pub struct ImportProposal { // ---------------------------------------------------------- 插件:在系统的确认框里点头的几步 // -// 装插件、换代码、批准改过的文件、打开改得了工具调用的插件(或者改它的设置、范围), -// 这几个端点**不在网页的白名单里**(`call.rs`)。网页只能请 Rust 去做:Rust 自己把插件 -// 再读一遍,在系统的确认框里写明它是谁、能做什么、这次改什么,点了头才写配置 -// (`plugins` 模块)。 +// 改得了回答里工具调用的插件,装上它、打开它、改它的代码、批准它磁盘上改过的文件,要在系统 +// 的确认框里点头(约定附录 4 §3)。点头之后发的那几个端点(`…Confirmed`)**不在网页的白名单 +// 里**(`call.rs`):网页只能请 Rust 去做,Rust 自己把插件再读一遍,在系统的确认框里写明它是 +// 谁、能做什么、这次改什么,点了头才写配置(`plugins` 模块)。 -/// 装一个插件(`plugin_install`):代码,和审核窗口里选的。 +/// 装一个插件(`plugin_install_confirmed`):代码、ID 和开关,和 `CreatePlugin` 一样。 /// -/// **没有 manifest**:名字、权限、处理哪几种请求由 Rust 把代码交给 core 再读一遍,网页 -/// 说的不算。 +/// **没有 manifest**:名字、权限、处理哪几种请求、适用范围由 Rust 把代码交给 core 再读一遍, +/// 网页说的不算。出错时怎么办、适用范围、设置都写在代码里 #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct PluginInstallRequest { pub source: String, @@ -767,24 +767,23 @@ pub struct PluginInstallRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub id: Option, pub enabled: bool, - pub on_error: tw_api::OnError, - pub scope: tw_api::PluginScope, - pub settings: std::collections::BTreeMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub base_version: Option, } -/// 换一个插件的代码(`plugin_replace_source`) +/// 保存一个插件(`plugin_save_confirmed`):整份代码和开关,和 `SavePlugin` 一样。这次改 +/// 什么由 Rust 和 core 那边确认过的那一份比出来 #[derive(Debug, Clone, Serialize, Deserialize, TS)] -pub struct PluginReplaceRequest { +pub struct PluginSaveRequest { pub id: String, pub source: String, + pub enabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub base_version: Option, } -/// 批准一个插件改过的文件(`plugin_approve`)。**文件由 Rust 自己去取**:读的、给人看的、 -/// 交给 core 认的是同一个 SHA-256 +/// 批准一个插件磁盘上改过的文件(`plugin_approve_confirmed`)。**文件由 Rust 自己去取**: +/// 读的、给人看的、交给 core 认的是同一个 SHA-256 #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct PluginApproveRequest { pub id: String, @@ -792,14 +791,6 @@ pub struct PluginApproveRequest { pub base_version: Option, } -/// 一次要点头的改动(`plugin_update_confirmed`):和 `UpdatePlugin` 一样整份交,交上来的 -/// 就是保存之后的样子 -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -pub struct PluginUpdateRequest { - pub id: String, - pub update: tw_api::PluginUpdate, -} - /// 写成了(配置的新版本),或者在系统的确认框里点了取消 —— **取消不是失败**,什么都 /// 没写,界面照原样 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] diff --git a/src-tauri/tests/control_plane.rs b/src-tauri/tests/control_plane.rs index a3d726dc..93f9f131 100644 --- a/src-tauri/tests/control_plane.rs +++ b/src-tauri/tests/control_plane.rs @@ -443,15 +443,20 @@ async fn a_bedrock_upstream_is_saved_the_way_the_dialog_sends_it() { assert_eq!(m.code, "config.credential.aws_profile_and_keys", "{m:?}"); } -/// 改得了工具调用的插件,网页那条路(`UpdatePlugin`)打不开它:core 答 +/// 改得了工具调用的插件,网页那条路(`SavePlugin`)打不开它:core 答 /// `control.plugin.needs_confirmation`,界面据此请 Rust 弹系统的确认框。确认过的那一条 -/// (`UpdatePluginConfirmed`,桌面端点过头才发)打得开;停用照常走网页那条。 +/// (`SavePluginConfirmed`,桌面端点过头才发)打得开;只改数据(按表单改写的设置值)、 +/// 停用照常走网页那条(约定附录 4 §3)。 /// -/// core 第一次起来时装上的默认插件里就有这样一个(`wsl-paths`),停用着 +/// core 第一次起来时装上的默认插件里就有这样一个(`wsl-paths`),停用着。 +/// +/// PROVISIONAL:端点是 core v0.59.0 的(`plugins::wire`),钉点升上去之后去掉 `#[ignore]` #[tokio::test] -async fn a_tool_call_plugin_is_turned_on_only_through_the_confirmed_update() { +#[ignore = "needs core v0.59.0 (plugins contract addendum 4)"] +async fn a_tool_call_plugin_is_turned_on_only_through_the_confirmed_save() { use thinkwatch_lite_lib::control::Refused; - use tw_api::{Permission, PluginUpdate, ep}; + use thinkwatch_lite_lib::plugins::wire; + use tw_api::{SettingValue, ep}; let core = Core::start(); core.wait_ready().await; @@ -459,43 +464,91 @@ async fn a_tool_call_plugin_is_turned_on_only_through_the_confirmed_update() { // 默认插件在启动时装上:等它出现在单子上 let deadline = Instant::now() + Duration::from_secs(10); - let wsl = loop { - let all = c.call::(&[], &()).await.unwrap(); + loop { + let all = c.call::(&[], &()).await.unwrap(); + let all: Vec = serde_json::from_value(all).unwrap(); if let Some(p) = all.into_iter().find(|p| p.id == "wsl-paths") { - break p; + assert!(!p.enabled, "默认插件装上时停用着"); + break; } assert!(Instant::now() < deadline, "默认插件没有装上"); tokio::time::sleep(Duration::from_millis(100)).await; - }; - assert!(!wsl.enabled, "默认插件装上时停用着"); - assert!( - wsl.permissions.is_empty() || wsl.permissions.contains(&Permission::ReplyToolCalls), - "{:?}", - wsl.permissions - ); + } + let source = c + .call::(&["wsl-paths"], &()) + .await + .unwrap() + .approved; - let on = PluginUpdate { + let on = wire::PluginSave { + source: source.clone(), enabled: true, - on_error: wsl.on_error, - scope: wsl.scope.clone(), - settings: wsl.settings.clone(), base_version: None, }; let e = c - .call::(&["wsl-paths"], &on) + .call::(&["wsl-paths"], &on) .await .unwrap_err(); let m = &e.downcast_ref::().expect("被拒时要带码").0; assert_eq!(m.code, "control.plugin.needs_confirmation", "{m:?}"); - - c.call::(&["wsl-paths"], &on) + c.call::(&["wsl-paths"], &on) .await .unwrap(); - let off = PluginUpdate { - enabled: false, - ..on - }; - c.call::(&["wsl-paths"], &off) + + // 开着的时候只改一个设置的值:网页那条就够 + let read = c + .call::( + &[], + &tw_api::PluginSource { + source: source.clone(), + }, + ) .await .unwrap(); + let read: wire::Inspection = serde_json::from_value(read).unwrap(); + let m = read.manifest.expect("默认插件读得出 manifest"); + let rewritten = c + .call::( + &[], + &wire::PluginRewriteRequest { + source, + on_error: m.on_error, + scope: m.scope.clone(), + settings: m + .settings_schema + .iter() + .map(|s| { + let v = match s.value { + SettingValue::Bool(b) => SettingValue::Bool(!b), + ref other => other.clone(), + }; + (s.key.clone(), v) + }) + .collect(), + }, + ) + .await + .unwrap() + .source; + c.call::( + &["wsl-paths"], + &wire::PluginSave { + source: rewritten.clone(), + enabled: true, + base_version: None, + }, + ) + .await + .unwrap(); + // 停用照常走网页那条 + c.call::( + &["wsl-paths"], + &wire::PluginSave { + source: rewritten, + enabled: false, + base_version: None, + }, + ) + .await + .unwrap(); } diff --git a/src-tauri/tests/ts_bindings.rs b/src-tauri/tests/ts_bindings.rs index 28109780..0ae703c1 100644 --- a/src-tauri/tests/ts_bindings.rs +++ b/src-tauri/tests/ts_bindings.rs @@ -130,9 +130,8 @@ fn lite_typescript() -> String { c.root::(); c.root::(); c.root::(); - c.root::(); + c.root::(); c.root::(); - c.root::(); c.root::(); // **契约里已经有的名字从那边引用**(`Msg`,以及 core 还在发的同名同形的类型), diff --git a/src/control.ts b/src/control.ts index 6f41d9c8..e8b68e0d 100644 --- a/src/control.ts +++ b/src/control.ts @@ -13,15 +13,18 @@ * 失败时抛出的是一条 `Msg` 形状的对象,交给 `errorText`。 */ import { invoke } from "@tauri-apps/api/core"; -import type { ENDPOINTS, Endpoints } from "./generated/tw-api"; +// 临时:插件的文件即真相(约定附录 4)之后的端点,core v0.59.0 发版后改回从 `./generated/tw-api` 取 +// (`EndpointTable` 换回 `typeof ENDPOINTS`),步骤见 `./plugins/api.provisional.ts` +import type { EndpointTable, Endpoints } from "./plugins/api.provisional"; /** * 界面能直接调的端点。**和 `src-tauri/src/call.rs` 的 `ALLOWED` 是同一份** * (那边的测试核对):不在这里的端点,界面够不着。 * - * 插件的装、换代码、批准改过的文件、确认过的改动(`CreatePlugin`、`ReplacePluginSource`、 - * `ApprovePluginFile`、`UpdatePluginConfirmed`)**有意不在这里**:只能请 Rust 弹系统的确认框 - * (`src/plugins/native.ts`)。 + * 插件要在系统的确认框里点头的三步(`CreatePluginConfirmed`、`SavePluginConfirmed`、 + * `ApprovePluginFileConfirmed`)**有意不在这里**:只能请 Rust 弹系统的确认框 + * (`src/plugins/write.ts`)。不必点头的装、存、批准(`CreatePlugin`、`SavePlugin`、 + * `ApprovePluginFile`)在这里,core 说要点头时答 403。 */ export const WEBVIEW_ENDPOINTS = [ "Interfaces", @@ -96,7 +99,10 @@ export const WEBVIEW_ENDPOINTS = [ "ZaiLoginStatus", "Plugins", "PluginInspect", - "UpdatePlugin", + "PluginRewrite", + "CreatePlugin", + "SavePlugin", + "ApprovePluginFile", "PluginSourceDiff", "DeletePlugin", "ReorderPlugins", @@ -110,7 +116,7 @@ export type WebviewEndpoint = (typeof WEBVIEW_ENDPOINTS)[number]; type Values = T extends readonly [unknown, ...infer Rest] ? [string | number, ...Values] : []; /** 这个端点的路径参数,按模板里的顺序 */ -type Params = Values<(typeof ENDPOINTS)[N]["params"]>; +type Params = Values; export function call( endpoint: N, diff --git a/src/generated/lite-api.ts b/src/generated/lite-api.ts index 397174cb..0399d06f 100644 --- a/src/generated/lite-api.ts +++ b/src/generated/lite-api.ts @@ -1,6 +1,6 @@ // Generated from src-tauri/src/wire.rs (`tests/ts_bindings.rs`). Do not edit by hand. -import type { CostBucketGroup, CostGroup, Msg, OnError, PluginScope, PluginUpdate, Protocol, SettingValue } from "./tw-api"; +import type { CostBucketGroup, CostGroup, Msg, Protocol } from "./tw-api"; export type AdoptResponse = { real: string, backup: string, created: boolean, /** @@ -470,33 +470,28 @@ also?: Array, bedrock?: BedrockDraft | null, }; /** - * 批准一个插件改过的文件(`plugin_approve`)。**文件由 Rust 自己去取**:读的、给人看的、 - * 交给 core 认的是同一个 SHA-256 + * 批准一个插件磁盘上改过的文件(`plugin_approve_confirmed`)。**文件由 Rust 自己去取**: + * 读的、给人看的、交给 core 认的是同一个 SHA-256 */ export type PluginApproveRequest = { id: string, base_version?: string | null, }; /** - * 装一个插件(`plugin_install`):代码,和审核窗口里选的。 + * 装一个插件(`plugin_install_confirmed`):代码、ID 和开关,和 `CreatePlugin` 一样。 * - * **没有 manifest**:名字、权限、处理哪几种请求由 Rust 把代码交给 core 再读一遍,网页 - * 说的不算。 + * **没有 manifest**:名字、权限、处理哪几种请求、适用范围由 Rust 把代码交给 core 再读一遍, + * 网页说的不算。出错时怎么办、适用范围、设置都写在代码里 */ export type PluginInstallRequest = { source: string, /** * 审核窗口里填的 ID。不给由 core 按名字起 */ -id?: string | null, enabled: boolean, on_error: OnError, scope: PluginScope, settings: { [key in string]: SettingValue }, base_version?: string | null, }; +id?: string | null, enabled: boolean, base_version?: string | null, }; /** - * 换一个插件的代码(`plugin_replace_source`) + * 保存一个插件(`plugin_save_confirmed`):整份代码和开关,和 `SavePlugin` 一样。这次改 + * 什么由 Rust 和 core 那边确认过的那一份比出来 */ -export type PluginReplaceRequest = { id: string, source: string, base_version?: string | null, }; - -/** - * 一次要点头的改动(`plugin_update_confirmed`):和 `UpdatePlugin` 一样整份交,交上来的 - * 就是保存之后的样子 - */ -export type PluginUpdateRequest = { id: string, update: PluginUpdate, }; +export type PluginSaveRequest = { id: string, source: string, enabled: boolean, base_version?: string | null, }; /** * 写成了(配置的新版本),或者在系统的确认框里点了取消 —— **取消不是失败**,什么都 diff --git a/src/labels.ts b/src/labels.ts index 69d666e3..ad588271 100644 --- a/src/labels.ts +++ b/src/labels.ts @@ -265,3 +265,6 @@ const APPS: Record = { export function appLabel(hint: string): string { return APPS[hint] ?? hint; } + +/** 认得出名字的那几个应用(插件适用范围里给的建议) */ +export const KNOWN_APPS: readonly string[] = Object.keys(APPS); diff --git a/src/plugins/ChangedDialog.i18n.tsx b/src/plugins/ChangedDialog.i18n.tsx index 3b91f7ad..3f850b7b 100644 --- a/src/plugins/ChangedDialog.i18n.tsx +++ b/src/plugins/ChangedDialog.i18n.tsx @@ -13,10 +13,10 @@ export const changedDialogText = messages( cannotLoad: "更改后的代码无法加载", at: (where: string) => `位置:${where}`, missing: "插件文件已不存在或无法读取。", - missingHint: "可以更换为新的代码,或删除此插件。", + missingHint: "可以编辑代码后保存,或删除此插件。", loadFailed: "文件内容读取失败", approve: "确认更改", - replace: "更换代码…", + edit: "编辑代码…", cancelled: "已取消,配置未改动。", }, { @@ -30,10 +30,10 @@ export const changedDialogText = messages( 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.", + missingHint: "Edit and save the code, or delete the plugin.", loadFailed: "The file could not be read", approve: "Approve changes", - replace: "Replace code…", + edit: "Edit code…", cancelled: "Cancelled. The configuration was not changed.", }, ); diff --git a/src/plugins/ChangedDialog.tsx b/src/plugins/ChangedDialog.tsx index a849a0fc..f6ad972d 100644 --- a/src/plugins/ChangedDialog.tsx +++ b/src/plugins/ChangedDialog.tsx @@ -16,32 +16,33 @@ import type { PluginView } from "@/types"; import { changedDialogText } from "./ChangedDialog.i18n"; import { pluginName } from "./defaults"; import { shaPrefix } from "./model"; -import { approvePluginFile } from "./native"; -import { CodeBox, PermissionList, PluginText, RequestKinds, SourceDiff } from "./parts"; +import { CodeBox, codeErrorOf, PermissionList, PluginText, RequestKinds, SourceDiff } from "./parts"; import { pluginPartsText } from "./parts.i18n"; -import type { NativeWrite } from "./SourceDialog"; +import { approvePlugin, type NativeWrite } from "./write"; /** * 插件文件在确认之后被改过(状态「文件已更改」):看清改了什么,再确认。 * * 给人看的三样:**和确认过的那一份逐行对比**(也可以看全文);**这一版申请的权限**,比原来 - * 多要的标成「新增」;两个 SHA-256。确认由 Rust 去做(`plugin_approve`):它自己再取一次 - * 文件、再读一遍,在系统原生对话框里写明插件名、权限和新的 SHA-256,点了才算数(I12)。 + * 多要的标成「新增」;两个 SHA-256。改得了回答里工具调用的插件(原来的或者改过的文件里), + * 确认要在系统的确认框里点头(Rust 自己再取一次文件、再读一遍,写明插件名、权限和新的 + * SHA-256);别的插件按一下就确认了(`write.ts`)。 * - * 文件没了、或者改坏了读不了的,确认不了:给「更换代码」。 + * 文件没了、或者改坏了读不了的,确认不了:给「编辑代码」,在编辑器里改好再保存。 */ export function ChangedDialog({ plugin, native, onClose, onApproved, - onReplace, + onEdit, }: { plugin: PluginView; native: NativeWrite; onClose: () => void; onApproved: () => void; - onReplace: () => void; + /** 打开编辑器改代码:`current` 是改磁盘上那一份(读不了的),`approved` 是从确认过的那一份起头(文件没了) */ + onEdit: (from: "approved" | "current") => void; }) { const t = useText(changedDialogText); const pt = useText(pluginPartsText); @@ -65,7 +66,9 @@ export function ChangedDialog({ setError(null); setCancelled(false); try { - const r = await native((base) => approvePluginFile({ id: plugin.id, base_version: base })); + const sha = diff.data?.current_sha256; + if (!sha) return; + const r = await native((base) => approvePlugin(plugin, sha, manifest, base)); if (r === "done") onApproved(); else setCancelled(true); } catch (e) { @@ -151,7 +154,7 @@ export function ChangedDialog({ {view === "changes" ? ( ) : ( - + )} @@ -163,8 +166,13 @@ export function ChangedDialog({ {(current == null || loadError != null) && diff.data !== undefined && ( - )} + + + + )} + + + !o && setAsking(null)}> + + + {t.discardTitle} + {t.discardDescription} + + + {t.keepEditing} + { + const then = asking; + setAsking(null); + then?.(); + }} + > + {t.discard} + + + + + + ); +} + +/** 标题和下面一行:ID、SHA-256、权限、处理的请求种类,和贴着底边的两个标签 */ +function Header({ title, meta, tabs }: { title: ReactNode; meta?: ReactNode; tabs?: ReactNode }) { + return ( + + {title} + +
{meta}
+
+ {/* 左移到标签的字和标题对齐:列表有 3px 内边距、每个标签左右 6px */} + {tabs &&
{tabs}
} +
+ ); +} + +/** 一段代码的 SHA-256(UTF-8),十六进制。core 读同一份代码得到的是同一串:系统的确认框里写的就是它 */ +function useSha256(text: string): string | null { + const [sha, setSha] = useState<{ text: string; hex: string } | null>(null); + useEffect(() => { + let live = true; + void crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)).then((buf) => { + if (!live) return; + setSha({ text, hex: [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("") }); + }); + return () => { + live = false; + }; + }, [text]); + return sha?.text === text ? sha.hex : null; +} + +function Editing({ + plugin, + loaded, + initialTab, + from, + ov, + native, + title, + onState, + onCancel, + onSaved, + onReview, +}: { + plugin: PluginView; + loaded: Loaded; + initialTab: EditorTab; + from: "approved" | "current"; + ov: Overview; + native: NativeWrite; + title: ReactNode; + onState: (s: { dirty: boolean; busy: boolean }) => void; + onCancel: () => void; + onSaved: () => void; + onReview: () => void; +}) { + const t = useText(editorText); + const lt = useText(pluginLabelsText); + const ft = useText(pluginFieldsText); + const common = useText(commonText); + const ed = useEditing(plugin.id, loaded.start); + const [tab, setTab] = useState( + // 代码读不了、也没有可以填的表单:直接看代码 + initialTab === "settings" && !loaded.start.inspection.manifest ? "code" : initialTab, + ); + const [enabled, setEnabled] = useState(plugin.enabled); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [cancelled, setCancelled] = useState(false); + const suggestions = useScopeSuggestions(ov); + + const dirty = ed.source !== loaded.start.source || enabled !== plugin.enabled; + const problem = ed.form ? valuesOf(ed.form).problem : null; + const problemText = !problem ? null : problem.kind === "scope" ? ft.needOne(lt.scopeParts[problem.part]) : ft.numberBad(problem.label); + useEffect(() => onState({ dirty, busy: saving }), [dirty, saving, onState]); + + const switchTab = (next: EditorTab) => { + // 切过去之前把这一页的改动交出去:另一页看到的是一致的样子 + void ed.flush(); + setTab(next); + }; + + async function save() { + setSaving(true); + setError(null); + setCancelled(false); + try { + await ed.flush(); + // 刚打的字可能刚读完:按「现在」的判断,不按点下保存那一刻渲染的样子 + const { source, manifest: next, error: broken } = ed.now(); + if (broken || !next) return; + const old = loaded.approvedManifest; + const asks = saveAsks({ + old: old?.permissions ?? (plugin.permissions.length > 0 ? plugin.permissions : null), + next: next.permissions, + turningOn: enabled && !plugin.enabled, + // 一定改了代码:manifest 里数据以外的东西变了,或者确认过的那一份读不了而代码不一样。 + // 别的(manifest 以外改了没有)交给 core 判断,它说要点头再请 Rust + codeChanged: source !== loaded.approved && (old == null || shapeChanged(old, next)), + }); + const r = await native((base) => savePlugin({ id: plugin.id, source, enabled, base_version: base }, asks)); + if (r === "cancelled") setCancelled(true); + else onSaved(); + } catch (e) { + setError(errorText(e)); + } finally { + setSaving(false); + } + } + + const m = ed.manifest; + const sha = useSha256(ed.source); + const meta = ( + <> + {t.id(plugin.id)} + {sha && {t.sha(shaPrefix(sha))}} + {m && ( + + )} + {m && } + + ); + + return ( + switchTab(v as EditorTab)} className="flex min-h-0 flex-1 flex-col gap-0"> +
+ {t.tabSettings} + + {t.tabCode} + {ed.error && } + + + } + /> + +
+ + switchTab("code")} + onReview={onReview} + /> + + + ed.editCode(s)} + onImport={(s) => ed.editCode(s, true)} + error={ed.error} + checking={ed.checking} + /> + +
+ +
+ + {cancelled &&

{t.cancelled}

} +
+ + + {problemText ? ( + {problemText} + ) : ( + dirty && {t.unsaved} + )} + + + + + ); +} + +/** 两页叠在同一块里,没选中的藏起来(不卸载:代码的撤销记录、滚动位置都留着) */ +const PANE = "absolute inset-0 data-[state=inactive]:hidden"; + +function SettingsPane({ + plugin, + form, + onForm, + codeError, + diskChanged, + enabled, + onEnabled, + suggestions, + onViewCode, + onReview, +}: { + plugin: PluginView; + form: Form | null; + onForm: (f: Form) => void; + codeError: boolean; + diskChanged: boolean; + enabled: boolean; + onEnabled: (on: boolean) => void; + suggestions: ReturnType; + onViewCode: () => void; + onReview: () => void; +}) { + const t = useText(editorText); + const lt = useText(pluginLabelsText); + const ft = useText(pluginFieldsText); + // 横幅钉在上面、不跟着表单滚走:表单滚到底时代码出了错,也一眼看得见 + const banners = (diskChanged || codeError) && ( +
+ {diskChanged && ( + + {t.reviewChanges} + + } + > + {t.diskChangedHint} + + )} + {codeError && ( + + {t.viewCode} + + } + > + {form ? t.codeErrorHint : t.codeErrorNoForm} + + )} +
+ ); + return ( + <> + {banners} +
+
+
+ +

{t.enabledHint}

+
+ +
+ + {form && ( + // 代码读不了时停在上一次读得了的样子,不能改:改了也写不回读不了的代码 +
+ onForm({ ...form, onError })} /> +
+

{lt.scope}

+ onForm({ ...form, scope })} suggestions={suggestions} /> +
+ {form.schema.length > 0 && ( +
+

{ft.settings}

+ onForm({ ...form, settings })} + /> +
+ )} +
+ )} +
+ + ); +} + +function CodePane({ + source, + onChange, + onImport, + error, + checking, +}: { + source: string; + onChange: (s: string) => void; + onImport: (s: string) => void; + error: ReturnType["error"]; + checking: boolean; +}) { + const t = useText(editorText); + const pt = useText(pluginPartsText); + const picker = useRef(null); + const [importError, setImportError] = useState(null); + const [jump, setJump] = useState<{ line: number; column: number | null; at: number } | null>(null); + const codeError = useMemo(() => codeErrorOf(error), [error]); + const lines = useMemo(() => source.split("\n").length, [source]); + // 读一遍通常只要几毫秒:慢了才说「正在检查」,免得每停一下就闪一下 + const [slow, setSlow] = useState(false); + useEffect(() => { + if (!checking) { + setSlow(false); + return; + } + const h = setTimeout(() => setSlow(true), 300); + return () => clearTimeout(h); + }, [checking]); + + async function choose(f: File | undefined) { + setImportError(null); + if (!f) return; + if (f.size > MAX_SOURCE) { + setImportError(t.tooLarge(size(f.size))); + return; + } + try { + onImport(await f.text()); + } catch { + setImportError(t.readFailed); + } + } + + return ( + <> +
+ + {error ? ( + + + {t.cannotLoad} + {error.line != null && ( + + )} + + ) : ( + + {pt.lines(lines)} · {size(new Blob([source]).size)} + + )} + {slow && {t.checking}} + + + { + void choose(e.target.files?.[0]); + // 同一个文件再选一次也要触发 + e.target.value = ""; + }} + /> +
+ {importError &&

{importError}

} + {/* 报错没有行号(清单不对之类):写在代码上面;有行号的写在那一行下面 */} + {error && error.line == null && ( + + + + + + )} +
+ }> + + +
+ + ); +} diff --git a/src/plugins/PluginsPage.i18n.tsx b/src/plugins/PluginsPage.i18n.tsx index 65575389..4e6659d3 100644 --- a/src/plugins/PluginsPage.i18n.tsx +++ b/src/plugins/PluginsPage.i18n.tsx @@ -26,14 +26,14 @@ export const pluginsPageText = messages( logs: "日志", remove: "删除", review: "审核更改", - replace: "更换代码", + editCode: "编辑代码", // 同一份操作在右键菜单里:打开对话框的带「…」 menu: { settings: "设置…", + code: "代码…", trial: "试运行…", logs: "日志…", review: "审核更改…", - replace: "更换代码…", remove: "删除…", }, actionsFor: (name: string) => `「${name}」的操作`, @@ -80,13 +80,13 @@ export const pluginsPageText = messages( logs: "Logs", remove: "Delete", review: "Review changes", - replace: "Replace code", + editCode: "Edit code", menu: { settings: "Settings…", + code: "Code…", trial: "Trial run…", logs: "Logs…", review: "Review changes…", - replace: "Replace code…", remove: "Delete…", }, actionsFor: (name: string) => `Actions for “${name}”`, diff --git a/src/plugins/PluginsPage.tsx b/src/plugins/PluginsPage.tsx index bc5767cd..ab922720 100644 --- a/src/plugins/PluginsPage.tsx +++ b/src/plugins/PluginsPage.tsx @@ -20,24 +20,24 @@ import { useText } from "@/i18n"; import { coreText } from "@/i18n/core.i18n"; import { useNav, useNavParams } from "@/nav"; import { useConfigVersion } from "@/keys/data"; -import type { Overview, PluginUpdate, PluginView, PluginWrite } from "@/types"; +import type { Overview, PluginView, PluginWrite } from "@/types"; import { ChangedDialog } from "./ChangedDialog"; import { pluginDescription, pluginName } from "./defaults"; +import { PluginEditor, type EditorTab } from "./Editor"; import { DeleteDialog, ReorderDialog } from "./ListDialogs"; import { LogsDialog } from "./LogsDialog"; -import { guarded, manifestUnknown } from "./model"; -import { savePlugin } from "./native"; +import { holdsToolCalls, manifestUnknown } from "./model"; import { PermissionChips, PluginText, RequestKinds, ScopeSummary, StatsCell, StatusOf } from "./parts"; import { pluginsPageText } from "./PluginsPage.i18n"; -import { SettingsDialog } from "./SettingsDialog"; -import { SourceDialog, type NativeWrite } from "./SourceDialog"; +import { SourceDialog } from "./SourceDialog"; import { TrialDialog } from "./TrialDialog"; +import { setEnabled, type NativeWrite } from "./write"; type DialogState = | null | { kind: "add" } - | { kind: "settings"; id: string } - | { kind: "replace"; id: string } + /** 编辑器:哪一页先开着;`current` 是从磁盘上那一份起头(审核更改时文件读不了,去修它) */ + | { kind: "edit"; id: string; tab: EditorTab; from?: "approved" | "current" } | { kind: "review"; id: string } | { kind: "trial"; id: string } | { kind: "logs"; id: string } @@ -45,11 +45,6 @@ type DialogState = /** 删的那一个连同它的样子一起记下:删成功之后它从列表里拿掉了,对话框还要放完收起动画 */ | { kind: "delete"; target: PluginView }; -/** 插件写回去时的样子:照原样,改其中几项 */ -export function updateOf(p: PluginView): PluginUpdate { - return { enabled: p.enabled, on_error: p.on_error, scope: p.scope, settings: p.settings }; -} - /** * 插件页。 * @@ -61,10 +56,11 @@ export function updateOf(p: PluginView): PluginUpdate { * * - **插件写的字一律按纯文本画**(名字、说明、设置项的标签、日志、报错),见 `PluginText`。 * core 自带的默认插件按界面语言说(`defaults.ts`)。 - * - **安装、更换代码、确认文件变更要在系统原生对话框里点头**:这三步的端点不在网页的 - * 白名单里,只能请 Rust 去做(`plugin_install` 等)。网页里的「安装」只是发起。**改得了 - * 工具调用的插件**(或者读不出权限的),打开它、改设置、改范围也一样(`savePlugin`)。 - * - 配置的改动都进对话框;启用、停用可以撤销,一按就写;删除要确认。 + * - **插件的 JS 文件是唯一的真相**:出错时怎么办、适用范围、设置都写在代码里。点一个插件 + * 打开它的编辑器(`Editor`),「设置」和「代码」两页、一个保存。 + * - **只有改得了回答里工具调用的插件**,装上它、打开它、改它的代码、批准它改过的文件,要在 + * 系统原生对话框里点头(`write.ts`)。别的写入不问;删除在应用里确认一次。 + * - 启用、停用可以撤销,一按就写。 * - **不在运行、又会拒绝请求的插件挂一条横幅**:文件变了或者加载不了的插件不运行,出错时 * 选了「拒绝」的,适用范围内的请求全部被拒 —— 这件事要一直看得见,直到处理掉。 */ @@ -148,26 +144,26 @@ export default function PluginsPage({ ov, onChanged }: { ov: Overview; onChanged } /** - * 启用、停用。**可以撤销**:先拨过去,写完给「撤销」。 + * 启用、停用。**可以撤销**:先拨过去,写完给「撤销」。开关和确认过的代码一起交(`setEnabled`)。 * * 改得了工具调用的插件(或者读不出权限的),打开它要在系统的确认框里点头:**开关不先拨 * 过去**,转着圈等那个框,点了头才是开。点了取消,开关原样,什么都不说 */ function toggle(p: PluginView, enabled: boolean) { - const send = (on: boolean) => + const send = (on: boolean, from: boolean) => tracked(p.id, async () => { - const r = await native((base) => savePlugin(p, { ...updateOf(p), enabled: on, base_version: base })); + const r = await native((base) => setEnabled(p, on, from, base)); return r === "cancelled" ? DECLINED : r; }); const name = ; - const asks = enabled && guarded(p); + const asks = enabled && holdsToolCalls(p.permissions); void undoable({ message: enabled ? t.turnedOn(name) : t.turnedOff(name), apply: asks ? undefined : () => plugins.mutate((ps) => (ps ?? []).map((x) => (x.id === p.id ? { ...x, enabled } : x))), - do: () => send(enabled), - undo: () => send(!enabled), + do: () => send(enabled, p.enabled), + undo: () => send(!enabled, enabled), }); } @@ -214,13 +210,25 @@ export default function PluginsPage({ ov, onChanged }: { ov: Overview; onChanged > {(data) => (
- setDialog({ kind: "review", id })} onReplace={(id) => setDialog({ kind: "replace", id })} /> + setDialog({ kind: "review", id })} + onEdit={(id) => setDialog({ kind: "edit", id, tab: "code" })} + /> (pending[id] ?? 0) > 0} onToggle={toggle} - onOpen={(kind, p) => setDialog(kind === "delete" ? { kind, target: p } : { kind, id: p.id })} + onOpen={(kind, p) => + setDialog( + kind === "delete" + ? { kind, target: p } + : kind === "settings" || kind === "code" + ? { kind: "edit", id: p.id, tab: kind } + : { kind, id: p.id }, + ) + } />
)} @@ -228,7 +236,6 @@ export default function PluginsPage({ ov, onChanged }: { ov: Overview; onChanged {dialog?.kind === "add" && ( setDialog(null)} @@ -238,25 +245,18 @@ export default function PluginsPage({ ov, onChanged }: { ov: Overview; onChanged }} /> )} - {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 })} + onSaved={() => setDialog(null)} + onReview={() => setDialog({ kind: "review", id: dialog.id })} /> )} {dialog?.kind === "review" && at(dialog) && ( @@ -265,7 +265,7 @@ export default function PluginsPage({ ov, onChanged }: { ov: Overview; onChanged native={native} onClose={() => setDialog(null)} onApproved={() => setDialog(null)} - onReplace={() => setDialog({ kind: "replace", id: dialog.id })} + onEdit={(from) => setDialog({ kind: "edit", id: dialog.id, tab: "code", from })} /> )} {dialog?.kind === "trial" && at(dialog) && setDialog(null)} />} @@ -340,11 +340,11 @@ function Summary({ list, loading }: { list: PluginView[] | undefined; loading: b function Stopped({ list, onReview, - onReplace, + onEdit, }: { list: PluginView[]; onReview: (id: string) => void; - onReplace: (id: string) => void; + onEdit: (id: string) => void; }) { const t = useText(pluginsPageText); const stopped = list.filter((p) => p.enabled && (p.status.kind === "changed" || p.status.kind === "error")); @@ -378,8 +378,8 @@ function Stopped({ tone={reject ? "error" : "info"} title={t.failedTitle(name)} actions={ - } > @@ -396,7 +396,7 @@ function Stopped({ ); } -type RowAction = "settings" | "trial" | "logs" | "review" | "replace" | "delete"; +type RowAction = "settings" | "code" | "trial" | "logs" | "review" | "delete"; /** * 插件列表,**按运行的顺序**:前一个改过的内容交给后一个,所以行首写着它是第几个。 @@ -427,9 +427,9 @@ function PluginList({ 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.code, onSelect: () => onOpen("code", 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 }, ]; @@ -450,7 +450,8 @@ function PluginList({ highlight === p.id && "bg-muted/60", rowMotion(presence), )} - {...openable(() => onOpen("settings", p))} + // 加载失败的:点开先看代码(设置读不出来) + {...openable(() => onOpen(p.status.kind === "error" ? "code" : "settings", p))} > {i + 1} @@ -503,8 +504,8 @@ function PluginList({ )} {p.status.kind === "error" && ( - )} onOpen("settings", p)}>{t.settings} diff --git a/src/plugins/SettingsDialog.i18n.ts b/src/plugins/SettingsDialog.i18n.ts deleted file mode 100644 index fd551120..00000000 --- a/src/plugins/SettingsDialog.i18n.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { messages } from "@/i18n"; - -export const settingsDialogText = messages( - { - title: "插件设置", - enabled: "启用", - enabledHint: "停用后此插件不运行,适用范围内的请求照常转发。", - id: (id: string) => `ID ${id}`, - sha: (prefix: string) => `SHA-256 ${prefix}`, - replace: "更换代码…", - /** 系统的确认框里点了「取消」:什么都没写 */ - cancelled: "已取消,配置未改动。", - }, - { - 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…", - cancelled: "Cancelled. The configuration was not changed.", - }, -); diff --git a/src/plugins/SettingsDialog.tsx b/src/plugins/SettingsDialog.tsx deleted file mode 100644 index c71fc3d6..00000000 --- a/src/plugins/SettingsDialog.tsx +++ /dev/null @@ -1,162 +0,0 @@ -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 type { OnError, PluginView } from "@/types"; -import { localSchema, pluginName } from "./defaults"; -import { draftOf, scopeOf, scopeProblem, ScopeFields, settingsDraftOf, settingsOf, SettingsFields } from "./fields"; -import { pluginFieldsText } from "./fields.i18n"; -import { pluginLabelsText } from "./labels.i18n"; -import { manifestUnknown, shaPrefix } from "./model"; -import { savePlugin } from "./native"; -import { PermissionChips, PluginText, RequestKinds } from "./parts"; -import { settingsDialogText } from "./SettingsDialog.i18n"; -import { OnErrorField } from "./SourceDialog"; - -/** - * 一个插件的设置:启用、出错时、适用范围、插件自己的设置项。**改完点保存才写** - * (一次写成一个配置版本)。 - * - * 保存带的是**打开时的版本号**:对话框开着的时候别处改了配置,core 会说「版本不一致」, - * 而不是让这份旧表单把别人的改动盖掉(和密钥对话框同一条)。 - * - * **改得了工具调用的插件**(或者读不出权限的),打开它、改设置、改范围要在系统的确认框里 - * 点头(`savePlugin`)。在那里点了取消,表单原样留着、什么都没写。 - * - * 代码不在这里改:「更换代码」走另一个对话框,最后要在系统对话框里确认。 - */ -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 [cancelled, setCancelled] = useState(false); - - const unknown = manifestUnknown(plugin); - // 默认插件的设置项标签按界面语言说;键、类型、默认值不变 - const schema = localSchema(plugin.id, plugin.name, plugin.settings_schema); - const check = settingsOf(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(schema, settingsDraftOf(schema, plugin.settings)).values); - const missing = scopeProblem(scope); - const problem = missing ? ft.needOne(lt.scopeParts[missing]) : check.bad[0] ? ft.numberBad(check.bad[0]) : null; - - async function save() { - setSaving(true); - setError(null); - setCancelled(false); - try { - const r = await savePlugin(plugin, { - enabled, - on_error: onError, - scope: nextScope, - settings: check.values, - base_version: base, - }); - if (r.kind === "cancelled") { - // 系统的确认框里点了取消:表单原样留着 - setCancelled(true); - setSaving(false); - return; - } - onSaved(r.version); - } catch (e) { - setError(errorText(e)); - setSaving(false); - } - } - - return ( - !o && !saving && onClose()}> - - - {t.title} - -
- - {unknown ? {plugin.id} : } - - - {t.id(plugin.id)} - {t.sha(shaPrefix(plugin.sha256))} - {!unknown && } - {!unknown && } - -
-
-
- -
-
-
- -

{t.enabledHint}

-
- -
- - - -
-

{lt.scope}

- -
- - {schema.length > 0 && ( -
-

{ft.settings}

- -
- )} -
- - - {cancelled &&

{t.cancelled}

} - - - - {problem && {problem}} - - - -
-
- ); -} diff --git a/src/plugins/SourceDialog.i18n.tsx b/src/plugins/SourceDialog.i18n.ts similarity index 84% rename from src/plugins/SourceDialog.i18n.tsx rename to src/plugins/SourceDialog.i18n.ts index c78cd54d..a131381d 100644 --- a/src/plugins/SourceDialog.i18n.tsx +++ b/src/plugins/SourceDialog.i18n.ts @@ -1,10 +1,8 @@ -import type { ReactNode } from "react"; import { messages } from "@/i18n"; export const sourceDialogText = messages( { addTitle: "添加插件", - replaceTitle: (name: ReactNode) => <>更换「{name}」的代码, sourceDescription: "选择本地的 .js 文件,或粘贴代码。", fromFile: "选择文件", fromPaste: "粘贴代码", @@ -22,15 +20,11 @@ export const sourceDialogText = messages( at: (where: string) => `位置:${where}`, sha: "SHA-256", code: "代码", - fullCode: "完整代码", - compare: "与当前代码对比", - options: "安装选项", id: "插件 ID", idHint: "小写字母、数字和连字符,最多 40 个。", idBad: "只能使用小写字母、数字和连字符,最多 40 个。", idTaken: "已有插件使用此 ID。", install: "安装", - replace: "更换代码", /** 原生对话框里点了「取消」:什么都没写 */ cancelled: "已取消,配置未改动。", /** 只有回答钩子的插件,没有申请改请求的权限 */ @@ -38,7 +32,6 @@ export const sourceDialogText = messages( }, { 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", @@ -56,15 +49,11 @@ export const sourceDialogText = messages( 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 index 6742479d..70f6e3d2 100644 --- a/src/plugins/SourceDialog.tsx +++ b/src/plugins/SourceDialog.tsx @@ -7,7 +7,6 @@ import { Input } from "@/ui/input"; import { Segmented } from "@/ui/segmented"; import { Textarea } from "@/ui/textarea"; import { call } from "@/control"; -import { useResource } from "@/lib/resource"; import { cn } from "@/lib/utils"; import { useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; @@ -15,59 +14,40 @@ import { coreText, errorText } from "@/i18n/core.i18n"; import { size } from "@/format"; import { focusSelf } from "@/keys/parts"; import { DialogError, FormItem } from "@/upstreams/parts"; -import type { OnError, PluginInspection, PluginView, PluginWrite } from "@/types"; -import { localSchema, pluginDescription, pluginName } from "./defaults"; -import { draftOf, scopeOf, scopeProblem, ScopeFields, settingsDraftOf, settingsOf, SettingsFields, type ScopeDraft, type SettingsDraft } from "./fields"; -import { pluginFieldsText } from "./fields.i18n"; +import type { ManifestView, PluginInspection } from "@/types"; import { pluginLabelsText } from "./labels.i18n"; -import { ID_RE, shaPrefix, suggestId } from "./model"; -import { installPlugin, replacePluginSource } from "./native"; -import { CodeBox, PermissionList, PluginText, RequestKinds, SourceDiff } from "./parts"; +import { ID_RE, MAX_SOURCE, shaPrefix, suggestId } from "./model"; +import { CodeBox, codeErrorOf, PermissionList, PluginText, RequestKinds, ScopeSummary } 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 }; +import { installPlugin, type NativeWrite } from "./write"; /** - * 添加插件、更换插件的代码:两步。 + * 添加插件:两步。 * * 1. **代码从哪儿来**:选一个本地的 `.js` 文件,或者粘贴。只从本地来 —— 不从链接装, * 没有插件市场。选好之后交给 core 读一遍(`PluginInspect`,不写任何东西)。 - * 2. **审核**:完整的代码、申请的每一项权限和它的后果、适用范围、设置项、ID、出错时的 - * 处置;读不了的说清第几行第几列。按「安装」之后,**由 Rust 再读一遍这份代码**,在 - * 系统原生对话框里写明插件名、权限和 SHA-256 的前几位,点了那里的「安装」才写配置 - * (I12:网页自己完成不了这一步)。这里显示的 SHA-256 和系统对话框里的是同一段, - * 对得上就是同一份代码。 + * 2. **审核**:完整的代码、申请的每一项权限和它的后果、代码里写的适用范围和出错时怎么办、 + * ID;读不了的写明第几行第几列。**按一次「安装」就装上**:改得了回答里工具调用的插件, + * 紧接着在系统的确认框里点头(Rust 再读一遍这份代码,写明插件名、权限和 SHA-256 的前几位, + * 和这里显示的是同一段),没有第二遍审核。 * - * 更换代码时没有安装选项(ID、范围、设置项都留着),权限和原来的对比:新增的标出来; - * 代码可以和现在确认过的那一份对比。 + * 出错时怎么办、适用范围、设置都写在代码里:装上之后在插件的编辑器里改。 */ export function SourceDialog({ - mode, taken, native, onClose, onDone, }: { - mode: Mode; /** 已有的插件 ID */ taken: readonly string[]; native: NativeWrite; onClose: () => void; - /** 写成了:插件的 ID */ + /** 装上了:插件的 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"); @@ -77,19 +57,13 @@ export function SourceDialog({ 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 [read, setRead] = useState<{ result: PluginInspection; source: string } | 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); @@ -117,14 +91,9 @@ export function SourceDialog({ setError(null); try { const result = await call("PluginInspect", { source }); - const fileName = how === "file" ? (file?.name ?? null) : null; - setRead({ result, source, fileName }); + setRead({ result, source }); const m = result.manifest; - if (m && !replacing) { - setId((cur) => cur || suggestId(m.name, fileName, taken)); - setScope(draftOf(m.scope)); - setSettings(settingsDraftOf(m.settings_schema, {})); - } + if (m) setId((cur) => cur || suggestId(m.name, how === "file" ? (file?.name ?? null) : null, taken)); setCancelled(false); setStep("review"); } catch (e) { @@ -136,33 +105,17 @@ export function SourceDialog({ 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; - // 装的是 core 自带的那个默认插件(id 和名字都对得上)时,标签按界面语言说 - const schema = manifest ? localSchema(replacing?.id ?? id, manifest.name, manifest.settings_schema) : []; - const settingsCheck = manifest ? settingsOf(schema, settings) : { values: {}, bad: [] }; - const blocked = - !manifest || loadError != null || idProblem != null || (!replacing && scopeProblem(scope) != null) || settingsCheck.bad.length > 0; + const idProblem = !ID_RE.test(id) ? t.idBad : taken.includes(id) ? t.idTaken : null; + const blocked = !manifest || loadError != null || idProblem != null; - async function write() { - if (!read || blocked) return; + async function install() { + if (!read || !manifest || 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); + const r = await native((base) => installPlugin({ source: read.source, id, enabled: true, base_version: base }, manifest)); + if (r === "done") onDone(id); else setCancelled(true); } catch (e) { setError(errorText(e)); @@ -171,26 +124,14 @@ export function SourceDialog({ } } - // 名字是插件写的:按纯文本画(`PluginText`),不拼进字符串 - const title = replacing - ? t.replaceTitle() - : step === "source" - ? t.addTitle - : t.reviewTitle; - return ( !o && !writing && onClose()}> - -{title} - + {step === "source" ? t.addTitle : t.reviewTitle} {step === "source" ? ( {t.sourceDescription} ) : ( @@ -265,40 +206,23 @@ export function SourceDialog({ ) : ( read && ( - -
- - setId(e.target.value.trim())} - /> - - -
- - {schema.length > 0 && ( -
-

{ft.settings}

- -
- )} - - ) - } - /> + + {manifest && ( + + setId(e.target.value.trim())} + /> + + )} + ) )} @@ -320,8 +244,8 @@ export function SourceDialog({ {t.next} ) : ( - )} @@ -330,50 +254,13 @@ export function SourceDialog({ ); } -/** 出错时:拒绝这次请求(默认),或者跳过这个插件 */ -export function OnErrorField({ value, onChange }: { value: OnError; onChange: (v: OnError) => void }) { - const lt = useText(pluginLabelsText); - return ( - - - label={lt.onError} - value={value} - options={[ - { id: "reject", label: lt.onErrorOptions.reject }, - { id: "skip", label: lt.onErrorOptions.skip }, - ]} - onChange={onChange} - /> - - ); -} - -/** 审核那一步:读不了的原因、插件是谁、要哪些权限、代码,和安装选项 */ -function Review({ - result, - source, - replacing, - options, -}: { - result: PluginInspection; - source: string; - replacing: PluginView | null; - options: ReactNode; -}) { +/** 审核那一步:读不了的原因、插件是谁、要哪些权限、代码里写的范围和出错时怎么办、代码,和 ID */ +function Review({ result, source, children }: { result: PluginInspection; source: string; children: ReactNode }) { const t = useText(sourceDialogText); const pt = useText(pluginPartsText); const m = result.manifest ?? null; const err = result.error ?? null; const lines = source.split("\n").length; - const [view, setView] = useState<"code" | "compare">("code"); - const current = useResource(replacing ? `plugin-source:${replacing.id}` : null, () => - call("PluginSourceDiff", null, replacing!.id), - ); - // 换上来的是 core 自带的那个默认插件(id 和名字都对得上):按界面语言说 - const words = m && { - name: pluginName(replacing?.id, m.name), - description: replacing ? pluginDescription({ id: replacing.id, name: m.name, description: m.description }) : m.description, - }; return (
{err && ( @@ -385,14 +272,14 @@ function Review({ )} - {m && words && ( + {m && (

- +

- {words.description && ( + {m.description && (

- +

)}

@@ -409,12 +296,7 @@ function Review({

{pt.permissions}

{m.permissions.length > 0 ? ( - 0 ? replacing.permissions : undefined} - replyMode={m.reply_mode} - /> + ) : (

{t.noPermissions}

)} @@ -422,34 +304,29 @@ function Review({
)} + {m && } +
-
-

{t.code}

- {replacing && current.data && ( - - label={t.code} - value={view} - options={[ - { id: "code", label: t.fullCode }, - { id: "compare", label: t.compare }, - ]} - onChange={setView} - /> - )} -
- {view === "compare" && current.data ? ( - - ) : ( - - )} +

{t.code}

+
- {options && ( -
-

{t.options}

- {options} -
- )} + {children}
); } + +/** 代码里写的适用范围和出错时怎么办:装上就按它们运行 */ +function Facts({ m }: { m: ManifestView }) { + const lt = useText(pluginLabelsText); + return ( +
+
{lt.scope}
+
+ +
+
{lt.onError}
+
{lt.onErrorOptions[m.on_error]}
+
+ ); +} diff --git a/src/plugins/api.provisional.ts b/src/plugins/api.provisional.ts new file mode 100644 index 00000000..e7ec92be --- /dev/null +++ b/src/plugins/api.provisional.ts @@ -0,0 +1,124 @@ +/** + * PROVISIONAL —— 插件的 JS 文件是唯一的真相(约定附录 4,core v0.59.0、协议 35)。 + * + * **core 发版之前只能这样。**钉着的 core v0.58.0 生成的 `src/generated/tw-api.ts` 还是旧的 + * 样子:出错时怎么办、适用范围、设置的值存在配置里,由 `UpdatePlugin` 改。附录 4 之后它们 + * 都写在插件文件的 manifest 里(设置项的 `value` 取代了 `default`,多了 `on_error`), + * 一个插件只有一种写法:把整份代码和开关交给 `SavePlugin`,core 自己分辨是只改了数据还是 + * 改了代码。界面其余的代码不知道这一层:`src/types.ts` 用这里的同名类型盖住生成的那几个 + * (显式转出优先于 `export type *`),`src/control.ts` 的端点表也从这里取。 + * + * 和 v0.58.0 相比: + * + * - 新增 `SavePlugin`(`PUT /plugins/{id}`,网页可调)、`PluginRewrite`(`POST /plugins/rewrite`, + * 网页可调,没有副作用:按表单的值改写代码里的 manifest); + * - 新增三个要在系统的确认框里点头之后才发的端点(**网页不可调**,只有 Rust 的命令发): + * `CreatePluginConfirmed`、`SavePluginConfirmed`、`ApprovePluginFileConfirmed`; + * - `CreatePlugin`、`ApprovePluginFile` 进了网页的白名单,`CreatePlugin` 只带代码、ID 和开关; + * - 删掉 `UpdatePlugin`、`UpdatePluginConfirmed`、`ReplacePluginSource`。 + * + * 约定没写死、这里先按最可能的样子写的(接上时以生成的为准): + * + * - `PluginCreate` 只剩 `{ source, id, enabled, base_version }`(出错时、范围、设置都在代码里); + * - `SettingSpecView` 是 `{ key, kind, label, value }`;`PluginView` 不再有 `settings` + * (值在 `settings_schema[].value` 里);`ManifestView` 多了 `on_error`; + * - 插件文件在确认之后被改过(状态「文件已更改」)时,用确认过的代码拨开关(`SavePlugin`, + * 代码和确认过的一样)**不动磁盘上那份**:界面的开关就是这么拨的。 + * + * 接上正式版(core v0.59.0 发版、钉点升上去之后): + * + * 1. 升级 core 钉点、`cargo update`,重新生成 `src/generated/tw-api.ts` + * (`UPDATE_TS=1 cargo test --manifest-path src-tauri/Cargo.toml --test ts_bindings`); + * 2. 删掉 `src/types.ts` 里标着「临时」的那一段转出;`src/control.ts` 的 `Endpoints`、 + * `EndpointTable` 改回从 `./generated/tw-api` 取(`EndpointTable` 换回 `typeof ENDPOINTS`); + * 3. Rust 那边照 `src-tauri/src/plugins/wire.rs` 顶上的步骤换; + * 4. core 新加的码(如 `gw.plugin.manifest_not_data`)补进 `src/i18n/core.zh.json`; + * 5. 删掉这个文件,`pnpm typecheck`:名字或形状和这里不一样的地方会在用到它的那一处报错。 + */ +import type * as G from "@/generated/tw-api"; + +// ─── 插件文件里的 manifest ─── + +/** + * 插件声明的一个设置项。`label` 是**插件写的字**:界面当纯文本显示。`value` 是它现在的值, + * **写在插件文件里**(取代了 `default`),和 `kind` 同一种类型 + */ +export type SettingSpecView = { key: string; kind: G.SettingKind; label: string; value: G.SettingValue }; + +/** 插件文件里的 manifest。出错时怎么办(`on_error`)也写在里面,默认是拒绝 */ +export type ManifestView = Omit & { + on_error: G.OnError; + settings_schema: SettingSpecView[]; +}; + +/** 读一份代码看到的东西。**什么都没留下** */ +export type PluginInspection = Omit & { manifest: ManifestView | null }; + +/** + * 一个装上了的插件(`GET /plugins`),按运行的顺序。`on_error`、`scope`、`settings_schema` + * 都是从它的文件里读出来的 + */ +export type PluginView = Omit & { settings_schema: SettingSpecView[] }; + +// ─── 写 ─── + +/** + * 装一个插件(`POST /plugins`,网页可调;改得了回答里工具调用的要点头,走 + * `POST /plugins/confirmed`)。出错时怎么办、范围、设置都在代码里 + */ +export type PluginCreate = { source: string; id?: string | null; enabled: boolean; base_version?: string | null }; + +/** + * 保存一个插件(`PUT /plugins/{id}`):**整份代码**和开关。core 和确认过的那一份比:只改了 + * manifest 里的数据(出错时、范围、设置的值)的不必点头;改得了工具调用的插件,打开它、 + * 改代码要点头(`control.plugin.needs_confirmation`,走 `PUT /plugins/{id}/confirmed`) + */ +export type PluginSave = { source: string; enabled: boolean; base_version?: string | null }; + +/** 按表单改写代码里的 manifest(`POST /plugins/rewrite`)。**没有副作用** */ +export type PluginRewrite = { + source: string; + on_error: G.OnError; + scope: G.PluginScope; + /** 每一个声明了的设置项的值 */ + settings: { [key in string]: G.SettingValue }; +}; + +/** 改写之后的代码:manifest 那一段换成新写的,其余每个字节都和原来一样 */ +export type PluginRewritten = { source: string }; + +// ─── 端点 ─── + +type Changed = { + Plugins: { req: null; res: Array }; + PluginInspect: { req: G.PluginSource; res: PluginInspection }; + CreatePlugin: { req: PluginCreate; res: G.ConfigWritten }; + CreatePluginConfirmed: { req: PluginCreate; res: G.ConfigWritten }; + SavePlugin: { req: PluginSave; res: G.ConfigWritten }; + SavePluginConfirmed: { req: PluginSave; res: G.ConfigWritten }; + ApprovePluginFileConfirmed: { req: G.PluginApprove; res: G.ConfigWritten }; + PluginRewrite: { req: PluginRewrite; res: PluginRewritten }; +}; + +/** v0.59.0 删掉的 */ +type Removed = "UpdatePlugin" | "UpdatePluginConfirmed" | "ReplacePluginSource"; + +/** 各端点的请求和响应 */ +export type Endpoints = Omit & Changed; + +/** 新加的那几个端点的方法、路径和路径参数(和 core 的 `tw_api::ep` 同一种写法) */ +type Added = { + CreatePluginConfirmed: { method: "POST"; path: "/plugins/confirmed"; params: readonly []; format: "json" }; + SavePlugin: { method: "PUT"; path: "/plugins/{id}"; params: readonly ["id"]; format: "json" }; + SavePluginConfirmed: { method: "PUT"; path: "/plugins/{id}/confirmed"; params: readonly ["id"]; format: "json" }; + ApprovePluginFileConfirmed: { + method: "POST"; + path: "/plugins/{id}/approve/confirmed"; + params: readonly ["id"]; + format: "json"; + }; + PluginRewrite: { method: "POST"; path: "/plugins/rewrite"; params: readonly []; format: "json" }; +}; + +/** 端点表的类型(`src/control.ts` 按它数路径参数) */ +export type EndpointTable = Omit & Added; diff --git a/src/plugins/fields.i18n.ts b/src/plugins/fields.i18n.ts index 9dc5c1e9..fb66a214 100644 --- a/src/plugins/fields.i18n.ts +++ b/src/plugins/fields.i18n.ts @@ -22,8 +22,6 @@ export const pluginFieldsText = messages( needOne: (part: string) => `${part}选了「指定」,至少添加一项。`, settings: "设置项", numberBad: (label: string) => `「${label}」需要填写数字。`, - defaultIs: (v: string) => `默认值:${v}`, - emptyDefault: "默认值为空", }, { all: "All", @@ -45,7 +43,5 @@ export const pluginFieldsText = messages( needOne: (part: string) => `${part} is set to Specific: add at least one.`, settings: "Settings", numberBad: (label: string) => `“${label}” needs a number.`, - defaultIs: (v: string) => `Default: ${v}`, - emptyDefault: "Empty by default", }, ); diff --git a/src/plugins/fields.tsx b/src/plugins/fields.tsx index fe034423..74b7b097 100644 --- a/src/plugins/fields.tsx +++ b/src/plugins/fields.tsx @@ -1,5 +1,6 @@ -import { useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/ui/button"; +import { Autocomplete, ComboboxBareInput, ComboboxContent, ComboboxItem, ComboboxList } from "@/ui/combobox"; import { Input } from "@/ui/input"; import { rowMotion, usePresentList } from "@/ui/motion"; import { Segmented } from "@/ui/segmented"; @@ -9,10 +10,10 @@ import { cn } from "@/lib/utils"; import { useText } from "@/i18n"; import { appLabel } from "@/labels"; import { Boxed, FormItem } from "@/upstreams/parts"; -import type { PluginScope, SettingSpecView, SettingValue } from "@/types"; +import type { OnError, PluginScope, SettingSpecView, SettingValue } from "@/types"; import { pluginFieldsText } from "./fields.i18n"; import { pluginLabelsText } from "./labels.i18n"; -import { SCOPE_PARTS, type ScopePart } from "./model"; +import { globMatch, SCOPE_PARTS, type ScopePart } from "./model"; import { PluginText } from "./parts"; // ─────────────────────────────────────────────── 适用范围 @@ -41,12 +42,39 @@ export function scopeProblem(d: ScopeDraft): ScopePart | null { return SCOPE_PARTS.find((p) => d[p].mode === "some" && d[p].list.length === 0) ?? null; } +/** 输入时给的一条建议:已知的客户端、模型或上游。`note` 写在右边(客户端的产品名) */ +export interface Suggestion { + value: string; + note?: string; +} + +export type ScopeSuggestions = Record; + +/** + * 输入的这一段对得上哪几条建议:带 `*` 的按通配比(输入 `claude-*` 就列出它会对上的那些), + * 不带的按包含比;都不分大小写,和 core 一样 + */ +export function suggestionMatches(query: string, value: string): boolean { + const q = query.trim().toLowerCase(); + if (q === "") return true; + const v = value.toLowerCase(); + return q.includes("*") ? globMatch(q, v) : v.includes(q); +} + /** * 适用范围的三项。**模型和上游各说一句按什么匹配**:模型比的是发给上游的那个名字(路由改写 * 过的按改写之后的),上游对请求和回答都管 —— 不说的话,按客户端发的模型名写范围的人会 * 以为没生效,以为上游只管回答的人会漏掉请求那一头。 */ -export function ScopeFields({ value, onChange }: { value: ScopeDraft; onChange: (next: ScopeDraft) => void }) { +export function ScopeFields({ + value, + onChange, + suggestions, +}: { + value: ScopeDraft; + onChange: (next: ScopeDraft) => void; + suggestions?: ScopeSuggestions; +}) { const t = useText(pluginFieldsText); const lt = useText(pluginLabelsText); const set = (p: ScopePart, next: Partial) => onChange({ ...value, [p]: { ...value[p], ...next } }); @@ -69,6 +97,7 @@ export function ScopeFields({ value, onChange }: { value: ScopeDraft; onChange: value={value[p].list} onChange={(list) => set(p, { list })} invalid={value[p].list.length === 0} + suggestions={suggestions?.[p] ?? []} /> )} @@ -86,21 +115,22 @@ function PatternList({ value, onChange, invalid, + suggestions, }: { part: ScopePart; value: string[]; onChange: (next: string[]) => void; invalid: boolean; + suggestions: readonly Suggestion[]; }) { const t = useText(pluginFieldsText); const lt = useText(pluginLabelsText); const rows = usePresentList(value, (x) => x); const [draft, setDraft] = useState(""); - function commit() { - const x = draft.trim(); - if (!x) return; - if (!value.includes(x)) onChange([...value, x]); + function add(x: string) { + const v = x.trim(); + if (v && !value.includes(v)) onChange([...value, v]); setDraft(""); } @@ -128,27 +158,13 @@ function PatternList({ ))} -
- setDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key !== "Enter" || e.nativeEvent.isComposing) return; - // 对话框会把回车当成提交 - e.preventDefault(); - commit(); - }} - onBlur={commit} - /> - {t.enterToAdd} -
+ !value.includes(s.value))} + />

{invalid ? t.needOne(lt.scopeParts[part]) : t.hint[part]} @@ -157,24 +173,145 @@ function PatternList({ ); } +/** 建议列表最多列几条 */ +const MAX_SUGGESTIONS = 50; + +/** + * 名单的最后一行:**自由输入 + 建议**。写的可以是一个名字,也可以是带 `*` 的通配 + * (`claude-*`);建议是已知的客户端、模型和上游,输入通配时列出它对得上的那些。 + * + * 所以是 Autocomplete,不是 Combobox(和路由规则的模型名同一个道理):建议之外的名字、 + * 通配都要留得住。点一条建议、或者用方向键选中再回车,直接加进名单;没选中建议时回车加的 + * 是输入的原样。 + */ +function PatternInput({ + part, + draft, + onDraft, + onAdd, + suggestions, +}: { + part: ScopePart; + draft: string; + onDraft: (v: string) => void; + onAdd: (v: string) => void; + suggestions: readonly Suggestion[]; +}) { + const t = useText(pluginFieldsText); + const lt = useText(pluginLabelsText); + // **建议列表挂进所在的对话框,不挂在 body 上**:Radix 的模态对话框把 body 设成 + // pointer-events: none,挂在 body 上的列表看得见、点不中(见路由的 `ModelInput`) + const anchor = useRef(null); + const [container, setContainer] = useState(null); + useLayoutEffect(() => { + setContainer(anchor.current?.closest('[role="dialog"]') ?? null); + }, []); + const [open, setOpen] = useState(false); + // **Esc 只收起列表**,不连同对话框(和里面没保存的改动)一起关掉 + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape" || e.isComposing) return; + e.stopPropagation(); + setOpen(false); + }; + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, [open]); + /** 方向键选中了一条建议:回车交给列表(加那一条),不是加输入的原样 */ + const highlighted = useRef(undefined); + const notes = useMemo(() => new Map(suggestions.map((s) => [s.value, s.note])), [suggestions]); + const items = useMemo(() => suggestions.map((s) => s.value), [suggestions]); + const shown = useMemo(() => items.filter((v) => suggestionMatches(draft, v)).slice(0, MAX_SUGGESTIONS), [items, draft]); + + return ( +

+ { + if (d.reason === "item-press") onAdd(v); + else onDraft(v); + }} + open={open && shown.length > 0} + onOpenChange={setOpen} + onItemHighlighted={(v) => { + highlighted.current = v as string | undefined; + }} + openOnInputClick + > + { + if (e.key !== "Enter" || e.nativeEvent.isComposing) return; + // 对话框会把回车当成提交 + e.preventDefault(); + if (open && highlighted.current !== undefined) return; + onAdd(draft); + }} + onBlur={() => { + if (!open) onAdd(draft); + }} + /> + + + {(v: string) => ( + + {v} + {notes.get(v) && {notes.get(v)}} + + )} + + + + {t.enterToAdd} +
+ ); +} + +// ─────────────────────────────────────────────── 出错时 + +/** 出错时:拒绝这次请求(默认),或者跳过这个插件 */ +export function OnErrorField({ value, onChange }: { value: OnError; onChange: (v: OnError) => void }) { + const lt = useText(pluginLabelsText); + return ( + + + label={lt.onError} + value={value} + options={[ + { id: "reject", label: lt.onErrorOptions.reject }, + { id: "skip", label: lt.onErrorOptions.skip }, + ]} + onChange={onChange} + /> + + ); +} + // ─────────────────────────────────────────────── 插件的设置项 -/** 设置项在表单里的值:数字先按输入的原样存着,保存时才换成数 */ +/** 设置项在表单里的值:数字先按输入的原样存着,交出去时才换成数 */ export type SettingsDraft = Record; -export function settingsDraftOf( - schema: readonly SettingSpecView[], - values: Partial>, -): SettingsDraft { +/** 表单从 manifest 里的值起头。值写在插件文件里(`value`) */ +export function settingsDraftOf(schema: readonly SettingSpecView[]): SettingsDraft { const out: SettingsDraft = {}; for (const s of schema) { - const v = values[s.key] ?? s.default; + const v = s.value; out[s.key] = s.kind === "boolean" ? v === true : typeof v === "boolean" ? "" : String(v); } return out; } -/** 写回去的值,和填错的那几项(按标签) */ +/** 交出去的值,和填错的那几项(按标签) */ export function settingsOf( schema: readonly SettingSpecView[], draft: SettingsDraft, @@ -195,8 +332,8 @@ export function settingsOf( } /** - * 插件声明的设置项。**标签是插件自己写的**,只按纯文本画(`PluginText`);默认值写在下面, - * 改过之后对照得上。 + * 插件声明的设置项。**标签是插件自己写的**,只按纯文本画(`PluginText`);core 自带的默认 + * 插件按界面语言说(`localSchema`)。 * * 字符串一项一行、可以写多行(一行一条的替换表这类),输入框随内容长高;数字两个一行;开关 * 一项一行排在最后 —— 开关和输入框并排时,两边的高度对不齐。 @@ -223,7 +360,6 @@ export function SettingsFields({ const label = s.label || s.key; const v = value[s.key]; const text = typeof v === "string" ? v : ""; - const def = s.default === "" ? t.emptyDefault : t.defaultIs(String(s.default)); const bad = s.kind === "number" && text.trim() !== "" && !Number.isFinite(Number(text.trim())); const set = (next: string) => onChange({ ...value, [s.key]: next }); return ( @@ -243,13 +379,7 @@ export function SettingsFields({ onChange={(e) => set(e.target.value)} /> )} - {bad ? ( -

{t.numberBad(label)}

- ) : ( -

- -

- )} + {bad &&

{t.numberBad(label)}

} ); })} diff --git a/src/plugins/model.test.ts b/src/plugins/model.test.ts index 2fd94cd6..a214e43d 100644 --- a/src/plugins/model.test.ts +++ b/src/plugins/model.test.ts @@ -1,24 +1,25 @@ import { describe, expect, it } from "vitest"; import { setLang } from "@/i18n"; -import type { PluginView } from "@/types"; +import type { ManifestView, PluginView } from "@/types"; import { localSchema, pluginDescription, pluginName } from "./defaults"; import { hunks, lineDiff, tally } from "./diff"; import { - changesWhatItDoes, cpuMs, extraKinds, globMatch, - guarded, + holdsToolCalls, ID_RE, manifestUnknown, requestInScope, + saveAsks, shaPrefix, + shapeChanged, splitInvisible, suggestId, touchesReplies, touchesRequests, } from "./model"; -import { draftOf, scopeOf, scopeProblem, settingsDraftOf, settingsOf } from "./fields"; +import { draftOf, scopeOf, scopeProblem, settingsDraftOf, settingsOf, suggestionMatches } from "./fields"; describe("通配", () => { it("* 是任意一段,可以为空", () => { @@ -68,8 +69,7 @@ function plugin(over: Partial = {}): PluginView { requests: ["conversation"], scope: { clients: [], models: [], upstreams: [] }, reply_mode: "block", - settings_schema: [{ key: "windows_client", kind: "boolean", label: "客户端运行在 Windows 上(关闭时按 WSL 处理)", default: false }], - settings: { windows_client: false }, + settings_schema: [{ key: "windows_client", kind: "boolean", label: "The client runs on Windows (otherwise WSL)", value: false }], sha256: "aa", status: { kind: "disabled" }, stats: { calls: 0, changed: 0, rejected: 0, errors: 0, avg_cpu_us: 0, last_error: null }, @@ -77,28 +77,57 @@ function plugin(over: Partial = {}): PluginView { }; } -const updateOf = (p: PluginView) => ({ enabled: p.enabled, on_error: p.on_error, scope: p.scope, settings: p.settings }); +/** 一份 manifest,按需改几项 */ +function manifest(over: Partial = {}): ManifestView { + return { + name: "Convert WSL and Windows paths", + description: "Rewrites drive paths in tool-call arguments.", + permissions: ["messages", "reply_tool_calls"], + requests: ["conversation"], + scope: { clients: [], models: [], upstreams: [] }, + reply_mode: "block", + on_error: "reject", + settings_schema: [{ key: "windows_client", kind: "boolean", label: "The client runs on Windows (otherwise WSL)", value: false }], + hooks: { request: true, reply_text: false, tool_call: true }, + ...over, + }; +} -describe("要在系统的确认框里点头的改动", () => { - it("改得了工具调用的、读不出权限的要;别的不要", () => { - expect(guarded(plugin())).toBe(true); - expect(guarded(plugin({ permissions: [] }))).toBe(true); - expect(guarded(plugin({ permissions: ["system"] }))).toBe(false); +describe("要在系统的确认框里点头的改动(约定附录 4 §3)", () => { + it("改得了工具调用的、读不出权限的算;别的不算", () => { + expect(holdsToolCalls(["messages", "reply_tool_calls"])).toBe(true); + expect(holdsToolCalls([])).toBe(true); + expect(holdsToolCalls(null)).toBe(true); + expect(holdsToolCalls(["system", "reply_text"])).toBe(false); }); - it("打开它、改设置、改范围算;停用、改出错时、照原样交回的默认值不算", () => { - const p = plugin(); - expect(changesWhatItDoes(p, { ...updateOf(p), enabled: true })).toBe(true); - expect(changesWhatItDoes(p, { ...updateOf(p), settings: { windows_client: true } })).toBe(true); - expect(changesWhatItDoes(p, { ...updateOf(p), scope: { ...p.scope, upstreams: ["deepseek"] } })).toBe(true); - expect(changesWhatItDoes(p, { ...updateOf(p), on_error: "skip" })).toBe(false); - expect(changesWhatItDoes({ ...p, enabled: true }, { ...updateOf(p), enabled: false })).toBe(false); - // 不交的设置按默认值算;范围不看顺序和空白 - expect(changesWhatItDoes(p, { ...updateOf(p), settings: {} })).toBe(false); - const scoped = plugin({ scope: { clients: [], models: ["b*", "a*"], upstreams: [] } }); - expect(changesWhatItDoes(scoped, { ...updateOf(scoped), scope: { clients: [], models: [" a*", "b*"], upstreams: [] } })).toBe( - false, - ); + it("只改了数据(出错时、范围、设置的值)不是代码改动;别的都是", () => { + const m = manifest(); + expect(shapeChanged(m, manifest({ on_error: "skip", scope: { clients: ["codex"], models: [], upstreams: [] } }))).toBe(false); + expect( + shapeChanged(m, manifest({ settings_schema: [{ ...m.settings_schema[0]!, value: true }] })), + ).toBe(false); + // 权限的顺序不算 + expect(shapeChanged(m, manifest({ permissions: ["reply_tool_calls", "messages"] }))).toBe(false); + expect(shapeChanged(m, manifest({ permissions: ["messages"] }))).toBe(true); + expect(shapeChanged(m, manifest({ name: "Paths" }))).toBe(true); + expect(shapeChanged(m, manifest({ settings_schema: [{ ...m.settings_schema[0]!, label: "Windows" }] }))).toBe(true); + expect(shapeChanged(m, manifest({ hooks: { request: true, reply_text: true, tool_call: true } }))).toBe(true); + }); + + it("改得了工具调用的插件:打开它、一定改了代码才直接去点头", () => { + const tools = ["messages", "reply_tool_calls"] as const; + const plain = ["system"] as const; + expect(saveAsks({ old: [...tools], next: [...tools], turningOn: true, codeChanged: false })).toBe(true); + expect(saveAsks({ old: [...tools], next: [...tools], turningOn: false, codeChanged: true })).toBe(true); + // 只改了数据、停用:不问 + expect(saveAsks({ old: [...tools], next: [...tools], turningOn: false, codeChanged: false })).toBe(false); + // 不碰工具调用的插件什么都不问 + expect(saveAsks({ old: [...plain], next: [...plain], turningOn: true, codeChanged: true })).toBe(false); + // 新代码多要了工具调用:改代码要问 + expect(saveAsks({ old: [...plain], next: [...tools], turningOn: false, codeChanged: true })).toBe(true); + // 原来那一版读不出权限:按改得了算 + expect(saveAsks({ old: null, next: [...plain], turningOn: true, codeChanged: false })).toBe(true); }); it("读不出 manifest 的只有 id 和状态", () => { @@ -229,15 +258,23 @@ describe("表单", () => { expect(scopeProblem(d)).toBeNull(); }); - it("设置项:数字按原样存着,保存时才换成数;填错的按标签报", () => { + it("设置项:值从代码里来,数字按原样存着,交出去时才换成数;填错的按标签报", () => { const schema = [ - { key: "note", kind: "string" as const, label: "附加内容", default: "" }, - { key: "days", kind: "number" as const, label: "天数", default: 7 }, - { key: "on", kind: "boolean" as const, label: "开关", default: true }, + { key: "note", kind: "string" as const, label: "附加内容", value: "x" }, + { key: "days", kind: "number" as const, label: "天数", value: 7 }, + { key: "on", kind: "boolean" as const, label: "开关", value: true }, ]; - const draft = settingsDraftOf(schema, { note: "x" }); + const draft = settingsDraftOf(schema); expect(draft).toEqual({ note: "x", days: "7", on: true }); expect(settingsOf(schema, draft)).toEqual({ values: { note: "x", days: 7, on: true }, bad: [] }); expect(settingsOf(schema, { ...draft, days: "seven" }).bad).toEqual(["天数"]); }); + + it("适用范围的建议:带 * 的按通配挑,不带的按包含挑,都不分大小写", () => { + expect(suggestionMatches("claude-*", "claude-sonnet-4-5")).toBe(true); + expect(suggestionMatches("claude-*", "gpt-5")).toBe(false); + expect(suggestionMatches("*-mini", "GPT-5-Mini")).toBe(true); + expect(suggestionMatches("sonnet", "claude-sonnet-4-5")).toBe(true); + expect(suggestionMatches("", "anything")).toBe(true); + }); }); diff --git a/src/plugins/model.ts b/src/plugins/model.ts index 84fa0121..8a0b4948 100644 --- a/src/plugins/model.ts +++ b/src/plugins/model.ts @@ -2,7 +2,7 @@ * 插件页的纯逻辑:权限分成请求和回答两头、处理哪几种请求、适用范围的通配、插件 ID、要不要 * 在系统的确认框里点头、文本里看不见的字符。没有界面,测试在 `model.test.ts`。 */ -import type { HistoryRow, Permission, PluginScope, PluginUpdate, PluginView, RequestKind, SettingValue } from "@/types"; +import type { HistoryRow, ManifestView, Permission, PluginScope, PluginView, RequestKind } from "@/types"; /** 插件申请的权限。**这张表的顺序就是界面上列权限的顺序**(和 core 的 `Permission::ALL` 一样) */ export const PERMISSIONS: readonly Permission[] = [ @@ -41,37 +41,51 @@ export function extraKinds(kinds: readonly RequestKind[]): { extra: RequestKind[ export const manifestUnknown = (p: Pick) => p.permissions.length === 0; /** - * 改它要不要在系统的确认框里点头:权限里有 `reply_tool_calls`(改得了客户端要执行的命令), - * 或者读不出权限(core 按改得了算)。这样的插件,打开它、改设置、改范围都只能经过 - * `plugin_update_confirmed`;停用、改出错时怎么办照常(core 那边同一条规矩) + * 改得了回答里的工具调用:权限里有 `reply_tool_calls`(改得了客户端要执行的命令),或者 + * **读不出权限**(空的、不知道 —— core 按改得了算)。 */ -export const guarded = (p: Pick) => - p.permissions.length === 0 || p.permissions.includes("reply_tool_calls"); +export const holdsToolCalls = (perms: readonly Permission[] | null | undefined): boolean => + !perms || perms.length === 0 || perms.includes("reply_tool_calls"); -/** 范围的一张名单比较之前:去掉两头的空白,排好、去重(和 core 比的方式一样) */ -const norm = (list: readonly string[]) => [...new Set(list.map((x) => x.trim()))].sort(); - -const sameList = (a: readonly string[], b: readonly string[]) => { - const [x, y] = [norm(a), norm(b)]; - return x.length === y.length && x.every((v, i) => v === y[i]); -}; +/** + * 两份 manifest 除了数据(出错时、适用范围、设置项的值)之外有没有不一样:名字、说明、 + * 权限、处理的请求种类、回答的方式、导出的钩子、设置项的键、类型和标签。**不一样就一定是 + * 改了代码**(core 的「只改了数据」要求这些全都一样);一样的不一定没改代码 —— manifest + * 以外的代码界面比不出来,交给 core 判断。 + */ +export function shapeChanged(a: ManifestView, b: ManifestView): boolean { + const set = (xs: readonly string[]) => [...new Set(xs)].sort().join("\n"); + const settings = (m: ManifestView) => JSON.stringify(m.settings_schema.map((s) => [s.key, s.kind, s.label])); + return ( + a.name !== b.name || + (a.description ?? null) !== (b.description ?? null) || + set(a.permissions) !== set(b.permissions) || + set(a.requests) !== set(b.requests) || + a.reply_mode !== b.reply_mode || + a.hooks.request !== b.hooks.request || + a.hooks.reply_text !== b.hooks.reply_text || + a.hooks.tool_call !== b.hooks.tool_call || + settings(a) !== settings(b) + ); +} /** - * 这次改动里有没有要点头的:打开它、改设置(按生效的值比,没写的按默认值算)、改范围。 - * **只是提示**:判定在 core,它说要点头(`control.plugin.needs_confirmation`)也走同一条路 + * 保存时要不要**直接**请 Rust 弹系统的确认框(约定附录 4 §3):插件改得了工具调用(原来的 + * 或者新的代码里),而这次要打开它,或者一定改了代码。 + * + * 只有界面确定要点头时才直接去:省下一次注定被拒的请求。确定不了的(代码在 manifest 以外 + * 改了没有,界面看不出来)照常走网页那条 —— core 说要点头(403)再请 Rust,见 `write.ts`。 */ -export function changesWhatItDoes(p: PluginView, next: PluginUpdate): boolean { - if (next.enabled && !p.enabled) return true; - if ((["clients", "models", "upstreams"] as const).some((k) => !sameList(p.scope[k], next.scope[k]))) return true; - const effective = (given: Partial>) => { - const all: Record = {}; - for (const s of p.settings_schema) all[s.key] = given[s.key] ?? s.default; - for (const [k, v] of Object.entries(given)) if (v !== undefined && !(k in all)) all[k] = v; - return all; - }; - const [a, b] = [effective(p.settings), effective(next.settings)]; - const keys = new Set([...Object.keys(a), ...Object.keys(b)]); - return [...keys].some((k) => a[k] !== b[k]); +export function saveAsks(x: { + /** 确认过的那一版的权限。读不出来是 `null` */ + old: readonly Permission[] | null; + /** 这次的代码的权限 */ + next: readonly Permission[] | null; + turningOn: boolean; + /** 一定改了代码(`shapeChanged`,或者确认过的那一版读不出来而代码不一样) */ + codeChanged: boolean; +}): boolean { + return (holdsToolCalls(x.old) || holdsToolCalls(x.next)) && (x.turningOn || x.codeChanged); } /** 一条记录下来的请求发给上游的模型:路由规则改写过的,按回答的那一跳发出的名字 */ @@ -89,8 +103,9 @@ export const EMPTY_SCOPE: PluginScope = { clients: [], models: [], upstreams: [] /** * 一条通配规则对不对得上:`*` 是任意一段(可以为空),别的字符原样比。 * - * **只给界面挑「最近哪几条请求在范围内」用**(试运行的候选),不决定插件跑不跑 —— - * 那是 core 的事,这里写错了顶多是候选的排序不对。 + * **只给界面用**:挑「最近哪几条请求在范围内」(试运行的候选),列出一条通配对得上的已知 + * 名字(适用范围的建议)。不决定插件跑不跑 —— 那是 core 的事,这里写错了顶多是候选的 + * 排序、建议的多少不对。 */ export function globMatch(pattern: string, value: string): boolean { const parts = pattern.split("*"); @@ -131,6 +146,9 @@ export function requestInScope( ); } +/** 插件文件的上限,和 core 一样 */ +export const MAX_SOURCE = 1024 * 1024; + /** 插件 ID 的写法,和 core 一样:小写字母、数字、连字符,1 到 40 个 */ export const ID_RE = /^[a-z0-9-]{1,40}$/; diff --git a/src/plugins/native.test.ts b/src/plugins/native.test.ts deleted file mode 100644 index b6921c27..00000000 --- a/src/plugins/native.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { PluginUpdate, PluginView } from "@/types"; - -/* - `savePlugin` 走哪条路:网页那条(`UpdatePlugin`,经过 `call`),还是请 Rust 弹系统的确认框 - (`plugin_update_confirmed`,经过 `invoke`)。两样都换成记录调用的假的。 -*/ -const invoke = vi.fn(); -const call = vi.fn(); -vi.mock("@tauri-apps/api/core", () => ({ invoke: (...a: unknown[]) => invoke(...a) })); -vi.mock("@/control", () => ({ call: (...a: unknown[]) => call(...a) })); - -const { savePlugin } = await import("./native"); - -function plugin(over: Partial = {}): PluginView { - return { - id: "p", - name: "p", - description: null, - enabled: false, - on_error: "reject", - permissions: ["system"], - requests: ["conversation"], - scope: { clients: [], models: [], upstreams: [] }, - reply_mode: "block", - settings_schema: [], - settings: {}, - sha256: "aa", - status: { kind: "disabled" }, - stats: { calls: 0, changed: 0, rejected: 0, errors: 0, avg_cpu_us: 0, last_error: null }, - ...over, - }; -} - -const update = (p: PluginView, over: Partial = {}): PluginUpdate => ({ - enabled: p.enabled, - on_error: p.on_error, - scope: p.scope, - settings: p.settings, - base_version: "v1", - ...over, -}); - -beforeEach(() => { - invoke.mockReset(); - call.mockReset(); -}); - -describe("保存插件", () => { - it("别的插件照常走网页那条", async () => { - call.mockResolvedValue({ version: "v2" }); - const p = plugin(); - expect(await savePlugin(p, update(p, { enabled: true }))).toEqual({ kind: "done", version: "v2" }); - expect(call).toHaveBeenCalledWith("UpdatePlugin", expect.objectContaining({ enabled: true }), "p"); - expect(invoke).not.toHaveBeenCalled(); - }); - - it("打开改得了工具调用的插件:直接请 Rust 弹确认框,取消照原样交回", async () => { - invoke.mockResolvedValue({ kind: "cancelled" }); - const p = plugin({ permissions: ["messages", "reply_tool_calls"] }); - const next = update(p, { enabled: true }); - expect(await savePlugin(p, next)).toEqual({ kind: "cancelled" }); - expect(invoke).toHaveBeenCalledWith("plugin_update_confirmed", { req: { id: "p", update: next } }); - expect(call).not.toHaveBeenCalled(); - }); - - it("读不出权限的插件按改得了工具调用算", async () => { - invoke.mockResolvedValue({ kind: "done", version: "v3" }); - const p = plugin({ permissions: [] }); - expect(await savePlugin(p, update(p, { scope: { clients: ["codex"], models: [], upstreams: [] } }))).toEqual({ - kind: "done", - version: "v3", - }); - expect(call).not.toHaveBeenCalled(); - }); - - it("只是停用、改出错时:网页那条就够", async () => { - call.mockResolvedValue({ version: "v2" }); - const p = plugin({ enabled: true, permissions: ["reply_tool_calls"] }); - await savePlugin(p, update(p, { enabled: false, on_error: "skip" })); - expect(call).toHaveBeenCalledTimes(1); - expect(invoke).not.toHaveBeenCalled(); - }); - - it("core 说要点头的,同一份改动再请 Rust 走一遍确认", async () => { - call.mockRejectedValue({ code: "control.plugin.needs_confirmation", args: { plugin: "p" }, text: "…" }); - invoke.mockResolvedValue({ kind: "done", version: "v4" }); - const p = plugin(); - const next = update(p, { enabled: true }); - expect(await savePlugin(p, next)).toEqual({ kind: "done", version: "v4" }); - expect(invoke).toHaveBeenCalledWith("plugin_update_confirmed", { req: { id: "p", update: next } }); - }); - - it("别的失败照样抛出", async () => { - const stale = { code: "control.config_stale", args: {}, text: "stale" }; - call.mockRejectedValue(stale); - const p = plugin(); - await expect(savePlugin(p, update(p, { enabled: true }))).rejects.toBe(stale); - expect(invoke).not.toHaveBeenCalled(); - }); -}); diff --git a/src/plugins/native.ts b/src/plugins/native.ts deleted file mode 100644 index a7224b6c..00000000 --- a/src/plugins/native.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * 要在系统的确认框里点头的几步(I12):装插件、换代码、批准改过的文件,以及打开改得了回答 - * 里工具调用的插件、改它的设置或范围。 - * - * 这几个端点**不在网页的白名单里**(`src/control.ts`)。网页只能请 Rust 去做:Rust 自己再 - * 读一遍插件(不信这里给的任何关于插件的说法),在系统的确认框里写明它是谁、能做什么、这次 - * 改什么,点了头才写配置。用户在那个框里点了取消不是失败:回执是 `cancelled`,界面照原样。 - */ -import { invoke } from "@tauri-apps/api/core"; -import { call } from "@/control"; -import type { - PluginApproveRequest, - PluginInstallRequest, - PluginReplaceRequest, - PluginUpdate, - PluginUpdateRequest, - PluginView, - PluginWrite, -} from "@/types"; -import { changesWhatItDoes, guarded } from "./model"; - -export const installPlugin = (req: PluginInstallRequest) => invoke("plugin_install", { req }); - -export const replacePluginSource = (req: PluginReplaceRequest) => invoke("plugin_replace_source", { req }); - -export const approvePluginFile = (req: PluginApproveRequest) => invoke("plugin_approve", { req }); - -const updateConfirmed = (req: PluginUpdateRequest) => invoke("plugin_update_confirmed", { req }); - -/** core 说这次改动要在系统的确认框里点头(`UpdatePlugin` 答 403) */ -const NEEDS_CONFIRMATION = "control.plugin.needs_confirmation"; - -/** - * 保存一个插件的开关、出错时、范围、设置。 - * - * 改得了工具调用的插件(或者读不出权限的),打开它、改设置、改范围**直接请 Rust 弹系统的 - * 确认框**;别的照常走 `UpdatePlugin`。core 说这次要点头的(界面的判断和 core 对不上的 - * 时候),同一份改动再请 Rust 走一遍确认 —— 用户不必再按一次保存。 - */ -export async function savePlugin(p: PluginView, update: PluginUpdate): Promise { - if (guarded(p) && changesWhatItDoes(p, update)) return updateConfirmed({ id: p.id, update }); - try { - const w = await call("UpdatePlugin", update, p.id); - return { kind: "done", version: w.version }; - } catch (e) { - if (needsConfirmation(e)) return updateConfirmed({ id: p.id, update }); - throw e; - } -} - -function needsConfirmation(e: unknown): boolean { - return typeof e === "object" && e !== null && (e as { code?: unknown }).code === NEEDS_CONFIRMATION; -} diff --git a/src/plugins/parts.i18n.ts b/src/plugins/parts.i18n.ts index e1d8cb12..4c3547ec 100644 --- a/src/plugins/parts.i18n.ts +++ b/src/plugins/parts.i18n.ts @@ -8,6 +8,8 @@ export const pluginPartsText = messages( noDifference: "内容相同", invisible: (code: string) => `看不见的字符 ${code}`, loadingCode: "正在打开代码", + /** 读屏读出来的代码框 */ + codeLabel: "插件代码", permissions: "申请的权限", /** 这一项权限这一版没有了 */ permissionRemoved: "不再申请", @@ -21,6 +23,7 @@ export const pluginPartsText = messages( noDifference: "The contents are the same", invisible: (code: string) => `Invisible character ${code}`, loadingCode: "Opening the code", + codeLabel: "Plugin code", permissions: "Requested permissions", permissionRemoved: "No longer requested", statsTitle: "Runs", diff --git a/src/plugins/parts.tsx b/src/plugins/parts.tsx index a13796b6..021b3b05 100644 --- a/src/plugins/parts.tsx +++ b/src/plugins/parts.tsx @@ -8,14 +8,24 @@ import { useText } from "@/i18n"; import { coreText } from "@/i18n/core.i18n"; import { appLabel } from "@/labels"; import { when } from "@/format"; -import type { Permission, PluginOutcome, PluginScope, PluginStats, PluginStatus, ReplyMode, RequestKind } from "@/types"; +import type { + Permission, + PluginLoadError, + PluginOutcome, + PluginScope, + PluginStats, + PluginStatus, + ReplyMode, + RequestKind, +} from "@/types"; +import type { CodeError } from "./CodeView"; import { hunks, lineDiff, tally } from "./diff"; import { pluginLabelsText, pluginStatsText } from "./labels.i18n"; import { cpuMs, extraKinds, PERMISSIONS, splitInvisible, SCOPE_PARTS } from "./model"; import { pluginPartsText } from "./parts.i18n"; /** 代码框按需加载:CodeMirror 只在这几个对话框里用(见 `CodeView`) */ -const CodeView = lazy(() => import("./CodeView")); +export const CodeView = lazy(() => import("./CodeView")); /** * 插件自己写的字:名字、说明、设置项的标签、日志、报错(I11)。 @@ -103,18 +113,36 @@ export function PermissionChip({ p }: { p: Permission }) { ); } -export function PermissionChips({ permissions }: { permissions: readonly Permission[] }) { +/** + * 一排权限标签。`previous` 给了的话,这一版**新增**的那几项后面跟一个「新增」(编辑器里改了 + * 代码、多要了一项权限时,保存之前就看得见) + */ +export function PermissionChips({ + permissions, + previous, +}: { + permissions: readonly Permission[]; + previous?: readonly Permission[]; +}) { + const t = useText(pluginLabelsText); return ( {ordered(permissions).map((p) => ( - + + + {previous && !previous.includes(p) && ( + + {t.added} + + )} + ))} ); } /** - * 申请的每一项权限:能做什么,和要留意的后果。安装、更换代码、确认文件变更时给人看。 + * 申请的每一项权限:能做什么,和要留意的后果。安装、确认文件变更时给人看。 * * `previous`:原来那一版申请的。这一版**新增**的标出来,不再申请的列在最后、灰着 —— * 一次文件变更里多要了一项权限,正是确认之前最该看见的事。 @@ -270,25 +298,34 @@ export function StatsCell({ stats }: { stats: PluginStats }) { ); } -/** 代码框:边框先画出来,编辑器加载完填进去,版面不跳 */ -export function CodeBox({ code, errorAt, maxHeight }: { code: string; errorAt?: number | null; maxHeight?: number }) { +/** 代码框(只读):边框先画出来,编辑器加载完填进去,版面不跳 */ +export function CodeBox({ code, error, maxHeight }: { code: string; error?: CodeError | null; maxHeight?: number }) { const t = useText(pluginPartsText); return (
- - - {t.loadingCode} -
- } - > - + }> + ); } +/** 编辑器还没加载完时那一块 */ +export function CodeLoading({ label }: { label: string }) { + return ( +
+ + {label} +
+ ); +} + +/** core 说的读不了的原因,换成代码框里标位置的那一份。没有行号的标不出来,是 `null` */ +export function codeErrorOf(e: PluginLoadError | null | undefined): CodeError | null { + if (!e || e.line == null) return null; + return { line: e.line, column: e.column ?? null, message: coreText(e.message) }; +} + /** * 两份文字的改动:行号、增删,改动前后各三行原样的,中间大段没动的收成一行。 * diff --git a/src/plugins/suggestions.ts b/src/plugins/suggestions.ts new file mode 100644 index 00000000..b6b217d3 --- /dev/null +++ b/src/plugins/suggestions.ts @@ -0,0 +1,26 @@ +import { useMemo } from "react"; +import { appLabel, KNOWN_APPS } from "@/labels"; +import { useClients } from "@/clients/data"; +import { useKnownModels } from "@/keys/data"; +import type { Overview } from "@/types"; +import type { ScopeSuggestions, Suggestion } from "./fields"; + +/** + * 适用范围输入时给的建议:已知的客户端(认得出名字的应用,和这台机器上找到的客户端)、 + * 网关知道的模型、配置里的上游。**只是建议**:名单里写什么都行,包括带 `*` 的通配。 + */ +export function useScopeSuggestions(ov: Overview): ScopeSuggestions { + const clients = useClients(); + const models = useKnownModels(); + return useMemo(() => { + // 客户端的 id → 产品名 + const names = new Map(KNOWN_APPS.map((id) => [id, appLabel(id)])); + for (const c of [...(clients.data?.clients ?? []), ...(clients.data?.manual ?? [])]) names.set(c.id, c.name); + const client = [...names.keys()] + .sort() + .map((id): Suggestion => ({ value: id, note: names.get(id) !== id ? names.get(id) : undefined })); + const model = (models.data ?? []).map((m): Suggestion => ({ value: m.id })); + const upstream = ov.providers.map((p): Suggestion => ({ value: p.name })); + return { clients: client, models: model, upstreams: upstream }; + }, [clients.data, models.data, ov.providers]); +} diff --git a/src/plugins/useEditing.ts b/src/plugins/useEditing.ts new file mode 100644 index 00000000..f4ea8437 --- /dev/null +++ b/src/plugins/useEditing.ts @@ -0,0 +1,278 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { call } from "@/control"; +import { errorText } from "@/i18n/core.i18n"; +import type { ManifestView, OnError, PluginInspection, PluginLoadError, PluginScope, SettingSpecView, SettingValue } from "@/types"; +import { localSchema } from "./defaults"; +import { draftOf, scopeOf, scopeProblem, settingsDraftOf, settingsOf, type ScopeDraft, type SettingsDraft } from "./fields"; +import type { ScopePart } from "./model"; + +/** + * 编辑器的表单:出错时、适用范围、设置项。**都写在插件的代码里**(manifest),表单只是它们 + * 的另一种样子:从代码读出来,改了写回代码里。 + */ +export interface Form { + onError: OnError; + scope: ScopeDraft; + settings: SettingsDraft; + /** 设置项:键、类型、标签(默认插件的标签按界面语言) */ + schema: SettingSpecView[]; +} + +export function formOf(id: string, m: ManifestView): Form { + const schema = localSchema(id, m.name, m.settings_schema); + return { onError: m.on_error, scope: draftOf(m.scope), settings: settingsDraftOf(schema), schema }; +} + +/** 交给 core 改写代码的值 */ +export interface Values { + on_error: OnError; + scope: PluginScope; + settings: Record; +} + +/** 表单里填得不对的一处:选了「指定」却没加名单的那一项,或者不是数的那一个设置 */ +export type Problem = { kind: "scope"; part: ScopePart } | { kind: "number"; label: string }; + +export function valuesOf(f: Form): { values: Values; problem: Problem | null } { + const s = settingsOf(f.schema, f.settings); + const part = scopeProblem(f.scope); + const problem: Problem | null = part ? { kind: "scope", part } : s.bad[0] ? { kind: "number", label: s.bad[0] } : null; + return { values: { on_error: f.onError, scope: scopeOf(f.scope), settings: s.values }, problem }; +} + +/** 代码停下来多久再交给 core 读一遍 */ +const CHECK_AFTER = 400; +/** 表单停下来多久再改写代码 */ +const REWRITE_AFTER = 250; + +/** 读到的 manifest 里换上表单的值(改写只动这几项,权限、名字照旧) */ +function withValues(m: ManifestView, v: Values): ManifestView { + return { + ...m, + on_error: v.on_error, + scope: v.scope, + settings_schema: m.settings_schema.map((s) => ({ ...s, value: v.settings[s.key] ?? s.value })), + }; +} + +/** + * 编辑器的两头:**代码是唯一的真相**,表单是从它读出来的。 + * + * - 改代码:停下 0.4 秒交给 core 读一遍(`PluginInspect`,不写任何东西)。读得了,表单换成 + * 代码里的值;读不了,记下第几行第几列、为什么,表单停在上一次读得了的样子、不能改。 + * - 改表单:停下 0.25 秒请 core 按表单的值改写代码里的 manifest(`PluginRewrite`,没有副作用), + * 代码换成改写之后的那一份。 + * - **晚到的回答不覆盖更新的改动**:读的时候代码又改了,那次的结果不要;读的时候表单又改了, + * 表单不跟着那次的结果变(改写会把表单的值写进去)。 + * - 表单改回了打开时的值、代码也没动过:代码回到打开时的那一份(不留一份只是换了排版的 + * 「改动」)。 + * + * `flush()` 把等着的那一次马上发出去,并等所有在路上的回答落地:切换标签、保存之前调用, + * 让两头一致。 + */ +export function useEditing(id: string, start: { source: string; inspection: PluginInspection }) { + const first = start.inspection.manifest; + const [source, setSourceState] = useState(start.source); + const [manifest, setManifestState] = useState(first); + const [error, setErrorState] = useState(start.inspection.error); + const [form, setFormState] = useState
(() => (first ? formOf(id, first) : null)); + const [checking, setChecking] = useState(false); + const [syncError, setSyncError] = useState(null); + + // 异步回来时要比的「现在」、保存时读的「现在」:改的那一刻同步更新,不等渲染 + const sourceNow = useRef(start.source); + const manifestNow = useRef(first); + const errorNow = useRef(start.inspection.error); + const formNow = useRef(form); + const formGen = useRef(0); + const codeTouched = useRef(false); + const pristine = useRef(form ? JSON.stringify(valuesOf(form).values) : null); + const timer = useRef<{ kind: "check" | "rewrite"; handle: ReturnType } | null>(null); + const checkSeq = useRef(0); + const rewriteSeq = useRef(0); + const inFlight = useRef(new Set>()); + const alive = useRef(true); + useEffect(() => { + // 开发模式下 effect 会先卸一次再装:装上时重新记成「在」 + alive.current = true; + return () => { + alive.current = false; + if (timer.current) clearTimeout(timer.current.handle); + }; + }, []); + + const setSource = useCallback((next: string) => { + sourceNow.current = next; + setSourceState(next); + }, []); + const setManifest = useCallback((next: ManifestView | null) => { + manifestNow.current = next; + setManifestState(next); + }, []); + const setError = useCallback((next: PluginLoadError | null) => { + errorNow.current = next; + setErrorState(next); + }, []); + + const track = useCallback((p: Promise) => { + inFlight.current.add(p); + void p.finally(() => inFlight.current.delete(p)); + }, []); + + /** 读一遍现在的代码 */ + const check = useCallback(() => { + const src = sourceNow.current; + const gen = formGen.current; + const seq = ++checkSeq.current; + setChecking(true); + track( + (async () => { + let r: PluginInspection; + try { + r = await call("PluginInspect", { source: src }); + } catch (e) { + if (alive.current && seq === checkSeq.current) setSyncError(errorText(e)); + return; + } finally { + if (alive.current && seq === checkSeq.current) setChecking(false); + } + if (!alive.current || seq !== checkSeq.current || src !== sourceNow.current) return; + setSyncError(null); + setError(r.error); + if (!r.manifest) return; + setManifest(r.manifest); + // 读的时候表单又改过:不跟着这次的结果变,改写会把表单的值写进代码 + if (formGen.current !== gen) return; + const next = formOf(id, r.manifest); + formNow.current = next; + setFormState(next); + })(), + ); + }, [id, track, setError, setManifest]); + + /** 按表单改写代码 */ + const rewrite = useCallback(() => { + const f = formNow.current; + if (!f) return; + const { values, problem } = valuesOf(f); + if (problem) return; + const src = sourceNow.current; + const seq = ++rewriteSeq.current; + track( + (async () => { + let next: string; + try { + next = (await call("PluginRewrite", { source: src, ...values })).source; + } catch (e) { + if (alive.current && seq === rewriteSeq.current) setSyncError(errorText(e)); + return; + } + if (!alive.current || seq !== rewriteSeq.current || src !== sourceNow.current) return; + setSyncError(null); + setSource(next); + const m = manifestNow.current; + setManifest(m ? withValues(m, values) : m); + })(), + ); + }, [setSource, setManifest, track]); + + /** 等着的那一次马上发 */ + const runPending = useCallback(() => { + const p = timer.current; + if (!p) return; + clearTimeout(p.handle); + timer.current = null; + if (p.kind === "check") check(); + else rewrite(); + }, [check, rewrite]); + + const schedule = useCallback( + (kind: "check" | "rewrite", ms: number) => { + // 等着的是另一头的:先发出去,两头的改动不搅在一起 + if (timer.current && timer.current.kind !== kind) runPending(); + if (timer.current) clearTimeout(timer.current.handle); + timer.current = { + kind, + handle: setTimeout(() => { + timer.current = null; + if (kind === "check") check(); + else rewrite(); + }, ms), + }; + }, + [check, rewrite, runPending], + ); + + /** 代码改了(打字、撤销)。`now`:马上读(从文件导入的) */ + const editCode = useCallback( + (next: string, now = false) => { + if (next === sourceNow.current) return; + codeTouched.current = true; + setSource(next); + if (now) { + if (timer.current?.kind === "check") { + clearTimeout(timer.current.handle); + timer.current = null; + } + runPending(); + check(); + } else schedule("check", CHECK_AFTER); + }, + [check, runPending, schedule, setSource], + ); + + /** 表单改了 */ + const editForm = useCallback( + (next: Form) => { + formNow.current = next; + formGen.current++; + setFormState(next); + const { values, problem } = valuesOf(next); + if (problem) { + // 填得不对的不往代码里写:等改对了再写 + if (timer.current?.kind === "rewrite") { + clearTimeout(timer.current.handle); + timer.current = null; + } + return; + } + if (!codeTouched.current && JSON.stringify(values) === pristine.current) { + // 改回了打开时的样子:代码也回到打开时的那一份,在路上的改写作废 + if (timer.current?.kind === "rewrite") { + clearTimeout(timer.current.handle); + timer.current = null; + } + rewriteSeq.current++; + setSource(start.source); + const m = manifestNow.current; + setManifest(m ? withValues(m, values) : m); + return; + } + schedule("rewrite", REWRITE_AFTER); + }, + [schedule, setSource, setManifest, start.source], + ); + + /** 两头一致了再往下走:等着的马上发,在路上的等它落地 */ + const flush = useCallback(async () => { + runPending(); + while (inFlight.current.size > 0) await Promise.all([...inFlight.current]); + }, [runPending]); + + return { + source, + editCode, + form, + editForm, + /** 最近一次读得了的 manifest(代码读不了时是上一次的) */ + manifest, + /** 现在的代码读不了的原因 */ + error, + checking, + /** 读或改写这一步本身没成(连不上 core 之类) */ + syncError, + flush, + /** 「现在」的代码、manifest 和读不了的原因:保存时用(`flush` 之后,不等渲染) */ + now: () => ({ source: sourceNow.current, manifest: manifestNow.current, error: errorNow.current }), + }; +} diff --git a/src/plugins/write.i18n.ts b/src/plugins/write.i18n.ts new file mode 100644 index 00000000..e6ba8819 --- /dev/null +++ b/src/plugins/write.i18n.ts @@ -0,0 +1,12 @@ +import { messages } from "@/i18n"; + +export const writeText = messages( + { + /** 确认过的代码不在了:拨不了开关(开关要和代码一起交) */ + approvedMissing: "此插件已确认的代码不存在,无法切换开关。请先在此插件的代码页中保存代码。", + }, + { + approvedMissing: + "The approved code of this plugin is missing, so it cannot be switched on or off. Save the code in the plugin's Code tab first.", + }, +); diff --git a/src/plugins/write.test.ts b/src/plugins/write.test.ts new file mode 100644 index 00000000..aa6af5cd --- /dev/null +++ b/src/plugins/write.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ManifestView, PluginView } from "@/types"; + +/* + 插件的写入走哪条路:网页那条(`CreatePlugin`、`SavePlugin`、`ApprovePluginFile`,经过 `call`), + 还是请 Rust 弹系统的确认框(`plugin_install_confirmed` 等,经过 `invoke`)。两样都换成记录 + 调用的假的。 +*/ +const invoke = vi.fn(); +const call = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ invoke: (...a: unknown[]) => invoke(...a) })); +vi.mock("@/control", () => ({ call: (...a: unknown[]) => call(...a) })); + +const { approvePlugin, installPlugin, savePlugin, setEnabled } = await import("./write"); + +function plugin(over: Partial = {}): PluginView { + return { + id: "p", + name: "p", + description: null, + enabled: false, + on_error: "reject", + permissions: ["system"], + requests: ["conversation"], + scope: { clients: [], models: [], upstreams: [] }, + reply_mode: "block", + settings_schema: [], + sha256: "aa", + status: { kind: "disabled" }, + stats: { calls: 0, changed: 0, rejected: 0, errors: 0, avg_cpu_us: 0, last_error: null }, + ...over, + }; +} + +function manifest(over: Partial = {}): ManifestView { + return { + name: "p", + description: null, + permissions: ["system"], + requests: ["conversation"], + scope: { clients: [], models: [], upstreams: [] }, + reply_mode: "block", + on_error: "reject", + settings_schema: [], + hooks: { request: true, reply_text: false, tool_call: false }, + ...over, + }; +} + +const NEEDS = { code: "control.plugin.needs_confirmation", args: { plugin: "p" }, text: "…" }; +const TOOLS = ["messages", "reply_tool_calls"] as const; + +beforeEach(() => { + invoke.mockReset(); + call.mockReset(); +}); + +describe("装插件", () => { + const req = { source: "src", id: "p", enabled: true, base_version: "v1" }; + + it("改得了工具调用的:直接请 Rust,一个确认框", async () => { + invoke.mockResolvedValue({ kind: "done", version: "v2" }); + expect(await installPlugin(req, manifest({ permissions: [...TOOLS] }))).toEqual({ kind: "done", version: "v2" }); + expect(invoke).toHaveBeenCalledWith("plugin_install_confirmed", { req }); + expect(call).not.toHaveBeenCalled(); + }); + + it("别的走网页那条,不问", async () => { + call.mockResolvedValue({ version: "v2" }); + expect(await installPlugin(req, manifest())).toEqual({ kind: "done", version: "v2" }); + expect(call).toHaveBeenCalledWith("CreatePlugin", req); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("core 说要点头的,同一份请求再请 Rust", async () => { + call.mockRejectedValue(NEEDS); + invoke.mockResolvedValue({ kind: "cancelled" }); + expect(await installPlugin(req, manifest())).toEqual({ kind: "cancelled" }); + expect(invoke).toHaveBeenCalledWith("plugin_install_confirmed", { req }); + }); +}); + +describe("保存插件", () => { + const req = { id: "p", source: "src", enabled: true, base_version: "v1" }; + + it("界面确定要点头的:直接请 Rust", async () => { + invoke.mockResolvedValue({ kind: "done", version: "v3" }); + expect(await savePlugin(req, true)).toEqual({ kind: "done", version: "v3" }); + expect(invoke).toHaveBeenCalledWith("plugin_save_confirmed", { req }); + expect(call).not.toHaveBeenCalled(); + }); + + it("别的走网页那条:代码和开关在请求里,ID 在路径上", async () => { + call.mockResolvedValue({ version: "v2" }); + expect(await savePlugin(req, false)).toEqual({ kind: "done", version: "v2" }); + expect(call).toHaveBeenCalledWith("SavePlugin", { source: "src", enabled: true, base_version: "v1" }, "p"); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("界面判断不了的(代码在 manifest 以外改了):core 说要点头再请 Rust,不必再按一次", async () => { + call.mockRejectedValue(NEEDS); + invoke.mockResolvedValue({ kind: "done", version: "v4" }); + expect(await savePlugin(req, false)).toEqual({ kind: "done", version: "v4" }); + expect(invoke).toHaveBeenCalledWith("plugin_save_confirmed", { req }); + }); + + it("别的失败照样抛出", async () => { + const stale = { code: "control.config_stale", args: {}, text: "stale" }; + call.mockRejectedValue(stale); + await expect(savePlugin(req, false)).rejects.toBe(stale); + expect(invoke).not.toHaveBeenCalled(); + }); +}); + +describe("批准改过的文件", () => { + it("原来的或改过的文件改得了工具调用:直接请 Rust", async () => { + invoke.mockResolvedValue({ kind: "done", version: "v2" }); + await approvePlugin(plugin({ permissions: [...TOOLS] }), "bb", manifest(), "v1"); + await approvePlugin(plugin(), "bb", manifest({ permissions: [...TOOLS] }), "v1"); + // 改过的文件读不出来:按改得了算 + await approvePlugin(plugin(), "bb", null, "v1"); + expect(invoke).toHaveBeenCalledTimes(3); + expect(invoke).toHaveBeenCalledWith("plugin_approve_confirmed", { req: { id: "p", base_version: "v1" } }); + expect(call).not.toHaveBeenCalled(); + }); + + it("别的走网页那条,带看过的那一份的哈希", async () => { + call.mockResolvedValue({ version: "v2" }); + expect(await approvePlugin(plugin(), "bb", manifest(), "v1")).toEqual({ kind: "done", version: "v2" }); + expect(call).toHaveBeenCalledWith("ApprovePluginFile", { sha256: "bb", base_version: "v1" }, "p"); + }); +}); + +describe("拨开关", () => { + const source = { approved: "approved", approved_sha256: "aa", current: "approved", current_sha256: "aa" }; + + it("开关和确认过的代码一起交;打开改得了工具调用的直接请 Rust", async () => { + call.mockResolvedValueOnce(source); + invoke.mockResolvedValue({ kind: "done", version: "v2" }); + await setEnabled(plugin({ permissions: [...TOOLS] }), true, false, "v1"); + expect(call).toHaveBeenCalledWith("PluginSourceDiff", null, "p"); + expect(invoke).toHaveBeenCalledWith("plugin_save_confirmed", { + req: { id: "p", source: "approved", enabled: true, base_version: "v1" }, + }); + }); + + it("停用、打开别的插件:网页那条", async () => { + call.mockResolvedValueOnce(source).mockResolvedValueOnce({ version: "v2" }); + await setEnabled(plugin({ permissions: [...TOOLS], enabled: true }), false, true, "v1"); + expect(call).toHaveBeenLastCalledWith("SavePlugin", { source: "approved", enabled: false, base_version: "v1" }, "p"); + call.mockResolvedValueOnce(source).mockResolvedValueOnce({ version: "v3" }); + await setEnabled(plugin(), true, false, "v2"); + expect(call).toHaveBeenLastCalledWith("SavePlugin", { source: "approved", enabled: true, base_version: "v2" }, "p"); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("底稿没了:磁盘上那一份的哈希对得上也行,都没有就报错", async () => { + call.mockResolvedValueOnce({ ...source, approved: "" }).mockResolvedValueOnce({ version: "v2" }); + await setEnabled(plugin(), true, false, "v1"); + expect(call).toHaveBeenLastCalledWith("SavePlugin", { source: "approved", enabled: true, base_version: "v1" }, "p"); + call.mockResolvedValueOnce({ ...source, approved: "", current_sha256: "cc" }); + await expect(setEnabled(plugin(), true, false, "v1")).rejects.toMatchObject({ code: "" }); + }); +}); diff --git a/src/plugins/write.ts b/src/plugins/write.ts new file mode 100644 index 00000000..c76e0b1f --- /dev/null +++ b/src/plugins/write.ts @@ -0,0 +1,114 @@ +/** + * 插件的写入:装、存(代码和开关)、批准磁盘上改过的文件。 + * + * **只有一种情形要在系统的确认框里点头**(约定附录 4 §3):插件改得了回答里的工具调用 + * (原来的或者新的代码里),而这次要装上它、打开它、改它的代码,或者批准它磁盘上改过的 + * 文件。别的写入(只改了出错时、范围、设置的值,停用,不碰工具调用的插件)都不问。 + * + * 要点头的那几个端点**不在网页的白名单里**(`src/control.ts`):网页只能请 Rust 去做 + * (`plugin_install_confirmed` 等),Rust 自己把代码再读一遍、在系统的确认框里写明它是谁、 + * 能做什么,点了头才写。用户在那个框里点了取消不是失败:回执是 `cancelled`,界面照原样。 + * + * 走哪条路:**界面按手上已有的权限先判断**,确定要点头的直接请 Rust;判断不了的照常走网页 + * 那条,core 说要点头(403 `control.plugin.needs_confirmation`)再请 Rust —— 同一份改动, + * 用户不必再按一次。 + */ +import { invoke } from "@tauri-apps/api/core"; +import { call } from "@/control"; +import { textOf } from "@/i18n"; +import type { + ManifestView, + PluginApproveRequest, + PluginInstallRequest, + PluginSaveRequest, + PluginView, + PluginWrite, +} from "@/types"; +import { holdsToolCalls, saveAsks } from "./model"; +import { writeText } from "./write.i18n"; + +/** + * 一次写入,排进插件页的写入队列(见 `PluginsPage`):`run` 拿到轮到它时的版本号。`done` 是 + * 写成了,`cancelled` 是用户在系统的确认框里点了取消 —— 什么都没写,不是失败 + */ +export type NativeWrite = (run: (base: string) => Promise) => Promise<"done" | "cancelled">; + +/** core 说这次要在系统的确认框里点头 */ +const NEEDS_CONFIRMATION = "control.plugin.needs_confirmation"; + +export function needsConfirmation(e: unknown): boolean { + return typeof e === "object" && e !== null && (e as { code?: unknown }).code === NEEDS_CONFIRMATION; +} + +/** Rust 的三个命令:再读一遍、弹系统的确认框、点了头才发 `…Confirmed` */ +const confirmed = { + install: (req: PluginInstallRequest) => invoke("plugin_install_confirmed", { req }), + save: (req: PluginSaveRequest) => invoke("plugin_save_confirmed", { req }), + approve: (req: PluginApproveRequest) => invoke("plugin_approve_confirmed", { req }), +}; + +/** 先走网页那条;core 说要点头就请 Rust 走一遍同一份改动 */ +async function plainThenNative( + plain: () => Promise<{ version: string }>, + native: () => Promise, +): Promise { + try { + const w = await plain(); + return { kind: "done", version: w.version }; + } catch (e) { + if (needsConfirmation(e)) return native(); + throw e; + } +} + +/** 装一个插件。`manifest` 是审核时读出来的那一份(判断用,Rust 不信它,自己再读) */ +export function installPlugin(req: PluginInstallRequest, manifest: ManifestView): Promise { + const native = () => confirmed.install(req); + if (holdsToolCalls(manifest.permissions)) return native(); + return plainThenNative(() => call("CreatePlugin", req), native); +} + +/** + * 保存一个插件:整份代码和开关。`asks`:界面已经确定要点头(`saveAsks`) + */ +export function savePlugin(req: PluginSaveRequest, asks: boolean): Promise { + const native = () => confirmed.save(req); + if (asks) return native(); + const { id, ...body } = req; + return plainThenNative(() => call("SavePlugin", body, id), native); +} + +/** + * 批准磁盘上改过的文件。`sha256` 是给人看过的那一份的哈希;`next` 是它读出来的 manifest + * (读不出来是 `null`,按改得了工具调用算) + */ +export function approvePlugin( + p: PluginView, + sha256: string, + next: ManifestView | null, + base: string, +): Promise { + const native = () => confirmed.approve({ id: p.id, base_version: base }); + if (holdsToolCalls(p.permissions) || holdsToolCalls(next?.permissions)) return native(); + return plainThenNative(() => call("ApprovePluginFile", { sha256, base_version: base }, p.id), native); +} + +/** + * 确认过的那一份代码:开关要和代码一起交(`SavePlugin`)。底稿没了的,磁盘上那一份的哈希 + * 和确认过的一样也行;两样都没有就写不了 + */ +export async function approvedSource(id: string): Promise { + const s = await call("PluginSourceDiff", null, id); + if (s.approved !== "") return s.approved; + if (s.current != null && s.current_sha256 === s.approved_sha256) return s.current; + throw { code: "", args: {}, text: textOf(writeText).approvedMissing }; +} + +/** + * 拨开关:确认过的那一份代码原样交回去,只改开关。`from` 是拨之前的样子(撤销时反过来) + */ +export async function setEnabled(p: PluginView, on: boolean, from: boolean, base: string): Promise { + const source = await approvedSource(p.id); + const asks = saveAsks({ old: p.permissions, next: p.permissions, turningOn: on && !from, codeChanged: false }); + return savePlugin({ id: p.id, source, enabled: on, base_version: base }, asks); +} diff --git a/src/types.ts b/src/types.ts index cfa75ce5..995eef76 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,21 @@ import type { LocalEvent } from "./generated/lite-api"; export type * from "./generated/tw-api"; export type * from "./generated/lite-api"; +// 临时:插件的文件即真相(约定附录 4)之后的形状,盖住生成的同名类型(显式转出优先于上面的 +// `export type *`)。core v0.59.0 发版、`tw-api.ts` 重新生成之后整段删掉,步骤见 +// `./plugins/api.provisional.ts` +export type { + Endpoints, + ManifestView, + PluginCreate, + PluginInspection, + PluginRewrite, + PluginRewritten, + PluginSave, + PluginView, + SettingSpecView, +} from "./plugins/api.provisional"; + /** core 的事件流上的一条 */ export type CoreEvent = Event; diff --git a/src/ui/combobox.tsx b/src/ui/combobox.tsx index a461ad2f..12640fed 100644 --- a/src/ui/combobox.tsx +++ b/src/ui/combobox.tsx @@ -6,6 +6,7 @@ import { import { cn } from "@/lib/utils" import { Button } from "@/ui/button" +import { Input } from "@/ui/input" import { InputGroup, InputGroupAddon, @@ -98,6 +99,23 @@ function ComboboxInput({ ) } +/** + * 不带外框的输入框:放进一张自己有边框的列表,当最后一行(插件适用范围的名单)。外框、 + * 焦点环都由那张列表画 + */ +function ComboboxBareInput({ className, ...props }: ComboboxPrimitive.Input.Props) { + return ( + } + className={cn( + "h-7 flex-1 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0 dark:bg-transparent", + className + )} + {...props} + /> + ) +} + function ComboboxContent({ className, side = "bottom", @@ -300,6 +318,7 @@ function useComboboxAnchor() { export { Autocomplete, Combobox, + ComboboxBareInput, ComboboxInput, ComboboxContent, ComboboxList, From 35b7441096a50b478f9916608e8fa7b9811cb5db Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:13:38 +0800 Subject: [PATCH 2/8] plugins: drag to reorder The reorder dialog takes drag and drop as well as the up and down buttons. A row is dragged by its grip and name (the buttons stay outside the drag area), the dragged row dims and an insertion line shows where it lands. It reuses the pointer-event hook of the route and group dialogs (routing/useReorder), so there is no new dependency and Tauri's file-drop handling does not interfere. The buttons stay for the keyboard. When a move takes a plugin to the top or the bottom, the button that just became disabled hands focus to the other one, so the keyboard can keep going. Saving still writes one config version (ReorderPlugins). Co-Authored-By: Claude Opus 5.5 --- src/plugins/ListDialogs.tsx | 78 +++++++++++++++++++++++++------- src/plugins/PluginsPage.i18n.tsx | 7 ++- src/plugins/model.test.ts | 9 ++++ 3 files changed, 75 insertions(+), 19 deletions(-) diff --git a/src/plugins/ListDialogs.tsx b/src/plugins/ListDialogs.tsx index aa3b15b9..74e06c62 100644 --- a/src/plugins/ListDialogs.tsx +++ b/src/plugins/ListDialogs.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { ArrowDownIcon, ArrowUpIcon, GripVerticalIcon } from "lucide-react"; import { AlertDialog, AlertDialogCancel, @@ -12,10 +12,12 @@ import { import { Banner } from "@/ui/banner"; import { Button } from "@/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/ui/dialog"; +import { cn } from "@/lib/utils"; import { useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; import { errorText } from "@/i18n/core.i18n"; import { ConfirmAction, focusSelf } from "@/keys/parts"; +import { useReorder } from "@/routing/useReorder"; import { DialogError } from "@/upstreams/parts"; import type { PluginView } from "@/types"; import { pluginName } from "./defaults"; @@ -78,8 +80,9 @@ export function DeleteDialog({ } /** - * 调整运行顺序。**顺序有意义**:前一个插件改过的内容交给后一个。上下移好了一次保存,写成 - * 一个配置版本(`ReorderPlugins`),不是每挪一下写一次。 + * 调整运行顺序。**顺序有意义**:前一个插件改过的内容交给后一个。拖动一行(按住左边的把手, + * 或者整行的空白处)放到别处,也可以用每行右边的上移、下移(键盘用这两个)。排好了一次保存, + * 写成一个配置版本(`ReorderPlugins`),不是每挪一下写一次。 */ export function ReorderDialog({ list, @@ -97,15 +100,27 @@ export function ReorderDialog({ 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 reorder = useReorder((from, to) => setOrder((o) => moved(o, from, to))); + /** 按钮挪到头之后自己失效了:焦点交给同一行的另一个按钮,键盘接着能按 */ + const [refocus, setRefocus] = useState<{ id: string; dir: "up" | "down" } | null>(null); + const list$ = useRef(null); + useEffect(() => { + if (!refocus) return; + list$.current + ?.querySelector(`[data-row="${CSS.escape(refocus.id)}"] [data-move="${refocus.dir}"]`) + ?.focus(); + setRefocus(null); + }, [refocus]); - 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; - }); + const move = (i: number, d: -1 | 1) => { + const j = i + d; + if (j < 0 || j >= order.length) return; + const id = order[i]!; + setOrder((o) => moved(o, i, j)); + // 挪到了头(或尾):这个方向的按钮失效,焦点换到另一个 + if (j === 0) setRefocus({ id, dir: "down" }); + else if (j === order.length - 1) setRefocus({ id, dir: "up" }); + }; async function save() { setSaving(true); @@ -125,19 +140,39 @@ export function ReorderDialog({ {t.reorderTitle} {t.reorderDescription} -
    +
      {order.map((id, i) => { const p = byId.get(id); if (!p) return null; + const mark = reorder.marker(i, order.length); return ( -
    1. - {i + 1} - - +
    2. + {/* 把手和名字这一段都能拖;右边的按钮不在里面,点它们不会开始拖动 */} + + + {i + 1} + + +
); } + +/** 把第 `from` 个挪到第 `to` 个的位置(拿走之后的下标),别的顺次让开 */ +export function moved(list: readonly T[], from: number, to: number): T[] { + const next = [...list]; + const [x] = next.splice(from, 1); + next.splice(to, 0, x!); + return next; +} diff --git a/src/plugins/PluginsPage.i18n.tsx b/src/plugins/PluginsPage.i18n.tsx index 4e6659d3..69927950 100644 --- a/src/plugins/PluginsPage.i18n.tsx +++ b/src/plugins/PluginsPage.i18n.tsx @@ -53,9 +53,10 @@ export const pluginsPageText = messages( deleteDescription: "插件和它的设置将从配置中删除,此后不再运行。", reorderTitle: "调整顺序", - reorderDescription: "插件按此顺序依次运行,后一个插件处理的是前一个改写后的内容。", + reorderDescription: "插件按此顺序依次运行,后一个插件处理的是前一个改写后的内容。拖动或用箭头调整位置。", moveUp: (name: string) => `上移「${name}」`, moveDown: (name: string) => `下移「${name}」`, + dragPlugin: (name: string) => `拖动调整「${name}」的位置`, }, { pluginsUnit: (n: number) => (n === 1 ? "plugin" : "plugins"), @@ -105,9 +106,11 @@ export const pluginsPageText = messages( 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.", + reorderDescription: + "Plugins run in this order. Each plugin works on what the one before it produced. Drag a plugin or use the arrows to move it.", moveUp: (name: string) => `Move “${name}” up`, moveDown: (name: string) => `Move “${name}” down`, + dragPlugin: (name: string) => `Drag to reorder “${name}”`, }, ); diff --git a/src/plugins/model.test.ts b/src/plugins/model.test.ts index a214e43d..409f5120 100644 --- a/src/plugins/model.test.ts +++ b/src/plugins/model.test.ts @@ -20,6 +20,7 @@ import { touchesRequests, } from "./model"; import { draftOf, scopeOf, scopeProblem, settingsDraftOf, settingsOf, suggestionMatches } from "./fields"; +import { moved } from "./ListDialogs"; describe("通配", () => { it("* 是任意一段,可以为空", () => { @@ -278,3 +279,11 @@ describe("表单", () => { expect(suggestionMatches("", "anything")).toBe(true); }); }); + +describe("调整顺序", () => { + it("挪到拿走之后的那个位置,别的顺次让开", () => { + expect(moved(["a", "b", "c", "d"], 0, 2)).toEqual(["b", "c", "a", "d"]); + expect(moved(["a", "b", "c", "d"], 3, 0)).toEqual(["d", "a", "b", "c"]); + expect(moved(["a", "b"], 1, 0)).toEqual(["b", "a"]); + }); +}); From 6beb5df31d8feb28720be854ac350c24d4c16c90 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:41:49 +0800 Subject: [PATCH 3/8] plugins: build against core's final plugin contract (protocol 35) Swap the provisional addendum-4 types for the ones core main generates (550e6fd, future v0.59.0, CONTROL_API_VERSION 35). - src/generated/tw-api.ts is regenerated. src/plugins/api.provisional.ts and src-tauri/src/plugins/wire.rs are gone: the webview endpoint list and the three native confirmations use tw_api::ep and tw_api's PluginView, PluginInspection, ManifestView, SettingSpecView, PluginCreate, PluginSave and PluginRewriteRequest directly. - Differences from the provisional guesses: PluginRewrite answers PluginSource (there is no PluginRewritten), its request type is PluginRewriteRequest, and its settings carry only the keys to change. The editor and the native save dialog keep sending every declared setting, which core accepts. - core.zh.json: gw.plugin.manifest_not_data and gw.plugin.manifest_not_data_at added, config.plugin.blank_pattern and config.plugin.setting_type removed, control.plugin.needs_confirmation rewritten for core's new sentence (install, turn on, change the code or approve a change to the file). - The control-plane test for tool-call plugins runs again. The tw-* pin stays at v0.58.0 until core tags v0.59.0, so until then this branch builds only with a local [patch] of the core crates. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/call.rs | 32 ++--- src-tauri/src/plugins/mod.rs | 66 +++++----- src-tauri/src/plugins/wire.rs | 213 ------------------------------- src-tauri/tests/control_plane.rs | 32 ++--- src/control.ts | 6 +- src/generated/tw-api.ts | 121 +++++++++++------- src/i18n/core.zh.json | 6 +- src/plugins/api.provisional.ts | 124 ------------------ src/types.ts | 15 --- 9 files changed, 136 insertions(+), 479 deletions(-) delete mode 100644 src-tauri/src/plugins/wire.rs delete mode 100644 src/plugins/api.provisional.ts diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index b73fac8b..f2216add 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -30,10 +30,10 @@ use crate::control::ControlClient; use crate::error::{CmdError, Out}; macro_rules! webview_endpoints { - (core: [$($name:ident),* $(,)?], provisional: [$($p:ident),* $(,)?] $(,)?) => { + ($($name:ident),* $(,)?) => { /// 界面能直接调的端点,按名字。和 `src/control.ts` 的 /// `WEBVIEW_ENDPOINTS` 是同一份(测试核对)。 - pub const ALLOWED: &[&str] = &[$(stringify!($name),)* $(stringify!($p),)*]; + pub const ALLOWED: &[&str] = &[$(stringify!($name)),*]; /// 调一个控制面端点。`params` 按顺序填路径参数,`req` 是请求(没有就是 /// `null`)。 @@ -47,7 +47,6 @@ 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}`." ))), @@ -56,8 +55,7 @@ macro_rules! webview_endpoints { }; } -webview_endpoints! { - core: [ +webview_endpoints![ // 进程与概览 Interfaces, Overview, @@ -141,23 +139,18 @@ webview_endpoints! { UseChatgptReset, ZaiLoginStatus, // 插件。要点头的三步(`…Confirmed`)走 Rust 这边的命令,见下面的测试 + Plugins, + PluginInspect, + PluginRewrite, + CreatePlugin, + SavePlugin, ApprovePluginFile, PluginSourceDiff, DeletePlugin, ReorderPlugins, TrialPlugin, PluginLogs, - ], - // PROVISIONAL:core v0.59.0 才有(或者形状变了)的插件端点,描述在 `plugins/wire.rs`, - // 接上正式版时并进上面那一组 - provisional: [ - Plugins, - PluginInspect, - PluginRewrite, - CreatePlugin, - SavePlugin, - ], -} +]; /// 请求按这个端点的类型读一遍再发:**界面发来的形状不对,在这里就停下**, /// 不把一个 core 读不了的请求送过去。 @@ -219,12 +212,9 @@ mod tests { ] { assert!(!ALLOWED.contains(&name), "{name}"); assert!(!ts.contains(&format!("\"{name}\"")), "{name}"); - // 它确实是一个端点(不是拼错了名字才「不在清单里」)。PROVISIONAL:接上 core - // v0.59.0 之后改成查 `ep::ALL` + // 它确实是 core 的一个端点(不是拼错了名字才「不在清单里」) assert!( - crate::plugins::wire::ALL - .iter() - .any(|(n, _, p)| *n == name && *p == path), + ep::ALL.iter().any(|e| e.name == name && e.path == path), "{name}" ); } diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs index 5b26510c..65b821d7 100644 --- a/src-tauri/src/plugins/mod.rs +++ b/src-tauri/src/plugins/mod.rs @@ -15,12 +15,10 @@ //! 3. 用户点了确认,才把**给人看过的那同一份**交给 core。 //! //! 用户在对话框里取消不是失败:回执是 `cancelled`,网页那边什么都不用报,界面照原样。 -//! -//! 端点和类型暂时是手写的(`wire`,PROVISIONAL):钉着的 core 还没有它们。 use std::collections::BTreeMap; -use tw_api::{Permission, SettingValue, ep}; +use tw_api::{ManifestView, Permission, PluginInspection, PluginView, SettingValue, ep}; use crate::AppState; use crate::control::ControlClient; @@ -29,10 +27,8 @@ use crate::wire::{PluginApproveRequest, PluginInstallRequest, PluginSaveRequest, mod confirm; pub mod defaults; -pub mod wire; pub mod words; -use wire::{Inspection, Installed, Manifest}; use words::{Change, ScopePart}; /// 插件文件的上限,和 core 一样 @@ -59,9 +55,9 @@ pub async fn plugin_install_confirmed( return Ok(PluginWrite::Cancelled); } let w = c - .call::( + .call::( &[], - &wire::PluginCreate { + &tw_api::PluginCreate { source: req.source, id: req.id, enabled: req.enabled, @@ -132,9 +128,9 @@ pub async fn plugin_save_confirmed( return Ok(PluginWrite::Cancelled); } let w = c - .call::( + .call::( &[&req.id], - &wire::PluginSave { + &tw_api::PluginSave { source: req.source, enabled: req.enabled, base_version: req.base_version, @@ -184,7 +180,7 @@ pub async fn plugin_approve_confirmed( return Ok(PluginWrite::Cancelled); } let w = c - .call::( + .call::( &[&req.id], &tw_api::PluginApprove { sha256: sha, @@ -199,7 +195,7 @@ pub async fn plugin_approve_confirmed( /// 两份 manifest 的数据改了什么,按确认框里的先后:设置(按声明的顺序)、适用范围(每一项 /// 单独说)、出错时怎么办。设置的标签按新代码里写的(默认插件按界面语言);范围不看顺序、 /// 空白和重复 -fn data_changes(id: &str, old: &Manifest, new: &Manifest) -> Vec { +fn data_changes(id: &str, old: &ManifestView, new: &ManifestView) -> Vec { let mut out = Vec::new(); let was: BTreeMap<&str, &SettingValue> = old .settings_schema @@ -248,7 +244,7 @@ fn norm(list: &[String]) -> Vec { /// core 读过一遍的代码:manifest 和 SHA-256(都是 core 读出来的,不是网页说的) struct Read { - manifest: Manifest, + manifest: ManifestView, sha256: String, } @@ -261,8 +257,8 @@ async fn inspect(c: &ControlClient, source: &str) -> Out { "The plugin file is over the 1 MB limit." ))); } - let raw = c - .call::( + let i: PluginInspection = c + .call::( &[], &tw_api::PluginSource { source: source.to_string(), @@ -270,7 +266,6 @@ async fn inspect(c: &ControlClient, source: &str) -> Out { ) .await .map_err(text)?; - let i: Inspection = serde_json::from_value(raw).map_err(|e| CmdError::plain(e.to_string()))?; if let Some(e) = i.error { // core 的那一句带着码(语法错还带行列),界面照码说 return Err(e.message.into()); @@ -288,7 +283,7 @@ async fn inspect(c: &ControlClient, source: &str) -> Out { } /// 确认过的那一份代码:底稿,或者没被改过的插件文件(哈希和配置里确认的一样)。都没有是 `None` -async fn approved_code(c: &ControlClient, p: &Installed) -> Option { +async fn approved_code(c: &ControlClient, p: &PluginView) -> Option { let src = c.call::(&[&p.id], &()).await.ok()?; if !src.approved.is_empty() && src.approved_sha256 == p.sha256 { Some(src.approved) @@ -299,9 +294,9 @@ async fn approved_code(c: &ControlClient, p: &Installed) -> Option { } } -/// 按这份 manifest 里的值改写一段代码。没成是 `None` -async fn rewrite(c: &ControlClient, source: &str, m: &Manifest) -> Option { - let req = wire::PluginRewriteRequest { +/// 按这份 manifest 里的值改写一段代码(每一个设置项都给)。没成是 `None` +async fn rewrite(c: &ControlClient, source: &str, m: &ManifestView) -> Option { + let req = tw_api::PluginRewriteRequest { source: source.to_string(), on_error: m.on_error, scope: m.scope.clone(), @@ -311,17 +306,15 @@ async fn rewrite(c: &ControlClient, source: &str, m: &Manifest) -> Option(&[], &req) + c.call::(&[], &req) .await .ok() .map(|r| r.source) } /// 装着的那一个插件现在的样子(core 说的) -async fn installed(c: &ControlClient, id: &str) -> Out { - let raw = c.call::(&[], &()).await.map_err(text)?; - let all: Vec = - serde_json::from_value(raw).map_err(|e| CmdError::plain(e.to_string()))?; +async fn installed(c: &ControlClient, id: &str) -> Out { + let all = c.call::(&[], &()).await.map_err(text)?; all.into_iter().find(|p| p.id == id).ok_or_else(|| { CmdError::plain(tr!( format!("插件「{id}」不存在,可能已被删除。"), @@ -331,13 +324,13 @@ async fn installed(c: &ControlClient, id: &str) -> Out { } /// 装着的插件在确认框里叫什么:默认插件按界面语言说(和插件页上一样),别的照它自己写的 -fn shown_name(p: &Installed) -> String { +fn shown_name(p: &PluginView) -> String { defaults::name(Some(&p.id), &p.name) } /// 装着的那一版申请的权限。读不出来(core 那边没有它的 manifest)是 `None`:不知道哪一项 /// 是新的,就不标「新增」 -fn known(p: &Installed) -> Option<&[Permission]> { +fn known(p: &PluginView) -> Option<&[Permission]> { (!p.permissions.is_empty()).then_some(p.permissions.as_slice()) } @@ -361,8 +354,9 @@ fn changed_meanwhile() -> CmdError { #[cfg(test)] mod tests { use super::*; - use tw_api::{OnError, PluginScope, RequestKind, SettingKind}; - use wire::SettingSpec; + use tw_api::{ + OnError, PluginHooks, PluginScope, ReplyMode, RequestKind, SettingKind, SettingSpecView, + }; #[test] fn the_receipt_says_done_or_cancelled() { @@ -395,14 +389,20 @@ mod tests { ); } - fn manifest() -> Manifest { - Manifest { + fn manifest() -> ManifestView { + ManifestView { name: "Answer in a chosen language".into(), + description: None, permissions: vec![Permission::System], requests: vec![RequestKind::Conversation], scope: PluginScope::default(), on_error: OnError::Reject, - settings_schema: vec![SettingSpec { + reply_mode: ReplyMode::Block, + hooks: PluginHooks { + request: true, + ..PluginHooks::default() + }, + settings_schema: vec![SettingSpecView { key: "language".into(), kind: SettingKind::String, label: "Answer language".into(), @@ -416,7 +416,7 @@ mod tests { #[test] fn data_changes_name_each_setting_scope_part_and_on_error() { crate::i18n::with_lang(crate::i18n::Lang::Zh, || { - let old = Manifest { + let old = ManifestView { scope: PluginScope { models: vec!["b*".into(), "a*".into()], ..PluginScope::default() @@ -457,7 +457,7 @@ mod tests { fn a_new_setting_is_part_of_the_code_change() { let old = manifest(); let mut new = manifest(); - new.settings_schema.push(SettingSpec { + new.settings_schema.push(SettingSpecView { key: "tone".into(), kind: SettingKind::String, label: "Tone".into(), diff --git a/src-tauri/src/plugins/wire.rs b/src-tauri/src/plugins/wire.rs deleted file mode 100644 index edd8b213..00000000 --- a/src-tauri/src/plugins/wire.rs +++ /dev/null @@ -1,213 +0,0 @@ -//! PROVISIONAL:插件的 JS 文件是唯一的真相(约定附录 4,core v0.59.0、协议 35)之后的端点和 -//! 类型,照约定手写。 -//! -//! **core 发版之前只能这样**:钉着的 core v0.58.0 的 `tw_api` 里还没有它们,而且那一版的 -//! `PluginView`、`PluginInspection` 读不了新的样子(设置项的 `value` 取代了 `default`,manifest -//! 多了 `on_error`)。`tw_api::Endpoint` 是公开的 trait,这里照它给每个端点写一份描述, -//! `call` 命令(`call.rs` 的 `provisional` 那一组)和原生确认的三个命令就能照常走 -//! `ControlClient::call`。 -//! -//! **网页能经过 `call` 走到的那两个读的端点(`Plugins`、`PluginInspect`),响应一律是 -//! `serde_json::Value`**:原样转给网页,这里少写一个字段也不会把它从网页那边吞掉。原生确认 -//! 的命令只读其中要用的几项(`Installed`、`Inspection`)。网页那一侧的类型在 -//! `src/plugins/api.provisional.ts`。 -//! -//! 接上正式版(core v0.59.0 发版之后): -//! -//! 1. `Cargo.toml` 的 core 钉点升到 v0.59.0,`cargo update -p tw-api`(几个 core crate 一起), -//! `scripts/fetch-core.sh` 取回同一版的 twcore; -//! 2. `call.rs` 里 `provisional: [...]` 那一组并进 core 那一组(`ep::Plugins` 等), -//! 三个 `…Confirmed` 照样不进; -//! 3. `plugins/mod.rs` 里的 `wire::X` 换成 `tw_api::ep::X`;`Installed`、`Inspection`、 -//! `Manifest`、`SettingSpec` 换成生成的 `PluginView`、`PluginInspection`、`ManifestView`、 -//! `SettingSpecView`,`PluginCreate`、`PluginSave`、`PluginRewriteRequest` 换成 `tw_api` 里的; -//! 4. `tests/control_plane.rs` 里插件那一条去掉 `#[ignore]`; -//! 5. 删掉这个文件,`cargo clippy --all-targets`:名字或形状不一样的地方会在用到它的那一处报错。 - -use std::collections::BTreeMap; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tw_api::{ - ConfigWritten, Endpoint, Format, Method, OnError, Permission, PluginApprove, PluginLoadError, - PluginScope, PluginSource, RequestKind, SettingKind, SettingValue, -}; - -// ------------------------------------------------------------- 请求 - -/// 装一个插件:代码、ID 和开关。出错时怎么办、适用范围、设置都写在代码里 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginCreate { - pub source: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub id: Option, - pub enabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub base_version: Option, -} - -/// 保存一个插件:整份代码和开关 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginSave { - pub source: String, - pub enabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub base_version: Option, -} - -/// 按这些值改写代码里的 manifest。没有副作用 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginRewriteRequest { - pub source: String, - pub on_error: OnError, - pub scope: PluginScope, - pub settings: BTreeMap, -} - -/// 改写之后的代码 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginRewritten { - pub source: String, -} - -// ------------------------------------------------------------- 原生确认要读的那几项 - -/// `Plugins` 里的一个插件,原生确认要用的部分 -#[derive(Debug, Clone, Deserialize)] -pub struct Installed { - pub id: String, - /// 读不出 manifest 时是 id - pub name: String, - pub enabled: bool, - /// 读不出 manifest 时是空的 - pub permissions: Vec, - pub requests: Vec, - /// 确认过的那一份的 SHA-256 - pub sha256: String, -} - -/// `PluginInspect` 的结果 -#[derive(Debug, Clone, Deserialize)] -pub struct Inspection { - pub manifest: Option, - pub sha256: String, - pub error: Option, -} - -/// 代码里的 manifest,原生确认要用的部分 -#[derive(Debug, Clone, PartialEq, Deserialize)] -pub struct Manifest { - pub name: String, - pub permissions: Vec, - pub requests: Vec, - pub scope: PluginScope, - pub on_error: OnError, - pub settings_schema: Vec, -} - -/// 一个设置项,`value` 是写在代码里的值 -#[derive(Debug, Clone, PartialEq, Deserialize)] -pub struct SettingSpec { - pub key: String, - pub kind: SettingKind, - pub label: String, - pub value: SettingValue, -} - -// ------------------------------------------------------------- 端点 - -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; - } - )* - - /// 这里描述的每个端点:名字、方法、路径(测试核对用) - pub const ALL: &[(&str, Method, &str)] = &[$((stringify!($name), Method::$method, $path)),*]; - }; -} - -endpoints! { - // 网页能经过 `call` 走到的(`call.rs` 的 `provisional` 那一组) - Plugins: Get "/plugins" [] () => Value; - PluginInspect: Post "/plugins/inspect" [] PluginSource => Value; - PluginRewrite: Post "/plugins/rewrite" [] PluginRewriteRequest => PluginRewritten; - CreatePlugin: Post "/plugins" [] PluginCreate => ConfigWritten; - SavePlugin: Put "/plugins/{id}" ["id"] PluginSave => ConfigWritten; - // **网页走不到的三个**:只有 `plugins` 里的原生确认命令调它们(约定附录 4 §3) - CreatePluginConfirmed: Post "/plugins/confirmed" [] PluginCreate => ConfigWritten; - SavePluginConfirmed: Put "/plugins/{id}/confirmed" ["id"] PluginSave => ConfigWritten; - ApprovePluginFileConfirmed: Post "/plugins/{id}/approve/confirmed" ["id"] PluginApprove => ConfigWritten; -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_paths_and_methods_are_the_contracts() { - assert_eq!( - (SavePlugin::METHOD, SavePlugin::PATH, SavePlugin::PARAMS), - (Method::Put, "/plugins/{id}", &["id"][..]) - ); - assert_eq!( - (SavePluginConfirmed::METHOD, SavePluginConfirmed::PATH), - (Method::Put, "/plugins/{id}/confirmed") - ); - assert_eq!( - (CreatePluginConfirmed::METHOD, CreatePluginConfirmed::PATH), - (Method::Post, "/plugins/confirmed") - ); - assert_eq!( - tw_api::fill(ApprovePluginFileConfirmed::PATH, &[("id", "add date")]), - "/plugins/add%20date/approve/confirmed" - ); - assert_eq!( - (PluginRewrite::METHOD, PluginRewrite::PATH), - (Method::Post, "/plugins/rewrite") - ); - } - - /// 新的 manifest 读得出来:设置项带 `value`,有 `on_error`;读不出 manifest 的插件 - /// 权限是空的 - #[test] - fn the_new_shapes_read() { - let i: Inspection = serde_json::from_value(serde_json::json!({ - "manifest": { - "name": "Answer in a chosen language", "description": null, - "permissions": ["system"], "requests": ["conversation"], - "scope": { "clients": [], "models": ["claude-*"], "upstreams": [] }, - "reply_mode": "block", "on_error": "skip", - "settings_schema": [{ "key": "language", "kind": "string", "label": "Answer language", "value": "English" }], - "hooks": { "request": true, "reply_text": false, "tool_call": false } - }, - "sha256": "aa", - "error": null - })) - .unwrap(); - let m = i.manifest.unwrap(); - assert_eq!(m.on_error, OnError::Skip); - assert_eq!( - m.settings_schema[0].value, - SettingValue::String("English".into()) - ); - let p: Installed = serde_json::from_value(serde_json::json!({ - "id": "legacy", "name": "legacy", "description": null, "enabled": false, - "on_error": "reject", "permissions": [], "requests": ["conversation"], - "scope": { "clients": [], "models": [], "upstreams": [] }, "reply_mode": "block", - "settings_schema": [], "sha256": "bb", "status": { "kind": "disabled" }, - "stats": { "calls": 0, "changed": 0, "rejected": 0, "errors": 0, "avg_cpu_us": 0, "last_error": null } - })) - .unwrap(); - assert!(p.permissions.is_empty()); - } -} diff --git a/src-tauri/tests/control_plane.rs b/src-tauri/tests/control_plane.rs index 93f9f131..7a1c0d8c 100644 --- a/src-tauri/tests/control_plane.rs +++ b/src-tauri/tests/control_plane.rs @@ -449,14 +449,10 @@ async fn a_bedrock_upstream_is_saved_the_way_the_dialog_sends_it() { /// 停用照常走网页那条(约定附录 4 §3)。 /// /// core 第一次起来时装上的默认插件里就有这样一个(`wsl-paths`),停用着。 -/// -/// PROVISIONAL:端点是 core v0.59.0 的(`plugins::wire`),钉点升上去之后去掉 `#[ignore]` #[tokio::test] -#[ignore = "needs core v0.59.0 (plugins contract addendum 4)"] async fn a_tool_call_plugin_is_turned_on_only_through_the_confirmed_save() { use thinkwatch_lite_lib::control::Refused; - use thinkwatch_lite_lib::plugins::wire; - use tw_api::{SettingValue, ep}; + use tw_api::{PluginRewriteRequest, PluginSave, PluginSource, SettingValue, ep}; let core = Core::start(); core.wait_ready().await; @@ -465,8 +461,7 @@ async fn a_tool_call_plugin_is_turned_on_only_through_the_confirmed_save() { // 默认插件在启动时装上:等它出现在单子上 let deadline = Instant::now() + Duration::from_secs(10); loop { - let all = c.call::(&[], &()).await.unwrap(); - let all: Vec = serde_json::from_value(all).unwrap(); + let all = c.call::(&[], &()).await.unwrap(); if let Some(p) = all.into_iter().find(|p| p.id == "wsl-paths") { assert!(!p.enabled, "默认插件装上时停用着"); break; @@ -480,37 +475,36 @@ async fn a_tool_call_plugin_is_turned_on_only_through_the_confirmed_save() { .unwrap() .approved; - let on = wire::PluginSave { + let on = PluginSave { source: source.clone(), enabled: true, base_version: None, }; let e = c - .call::(&["wsl-paths"], &on) + .call::(&["wsl-paths"], &on) .await .unwrap_err(); let m = &e.downcast_ref::().expect("被拒时要带码").0; assert_eq!(m.code, "control.plugin.needs_confirmation", "{m:?}"); - c.call::(&["wsl-paths"], &on) + c.call::(&["wsl-paths"], &on) .await .unwrap(); // 开着的时候只改一个设置的值:网页那条就够 let read = c - .call::( + .call::( &[], - &tw_api::PluginSource { + &PluginSource { source: source.clone(), }, ) .await .unwrap(); - let read: wire::Inspection = serde_json::from_value(read).unwrap(); let m = read.manifest.expect("默认插件读得出 manifest"); let rewritten = c - .call::( + .call::( &[], - &wire::PluginRewriteRequest { + &PluginRewriteRequest { source, on_error: m.on_error, scope: m.scope.clone(), @@ -530,9 +524,9 @@ async fn a_tool_call_plugin_is_turned_on_only_through_the_confirmed_save() { .await .unwrap() .source; - c.call::( + c.call::( &["wsl-paths"], - &wire::PluginSave { + &PluginSave { source: rewritten.clone(), enabled: true, base_version: None, @@ -541,9 +535,9 @@ async fn a_tool_call_plugin_is_turned_on_only_through_the_confirmed_save() { .await .unwrap(); // 停用照常走网页那条 - c.call::( + c.call::( &["wsl-paths"], - &wire::PluginSave { + &PluginSave { source: rewritten, enabled: false, base_version: None, diff --git a/src/control.ts b/src/control.ts index e8b68e0d..9f6079cf 100644 --- a/src/control.ts +++ b/src/control.ts @@ -13,9 +13,7 @@ * 失败时抛出的是一条 `Msg` 形状的对象,交给 `errorText`。 */ import { invoke } from "@tauri-apps/api/core"; -// 临时:插件的文件即真相(约定附录 4)之后的端点,core v0.59.0 发版后改回从 `./generated/tw-api` 取 -// (`EndpointTable` 换回 `typeof ENDPOINTS`),步骤见 `./plugins/api.provisional.ts` -import type { EndpointTable, Endpoints } from "./plugins/api.provisional"; +import type { ENDPOINTS, Endpoints } from "./generated/tw-api"; /** * 界面能直接调的端点。**和 `src-tauri/src/call.rs` 的 `ALLOWED` 是同一份** @@ -116,7 +114,7 @@ export type WebviewEndpoint = (typeof WEBVIEW_ENDPOINTS)[number]; type Values = T extends readonly [unknown, ...infer Rest] ? [string | number, ...Values] : []; /** 这个端点的路径参数,按模板里的顺序 */ -type Params = Values; +type Params = Values<(typeof ENDPOINTS)[N]["params"]>; export function call( endpoint: N, diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index a6983096..df95d279 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -1,6 +1,6 @@ // Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand. -export const CONTROL_API_VERSION = 34; +export const CONTROL_API_VERSION = 35; /** * 一个账号上游登的是哪个账号。 @@ -1770,6 +1770,9 @@ export type LoginStatus = "pending" | "done" | "failed" | "expired" | "cancelled /** * 插件文件里的 manifest,加上它导出了哪些钩子。名字、说明、设置项的 `label` * **都是插件写的字**。 + * + * 出错时怎么办、范围、设置的值**都在文件里**:这里读到的就是这份源码装上之后的样子。 + * 要改它们,用 `POST /plugins/rewrite` 改写源码 */ export type ManifestView = { name: string, description: string | null, permissions: Array, /** @@ -1778,9 +1781,17 @@ export type ManifestView = { name: string, description: string | null, permissio */ requests: Array, /** - * 插件建议的范围。装上时照它填 + * 管哪些请求(manifest 的 `match`) + */ +scope: PluginScope, +/** + * 出错时怎么办(manifest 的 `on_error`)。没写是 `reject` */ -scope: PluginScope, reply_mode: ReplyMode, settings_schema: Array, hooks: PluginHooks, }; +on_error: OnError, reply_mode: ReplyMode, +/** + * 设置项,按插件写的先后,带着此刻的值 + */ +settings_schema: Array, hooks: PluginHooks, }; /** * 一条规则按什么认。**给界面说明用**,界面按类型写成自己的话。 @@ -2065,8 +2076,10 @@ export type PatchValue = string | number | boolean | null; export type Permission = "system" | "messages" | "tools" | "params" | "reply_text" | "reply_tool_calls"; /** - * 批准磁盘上改过的那个文件(`POST /plugins/{id}/approve`)。**网页不能调**,理由同 - * [`PluginCreate`]。 + * 批准磁盘上改过的那个文件(`POST /plugins/{id}/approve`、 + * `POST /plugins/{id}/approve/confirmed`)。插件改得了回答里的工具调用(新旧两份里有一份的 + * 权限有 [`Permission::ReplyToolCalls`],或者读不出旧的那份要什么权限)时,前一条拒绝(403, + * `control.plugin.needs_confirmation`),要在系统的确认框里点过头、走后一条。 */ export type PluginApprove = { /** @@ -2076,20 +2089,18 @@ export type PluginApprove = { sha256: string, base_version?: string | null, }; /** - * 装一个插件(`POST /plugins`)。 + * 装一个插件(`POST /plugins`、`POST /plugins/confirmed`)。出错时怎么办、范围、设置的值 + * 都在源码里(`POST /plugins/rewrite` 改写)。 * - * **网页不能调。**装插件要在系统的确认框里点头,那一步在桌面端的 Rust 里:它自己 - * 再编一遍源码、把名字和权限摆给人看,点了头才发这个请求。 + * 插件改得了回答里的工具调用(权限有 [`Permission::ReplyToolCalls`])时,`POST /plugins` + * 拒绝(403,`control.plugin.needs_confirmation`),要在系统的确认框里点过头、走 + * `POST /plugins/confirmed`。 */ export type PluginCreate = { source: string, /** * 不给就从名字生成一个 */ -id?: string | null, enabled: boolean, on_error: OnError, scope: PluginScope, -/** - * 没给的取默认值 - */ -settings: { [key in string]: SettingValue }, base_version?: string | null, }; +id?: string | null, enabled: boolean, base_version?: string | null, }; /** * 插件在一个请求的哪一段上跑。 @@ -2164,6 +2175,18 @@ export type PluginOrder = { ids: Array, base_version?: string | null, }; */ export type PluginOutcome = "unchanged" | "changed" | "rejected" | "error" | "skipped"; +/** + * 改写一份源码里的数据(`POST /plugins/rewrite`):出错时怎么办、范围、设置的值。**什么都不 + * 留下**,只交回改写之后的源码([`PluginSource`])—— 只换 manifest 字面量那一段,别的字节 + * 一个不动;字面量里的注释不保留。 + * + * `on_error` 和 `scope` 是改完的样子;`settings` 只改给了的那几个,没给的照旧。插件没声明的 + * 设置(`gw.plugin.setting_unknown`)、类型不对的值(`gw.plugin.setting_type`)、范围里空着 + * 的一项(`control.plugin.blank_pattern`)、不是纯数据的 manifest + * (`gw.plugin.manifest_not_data_at` / `gw.plugin.manifest_not_data`)都是 400。 + */ +export type PluginRewriteRequest = { source: string, on_error: OnError, scope: PluginScope, settings: { [key in string]: SettingValue }, }; + /** * 一个插件在一个请求上的一次运行(详情抽屉的时间线)。 */ @@ -2182,6 +2205,19 @@ attempt: number, outcome: PluginOutcome, */ error: Msg | null, cpu_us: number, }; +/** + * 保存一个插件(`PUT /plugins/{id}`、`PUT /plugins/{id}/confirmed`):源码和开关,**整份交** + * —— 交上来的就是保存之后的样子。出错时怎么办、范围、设置的值都在源码里。 + * + * core 拿它和批准的那一份比:manifest 字面量以外一个字节不差、manifest 里只差出错时怎么办、 + * 范围和设置的值,是**只改了数据**;别的都是**改了代码**。插件改得了回答里的工具调用 + * (新旧两份里有一份的权限有 [`Permission::ReplyToolCalls`],或者读不出旧的那份要什么 + * 权限)时,改代码、打开它在 `PUT /plugins/{id}` 上拒绝(403, + * `control.plugin.needs_confirmation`),要在系统的确认框里点过头、走 `/confirmed` 那一条; + * 只改数据、停用照常。源码和批准的一字不差时只改开关,文件不动。 + */ +export type PluginSave = { source: string, enabled: boolean, base_version?: string | null, }; + /** * 插件管哪些请求。**每张单子里都是 `*` 通配**(不分大小写),空着是「都管」。 */ @@ -2204,11 +2240,6 @@ upstreams: Array, }; */ export type PluginSource = { source: string, }; -/** - * 换一份源码(`PUT /plugins/{id}/source`)。**网页不能调**,理由同 [`PluginCreate`]。 - */ -export type PluginSourceReplace = { source: string, base_version?: string | null, }; - /** * 批准过的那一份和磁盘上现在那一份(`GET /plugins/{id}/source`)。 */ @@ -2291,21 +2322,6 @@ logs: Array, */ error: Msg | null, }; -/** - * 改一个插件的开关、出错时怎么办、范围、设置(`PUT /plugins/{id}`)。**整份交**: - * 交上来的就是保存之后的样子。 - * - * 插件改得了回答里的工具调用(权限有 [`Permission::ReplyToolCalls`],或者读不出它要 - * 什么权限)时,打开它、改设置、改范围这条路不收(`control.plugin.needs_confirmation`), - * 同一份请求体交给 `PUT /plugins/{id}/confirmed`:那个端点网页调不了,桌面端在系统的 - * 确认框里点了头才发。比的是生效的值:没写进配置的设置按默认值算,范围不看顺序。 - */ -export type PluginUpdate = { enabled: boolean, on_error: OnError, scope: PluginScope, -/** - * 没给的取默认值 - */ -settings: { [key in string]: SettingValue }, base_version?: string | null, }; - /** * 一个装上了的插件(`GET /plugins`),按运行的顺序。 */ @@ -2317,7 +2333,12 @@ name: string, /** * 插件写的字 */ -description: string | null, enabled: boolean, on_error: OnError, +description: string | null, enabled: boolean, +/** + * 出错时怎么办:插件文件里写的。读不出 manifest 时照批准的那份文件里写着的,再读不出 + * 是 `reject` + */ +on_error: OnError, /** * 读不出 manifest 时是空的 */ @@ -2328,13 +2349,14 @@ permissions: Array, */ requests: Array, /** - * 生效的范围(配置里的) + * 管哪些请求:插件文件里写的(同 `on_error`,读不出时是空的 —— 都管) */ -scope: PluginScope, reply_mode: ReplyMode, settings_schema: Array, +scope: PluginScope, reply_mode: ReplyMode, /** - * 交给插件的值:配置里写的,没写的是默认值 + * 设置项,按插件写的先后,带着交给插件的值([`SettingSpecView::value`])。读不出 + * manifest 时是空的 */ -settings: { [key in string]: SettingValue }, +settings_schema: Array, /** * 批准过的那一份的 SHA-256,小写十六进制 */ @@ -3784,13 +3806,14 @@ field: SetField, value: string, }; export type SettingKind = "string" | "number" | "boolean"; /** - * 插件声明的一个设置项。`label` 是**插件写的字**:界面当纯文本显示。 + * 插件声明的一个设置项,连同它此刻的值。`label` 是**插件写的字**:界面当纯文本显示。 */ export type SettingSpecView = { key: string, kind: SettingKind, label: string, /** - * 和 `kind` 同一种类型 + * 此刻的值:插件文件的 manifest 里 `value` 写的,没写是这种类型的空值(`""`、`0`、 + * `false`)。和 `kind` 同一种类型 */ -default: SettingValue, }; +value: SettingValue, }; /** * 一个设置的值:字符串、数字或 true/false。 @@ -4378,14 +4401,16 @@ export const ENDPOINTS = { TestSecurity: { method: "POST", path: "/security/{guard}/test", params: ["guard"], format: "json" }, Plugins: { method: "GET", path: "/plugins", params: [], format: "json" }, PluginInspect: { method: "POST", path: "/plugins/inspect", params: [], format: "json" }, + PluginRewrite: { method: "POST", path: "/plugins/rewrite", params: [], format: "json" }, CreatePlugin: { method: "POST", path: "/plugins", params: [], format: "json" }, + CreatePluginConfirmed: { method: "POST", path: "/plugins/confirmed", params: [], format: "json" }, ReorderPlugins: { method: "PUT", path: "/plugins/order", params: [], format: "json" }, - UpdatePlugin: { method: "PUT", path: "/plugins/{id}", params: ["id"], format: "json" }, - UpdatePluginConfirmed: { method: "PUT", path: "/plugins/{id}/confirmed", params: ["id"], format: "json" }, + SavePlugin: { method: "PUT", path: "/plugins/{id}", params: ["id"], format: "json" }, + SavePluginConfirmed: { method: "PUT", path: "/plugins/{id}/confirmed", params: ["id"], format: "json" }, DeletePlugin: { method: "DELETE", path: "/plugins/{id}", params: ["id"], format: "json" }, - ReplacePluginSource: { method: "PUT", path: "/plugins/{id}/source", params: ["id"], format: "json" }, PluginSourceDiff: { method: "GET", path: "/plugins/{id}/source", params: ["id"], format: "json" }, ApprovePluginFile: { method: "POST", path: "/plugins/{id}/approve", params: ["id"], format: "json" }, + ApprovePluginFileConfirmed: { method: "POST", path: "/plugins/{id}/approve/confirmed", params: ["id"], format: "json" }, TrialPlugin: { method: "POST", path: "/plugins/{id}/trial", params: ["id"], format: "json" }, PluginLogs: { method: "GET", path: "/plugins/{id}/logs", params: ["id"], format: "json" }, StartChatgptLogin: { method: "POST", path: "/chatgpt/login", params: [], format: "json" }, @@ -4489,14 +4514,16 @@ export type Endpoints = { TestSecurity: { req: SecurityTestRequest; res: SecurityTestResult }; Plugins: { req: null; res: Array }; PluginInspect: { req: PluginSource; res: PluginInspection }; + PluginRewrite: { req: PluginRewriteRequest; res: PluginSource }; CreatePlugin: { req: PluginCreate; res: ConfigWritten }; + CreatePluginConfirmed: { req: PluginCreate; res: ConfigWritten }; ReorderPlugins: { req: PluginOrder; res: ConfigWritten }; - UpdatePlugin: { req: PluginUpdate; res: ConfigWritten }; - UpdatePluginConfirmed: { req: PluginUpdate; res: ConfigWritten }; + SavePlugin: { req: PluginSave; res: ConfigWritten }; + SavePluginConfirmed: { req: PluginSave; res: ConfigWritten }; DeletePlugin: { req: BaseVersion; res: ConfigWritten }; - ReplacePluginSource: { req: PluginSourceReplace; res: ConfigWritten }; PluginSourceDiff: { req: null; res: PluginSourceView }; ApprovePluginFile: { req: PluginApprove; res: ConfigWritten }; + ApprovePluginFileConfirmed: { req: PluginApprove; res: ConfigWritten }; TrialPlugin: { req: PluginTrial; res: PluginTrialResult }; PluginLogs: { req: null; res: Array }; StartChatgptLogin: { req: ChatgptLoginStart; res: ChatgptLogin }; diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index cb3f8934..fc8d322e 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -330,6 +330,8 @@ "gw.plugin.syntax": "插件有语法错误:{detail}", "gw.plugin.syntax_at": "插件第 {line} 行第 {column} 列有语法错误:{detail}", "gw.plugin.manifest": "插件清单无效:{detail}", + "gw.plugin.manifest_not_data": "插件清单不是纯数据:{detail}", + "gw.plugin.manifest_not_data_at": "插件清单第 {line} 行第 {column} 列不是纯数据:{detail}", "gw.plugin.api": "此插件按插件 API {api} 编写,目前只支持 API 1。", "gw.plugin.engine": "插件引擎无法加载插件:{detail}", "gw.plugin.unreadable": "无法读取插件文件 {file}:{detail}", @@ -550,7 +552,7 @@ "control.plugin.blank_pattern": "适用范围中有空白项。请删除该项,或填写名称或带 * 的通配。", "control.plugin.unreadable": "无法读取插件文件 {file}:{detail}", "control.plugin.write_failed": "无法写入 {path}:{detail}", - "control.plugin.needs_confirmation": "启用插件「{plugin}」或修改其设置、适用范围,需要在应用中确认:此插件可以修改回答中的工具调用。", + "control.plugin.needs_confirmation": "插件「{plugin}」可以修改回答中的工具调用,因此安装、启用此插件,修改其代码或确认其文件的更改,都需要在应用中确认。", "control.plugin.file_missing": "插件「{plugin}」的文件已不存在,没有可确认的更改。请更换代码,或删除此插件。", "control.plugin.file_moved_on": "插件「{plugin}」的文件在审核之后再次被改动,请重新审核。", "control.plugin.order": "新的顺序须包含每个插件,且每个只出现一次。", @@ -602,8 +604,6 @@ "config.plugin.duplicate": "插件 ID「{plugin}」重复。", "config.plugin.file": "插件「{plugin}」的文件为 {file},应为 plugins/{plugin}.js。", "config.plugin.sha256": "插件「{plugin}」的 sha256 应为 64 位小写十六进制字符。", - "config.plugin.blank_pattern": "插件「{plugin}」的适用范围中有空白项。", - "config.plugin.setting_type": "插件「{plugin}」的设置项 {key} 只能是字符串、数字或 true/false。", "config.store.read_failed": "无法读取 {path}:{detail}", "config.store.missing": "{path} 不存在。", "config.store.conflict": "配置文件在此期间已被修改(当前版本 {current},本次修改基于 {expected}),未覆盖。请查看当前内容后重试。", diff --git a/src/plugins/api.provisional.ts b/src/plugins/api.provisional.ts deleted file mode 100644 index e7ec92be..00000000 --- a/src/plugins/api.provisional.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * PROVISIONAL —— 插件的 JS 文件是唯一的真相(约定附录 4,core v0.59.0、协议 35)。 - * - * **core 发版之前只能这样。**钉着的 core v0.58.0 生成的 `src/generated/tw-api.ts` 还是旧的 - * 样子:出错时怎么办、适用范围、设置的值存在配置里,由 `UpdatePlugin` 改。附录 4 之后它们 - * 都写在插件文件的 manifest 里(设置项的 `value` 取代了 `default`,多了 `on_error`), - * 一个插件只有一种写法:把整份代码和开关交给 `SavePlugin`,core 自己分辨是只改了数据还是 - * 改了代码。界面其余的代码不知道这一层:`src/types.ts` 用这里的同名类型盖住生成的那几个 - * (显式转出优先于 `export type *`),`src/control.ts` 的端点表也从这里取。 - * - * 和 v0.58.0 相比: - * - * - 新增 `SavePlugin`(`PUT /plugins/{id}`,网页可调)、`PluginRewrite`(`POST /plugins/rewrite`, - * 网页可调,没有副作用:按表单的值改写代码里的 manifest); - * - 新增三个要在系统的确认框里点头之后才发的端点(**网页不可调**,只有 Rust 的命令发): - * `CreatePluginConfirmed`、`SavePluginConfirmed`、`ApprovePluginFileConfirmed`; - * - `CreatePlugin`、`ApprovePluginFile` 进了网页的白名单,`CreatePlugin` 只带代码、ID 和开关; - * - 删掉 `UpdatePlugin`、`UpdatePluginConfirmed`、`ReplacePluginSource`。 - * - * 约定没写死、这里先按最可能的样子写的(接上时以生成的为准): - * - * - `PluginCreate` 只剩 `{ source, id, enabled, base_version }`(出错时、范围、设置都在代码里); - * - `SettingSpecView` 是 `{ key, kind, label, value }`;`PluginView` 不再有 `settings` - * (值在 `settings_schema[].value` 里);`ManifestView` 多了 `on_error`; - * - 插件文件在确认之后被改过(状态「文件已更改」)时,用确认过的代码拨开关(`SavePlugin`, - * 代码和确认过的一样)**不动磁盘上那份**:界面的开关就是这么拨的。 - * - * 接上正式版(core v0.59.0 发版、钉点升上去之后): - * - * 1. 升级 core 钉点、`cargo update`,重新生成 `src/generated/tw-api.ts` - * (`UPDATE_TS=1 cargo test --manifest-path src-tauri/Cargo.toml --test ts_bindings`); - * 2. 删掉 `src/types.ts` 里标着「临时」的那一段转出;`src/control.ts` 的 `Endpoints`、 - * `EndpointTable` 改回从 `./generated/tw-api` 取(`EndpointTable` 换回 `typeof ENDPOINTS`); - * 3. Rust 那边照 `src-tauri/src/plugins/wire.rs` 顶上的步骤换; - * 4. core 新加的码(如 `gw.plugin.manifest_not_data`)补进 `src/i18n/core.zh.json`; - * 5. 删掉这个文件,`pnpm typecheck`:名字或形状和这里不一样的地方会在用到它的那一处报错。 - */ -import type * as G from "@/generated/tw-api"; - -// ─── 插件文件里的 manifest ─── - -/** - * 插件声明的一个设置项。`label` 是**插件写的字**:界面当纯文本显示。`value` 是它现在的值, - * **写在插件文件里**(取代了 `default`),和 `kind` 同一种类型 - */ -export type SettingSpecView = { key: string; kind: G.SettingKind; label: string; value: G.SettingValue }; - -/** 插件文件里的 manifest。出错时怎么办(`on_error`)也写在里面,默认是拒绝 */ -export type ManifestView = Omit & { - on_error: G.OnError; - settings_schema: SettingSpecView[]; -}; - -/** 读一份代码看到的东西。**什么都没留下** */ -export type PluginInspection = Omit & { manifest: ManifestView | null }; - -/** - * 一个装上了的插件(`GET /plugins`),按运行的顺序。`on_error`、`scope`、`settings_schema` - * 都是从它的文件里读出来的 - */ -export type PluginView = Omit & { settings_schema: SettingSpecView[] }; - -// ─── 写 ─── - -/** - * 装一个插件(`POST /plugins`,网页可调;改得了回答里工具调用的要点头,走 - * `POST /plugins/confirmed`)。出错时怎么办、范围、设置都在代码里 - */ -export type PluginCreate = { source: string; id?: string | null; enabled: boolean; base_version?: string | null }; - -/** - * 保存一个插件(`PUT /plugins/{id}`):**整份代码**和开关。core 和确认过的那一份比:只改了 - * manifest 里的数据(出错时、范围、设置的值)的不必点头;改得了工具调用的插件,打开它、 - * 改代码要点头(`control.plugin.needs_confirmation`,走 `PUT /plugins/{id}/confirmed`) - */ -export type PluginSave = { source: string; enabled: boolean; base_version?: string | null }; - -/** 按表单改写代码里的 manifest(`POST /plugins/rewrite`)。**没有副作用** */ -export type PluginRewrite = { - source: string; - on_error: G.OnError; - scope: G.PluginScope; - /** 每一个声明了的设置项的值 */ - settings: { [key in string]: G.SettingValue }; -}; - -/** 改写之后的代码:manifest 那一段换成新写的,其余每个字节都和原来一样 */ -export type PluginRewritten = { source: string }; - -// ─── 端点 ─── - -type Changed = { - Plugins: { req: null; res: Array }; - PluginInspect: { req: G.PluginSource; res: PluginInspection }; - CreatePlugin: { req: PluginCreate; res: G.ConfigWritten }; - CreatePluginConfirmed: { req: PluginCreate; res: G.ConfigWritten }; - SavePlugin: { req: PluginSave; res: G.ConfigWritten }; - SavePluginConfirmed: { req: PluginSave; res: G.ConfigWritten }; - ApprovePluginFileConfirmed: { req: G.PluginApprove; res: G.ConfigWritten }; - PluginRewrite: { req: PluginRewrite; res: PluginRewritten }; -}; - -/** v0.59.0 删掉的 */ -type Removed = "UpdatePlugin" | "UpdatePluginConfirmed" | "ReplacePluginSource"; - -/** 各端点的请求和响应 */ -export type Endpoints = Omit & Changed; - -/** 新加的那几个端点的方法、路径和路径参数(和 core 的 `tw_api::ep` 同一种写法) */ -type Added = { - CreatePluginConfirmed: { method: "POST"; path: "/plugins/confirmed"; params: readonly []; format: "json" }; - SavePlugin: { method: "PUT"; path: "/plugins/{id}"; params: readonly ["id"]; format: "json" }; - SavePluginConfirmed: { method: "PUT"; path: "/plugins/{id}/confirmed"; params: readonly ["id"]; format: "json" }; - ApprovePluginFileConfirmed: { - method: "POST"; - path: "/plugins/{id}/approve/confirmed"; - params: readonly ["id"]; - format: "json"; - }; - PluginRewrite: { method: "POST"; path: "/plugins/rewrite"; params: readonly []; format: "json" }; -}; - -/** 端点表的类型(`src/control.ts` 按它数路径参数) */ -export type EndpointTable = Omit & Added; diff --git a/src/types.ts b/src/types.ts index 995eef76..cfa75ce5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,21 +29,6 @@ import type { LocalEvent } from "./generated/lite-api"; export type * from "./generated/tw-api"; export type * from "./generated/lite-api"; -// 临时:插件的文件即真相(约定附录 4)之后的形状,盖住生成的同名类型(显式转出优先于上面的 -// `export type *`)。core v0.59.0 发版、`tw-api.ts` 重新生成之后整段删掉,步骤见 -// `./plugins/api.provisional.ts` -export type { - Endpoints, - ManifestView, - PluginCreate, - PluginInspection, - PluginRewrite, - PluginRewritten, - PluginSave, - PluginView, - SettingSpecView, -} from "./plugins/api.provisional"; - /** core 的事件流上的一条 */ export type CoreEvent = Event; From 5a8220a02026c2942ebbc2019da980ea3698ad81 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:41:49 +0800 Subject: [PATCH 4/8] config: keep core's plugin confirmation refusal in the editor and history Core now refuses PUT /config, PATCH /config and a rollback with 403 control.plugin.needs_confirmation when the write would turn on, add or re-approve a plugin that can change the tool calls in replies, and those paths have no confirmed variant. A toast disappeared after a few seconds and did not say where the change can be made. The config file editor and the version history now show core's sentence in an inline notice ("This change has to be confirmed on the Plugins page", plus whether the file was saved or the version restored) with a button that opens the Plugins page. Other failures still use the toast. PatchConfig is only used for retention, failover and client probes, so it cannot hit this. Co-Authored-By: Claude Opus 5.5 --- src/App.tsx | 11 ++++++++- src/ConfigDialogs.tsx | 16 +++++++++++-- src/ConfigText.i18n.ts | 17 ++++++++++++++ src/ConfigText.tsx | 51 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6e306250..709d4801 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1108,7 +1108,16 @@ function Shell({ first }: { first: boolean }) { }} /> )} - {historyOpen && setHistoryOpen(false)} />} + {historyOpen && ( + setHistoryOpen(false)} + onOpenPlugins={() => { + setHistoryOpen(false); + open("plugins"); + }} + /> + )} {/* 等连上 core、拿到概览再弹:名称是否重名、保存基于哪一版都要它 */} {importing && ov && !remoteLost && ( void; + /** 关掉这里、去插件页(恢复的版本要装上、启用改得了工具调用的插件时) */ + onOpenPlugins?: () => void; }) { const t = useText(configDialogsText); const [versions, setVersions] = useState(null); const [busy, setBusy] = useState(null); + /** core 不让这次恢复装上、启用、换掉批准的代码的那个插件(它的那句话) */ + const [pluginRefusal, setPluginRefusal] = useState(null); useEffect(() => { let alive = true; @@ -118,10 +124,13 @@ export function VersionHistoryDialog({ async function restore(version: string) { setBusy(version); + setPluginRefusal(null); try { await call("ConfigRollback", { version }); } catch (e) { - toast.error(errorText(e)); + // 和保存配置文件一样:恢复这条路没有点过头的那一条,只能去插件页 + if (needsConfirmation(e)) setPluginRefusal(errorText(e)); + else toast.error(errorText(e)); } finally { setBusy(null); } @@ -134,6 +143,9 @@ export function VersionHistoryDialog({ {t.historyTitle} {t.historyDescription} + {pluginRefusal && ( + + )}
{versions == null ? (

diff --git a/src/ConfigText.i18n.ts b/src/ConfigText.i18n.ts index 02c2144f..f30de2f8 100644 --- a/src/ConfigText.i18n.ts +++ b/src/ConfigText.i18n.ts @@ -22,6 +22,17 @@ export const configTextText = messages( cursorAt: (section: string) => `光标位于${section}`, showInApp: "在界面中查看", unsaved: "有未保存的修改", + /** + * 写配置原文(保存文件、恢复版本)要装上、启用、换掉批准的代码的那个插件改得了回答里的 + * 工具调用:这条路做不了,只能在插件页里做(那里会弹系统的确认框) + */ + pluginConfirm: { + title: "此更改须在插件页中确认", + /** `reason` 是 core 的那句话 */ + notSaved: (reason: string) => `配置文件未保存。${reason}`, + notRestored: (reason: string) => `未恢复此版本。${reason}`, + open: "打开插件页", + }, }, { sections: { @@ -43,5 +54,11 @@ export const configTextText = messages( cursorAt: (section: string) => `Cursor in ${section}`, showInApp: "Show in app", unsaved: "Unsaved changes", + pluginConfirm: { + title: "This change has to be confirmed on the Plugins page", + notSaved: (reason: string) => `The config file was not saved. ${reason}`, + notRestored: (reason: string) => `This version was not restored. ${reason}`, + open: "Open Plugins", + }, }, ); diff --git a/src/ConfigText.tsx b/src/ConfigText.tsx index 6b0867f3..92abe816 100644 --- a/src/ConfigText.tsx +++ b/src/ConfigText.tsx @@ -10,6 +10,7 @@ import { useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; import { configTextText } from "./ConfigText.i18n"; import { errorText } from "@/i18n/core.i18n"; +import { needsConfirmation } from "@/plugins/write"; /** * 编辑器**打开这个对话框时才加载**。CodeMirror 连同 YAML 语法有几百 KB,整个应用 @@ -53,6 +54,8 @@ export default function ConfigTextMode({ const sections: Record = t.sections; const [draft, setDraft] = useState(doc.text); const [busy, setBusy] = useState(false); + /** core 不让这次保存装上、启用、换掉批准的代码的那个插件(它的那句话) */ + const [pluginRefusal, setPluginRefusal] = useState(null); /** 打开这一版时文件是什么样。**保存时带的就是它** */ const base = useRef(doc.version); const dirty = draft !== doc.text; @@ -106,12 +109,16 @@ export default function ConfigTextMode({ async function save() { setBusy(true); + setPluginRefusal(null); try { await call("PutConfig", { text: draft, base_version: base.current }); base.current = ""; onSaved(); } catch (e) { - toast.error(errorText(e)); + // 改得了工具调用的插件:这条路没有点过头的那一条,只能去插件页。几秒就消失的 + // toast 说不清去哪儿,这一句留在编辑器上 + if (needsConfirmation(e)) setPluginRefusal(errorText(e)); + else toast.error(errorText(e)); } finally { setBusy(false); } @@ -153,6 +160,14 @@ export default function ConfigTextMode({ )} + {pluginRefusal && ( + onJumpToForm({ name: "", section: "plugins" }))} + /> + )} +

); } + +/** + * 写配置原文(保存文件、恢复版本)被 core 拒了:这次会装上、启用一个改得了回答里工具调用的 + * 插件,或者换掉它批准的代码(403 `control.plugin.needs_confirmation`)。这条路没有点过头的 + * 那一条,**只能在插件页里做**,那里会弹系统的确认框。写明没写成、为什么、去哪儿 + */ +export function PluginConfirmNotice({ + reason, + result, + onOpenPlugins, +}: { + /** core 的那句话(按界面语言) */ + reason: string; + /** 没做成的是什么 */ + result: "notSaved" | "notRestored"; + onOpenPlugins?: () => void; +}) { + const t = useText(configTextText).pluginConfirm; + return ( + + {t.title} + +

{t[result](reason)}

+ {onOpenPlugins && ( +
+ +
+ )} +
+
+ ); +} From 253f117bfa018c821baec9699f4e932cd9ac5ca6 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:02:08 +0800 Subject: [PATCH 5/8] plugins: name default plugins in the UI language in core's messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core puts a plugin's manifest name into its messages ({plugin} in gw.plugin.* and control.plugin.needs_confirmation, plugin_name in plugin_failed). For the default plugins that name is English, so the Chinese UI said "Convert WSL and Windows paths" where the Plugins page says 「WSL 路径转换」. One helper on each side now names a plugin everywhere: pluginName (TS) and plugins::defaults::name_in (Rust), both reading src/i18n/plugin-defaults.json. With an id, a plugin is matched by id and manifest name as before; with only a name, as in a message's {plugin}, by the exact English manifest name. Descriptions and setting labels still need the id. - zhOf / core_text::zh render {plugin} through it, so toasts, notices, inline errors (such as the config file's 403), the request drawer's plugin runs, the stats tooltip and notifications all say the same name. Callers that know the plugin pass its id (coreText, errorText). - The history search sends the codes with {plugin} when the query matches a localized default plugin name, since the stored sentences are English. - Shared cases in core.zh.cases.json keep both implementations in step. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/core_text.rs | 41 +++++++++++++++- src-tauri/src/plugins/defaults.rs | 80 +++++++++++++++++++++++++------ src/RequestDrawer.tsx | 2 +- src/i18n/core.i18n.ts | 30 ++++++++---- src/i18n/core.test.ts | 33 +++++++++++++ src/i18n/core.zh.cases.json | 41 ++++++++++++++++ src/plugins/ChangedDialog.tsx | 6 +-- src/plugins/ListDialogs.tsx | 2 +- src/plugins/PluginsPage.tsx | 6 +-- src/plugins/TrialDialog.tsx | 8 ++-- src/plugins/defaults.ts | 28 ++++++++--- src/plugins/model.test.ts | 24 +++++++++- src/plugins/parts.tsx | 5 +- src/traffic/historySearch.test.ts | 11 +++++ 14 files changed, 269 insertions(+), 48 deletions(-) diff --git a/src-tauri/src/core_text.rs b/src-tauri/src/core_text.rs index 260f73d9..40c5c127 100644 --- a/src-tauri/src/core_text.rs +++ b/src-tauri/src/core_text.rs @@ -54,12 +54,20 @@ pub fn clause(m: &Msg) -> String { } /// 按码说中文,连同外面套的场合。说不出来是 None +/// +/// **插件名按中文说**:`{plugin}` 是默认插件 core 发的英文名时,换成它的中文名(和插件页上 +/// 一样,见 `plugins::defaults::name_in`)。消息里只有名字,按名字认 pub fn zh(m: &Msg) -> Option { let t = table(); if m.code.starts_with("//") { return None; } let say = t.messages.get(&m.code)?; + // 填进中文句子的参数。认场合比的是英文原句,那边照旧用原样的参数 + let mut zh_args = m.args.clone(); + if let Some(p) = zh_args.get_mut("plugin") { + *p = crate::plugins::defaults::name_in(crate::i18n::Lang::Zh, None, p); + } let mut leads = String::new(); let mut rest = m.text.as_str(); 'peel: loop { @@ -76,14 +84,14 @@ pub fn zh(m: &Msg) -> Option { else { continue; }; - leads.push_str(&render(&c.zh, &m.args, &t.tables)?); + leads.push_str(&render(&c.zh, &zh_args, &t.tables)?); leads.push(':'); rest = after; continue 'peel; } break; } - Some(leads + &render(say, &m.args, &t.tables)?) + Some(leads + &render(say, &zh_args, &t.tables)?) } // ------------------------------------------------------------ 写法 @@ -317,6 +325,35 @@ mod tests { zh: String, } + /// 默认插件的英文名在中文里换成插件页上的名字;英文照 core 的原句。别人的插件照它写的 + #[test] + fn a_default_plugin_is_named_like_on_the_plugins_page() { + let m = |plugin: &str| Msg { + code: "control.plugin.needs_confirmation".into(), + args: BTreeMap::from([("plugin".into(), plugin.into())]), + text: format!("Plugin `{plugin}` can change the tool calls in replies, so …"), + }; + let wsl = m("Convert WSL and Windows paths"); + crate::i18n::with_lang(crate::i18n::Lang::Zh, || { + assert!( + text(&wsl).starts_with("插件「WSL 路径转换」可以修改"), + "{}", + text(&wsl) + ); + let theirs = m("Rename tools"); + assert!( + text(&theirs).starts_with("插件「Rename tools」"), + "{}", + text(&theirs) + ); + }); + crate::i18n::with_lang(crate::i18n::Lang::En, || { + assert_eq!(text(&wsl), wsl.text); + // 中文那一句不看当前语言 + assert!(zh(&wsl).unwrap().contains("「WSL 路径转换」")); + }); + } + /// 和界面跑同一份用例:两份实现对同一条消息说同一句话 #[test] fn the_shared_cases_say_the_same_as_the_interface() { diff --git a/src-tauri/src/plugins/defaults.rs b/src-tauri/src/plugins/defaults.rs index e2822fa7..546c71eb 100644 --- a/src-tauri/src/plugins/defaults.rs +++ b/src-tauri/src/plugins/defaults.rs @@ -1,4 +1,5 @@ -//! core 自带的默认插件在这一侧的说法:系统的确认框、通知里的名字和设置项的标签。 +//! core 自带的默认插件在这一侧的说法:系统的确认框、通知、core 消息的中文里的名字,和设置项 +//! 的标签。 //! //! **表只有一张,在 `src/i18n/plugin-defaults.json`**,界面(`src/plugins/defaults.ts`) //! 读的是同一份,这里 `include_str!`。默认插件的 manifest 里名字、说明和标签都是英文; @@ -7,13 +8,16 @@ //! //! **按 id 认,manifest 的名字也得对得上**(core 发的那一个):用户删掉默认插件之后自己 //! 装了一个、恰好用了同一个 id 的,照它自己写的名字说 —— 不能拿默认插件的名字替一个 -//! 别人写的插件作保。 +//! 别人写的插件作保。**只有名字的按名字认**:core 的消息里嵌着的插件名(`{plugin}`)不带 +//! id,和 core 发的英文名一字不差的按默认插件的名字说。设置项的标签一律要 id。 use std::collections::HashMap; use std::sync::OnceLock; use serde::Deserialize; +use crate::i18n::Lang; + const SOURCE: &str = include_str!("../../../src/i18n/plugin-defaults.json"); #[derive(Deserialize)] @@ -46,27 +50,40 @@ fn table() -> &'static Table { T.get_or_init(|| serde_json::from_str(SOURCE).expect("plugin-defaults.json 读不出来")) } -/// 是 core 自带的那一个插件:id 对得上,manifest 的名字也是 core 发的那一个 -fn entry(id: Option<&str>, name: &str) -> Option<&'static Words> { - let id = id?; - let e = table() +/// 是 core 自带的那一个插件:manifest 的名字是 core 发的那一个,知道 id 的 id 也对得上 +fn entry(id: Option<&str>, name: &str) -> Option<&'static Entry> { + table() .plugins .iter() - .find(|e| e.id == id && e.manifest_name == name)?; - Some(tr!(&e.zh, &e.en)) + .find(|e| e.manifest_name == name && id.is_none_or(|id| e.id == id)) } -/// 插件在这一侧叫什么。默认插件按当前语言说,别的照它自己写的 -pub fn name(id: Option<&str>, name: &str) -> String { +fn words(e: &Entry, lang: Lang) -> &Words { + match lang { + Lang::Zh => &e.zh, + Lang::En => &e.en, + } +} + +/// 插件在某种语言里叫什么:确认框、通知、core 的消息里(`{plugin}`)**都用这一个**。知道 id +/// 的按 id 认(manifest 的名字也得对得上);只知道名字的(`id` 是 `None`)按 core 发的英文名 +/// 认,一字不差才算。别的照它自己写的 +pub fn name_in(lang: Lang, id: Option<&str>, name: &str) -> String { entry(id, name) - .and_then(|w| w.name.clone()) + .and_then(|e| words(e, lang).name.clone()) .unwrap_or_else(|| name.to_string()) } -/// 一个设置项的标签。参数同 [`name`],`key` 是设置项的键,`label` 是 manifest 写的 +/// 同 [`name_in`],按当前语言 +pub fn name(id: Option<&str>, name: &str) -> String { + name_in(crate::i18n::current(), id, name) +} + +/// 一个设置项的标签,按当前语言。`id`、`name` 是插件的(**一定要 id**),`key` 是设置项的 +/// 键,`label` 是 manifest 写的 pub fn label(id: &str, name: &str, key: &str, label: &str) -> String { entry(Some(id), name) - .and_then(|w| w.settings.get(key).cloned()) + .and_then(|e| words(e, crate::i18n::current()).settings.get(key).cloned()) .unwrap_or_else(|| label.to_string()) } @@ -104,7 +121,8 @@ mod tests { }); } - /// 同一个 id、别人写的插件:照它自己写的说 + /// 同一个 id、别人写的插件:照它自己写的说。用了默认插件的英文名、id 却不一样的,知道 + /// id 时也照它自己写的说 #[test] fn someone_elses_plugin_under_a_default_id_keeps_its_own_name() { crate::i18n::with_lang(crate::i18n::Lang::Zh, || { @@ -114,7 +132,41 @@ mod tests { "开关" ); let wsl = "Convert WSL and Windows paths"; + assert_eq!(name(Some("my-paths"), wsl), wsl); + assert_eq!(label("my-paths", wsl, "windows_client", "开关"), "开关"); + }); + } + + /// 只知道名字(core 的消息里那样):和 core 发的英文名一字不差才换,大小写、空白不同的不算 + #[test] + fn a_name_alone_is_matched_exactly() { + let wsl = "Convert WSL and Windows paths"; + assert_eq!(name_in(Lang::Zh, None, wsl), "WSL 路径转换"); + assert_eq!(name_in(Lang::En, None, wsl), wsl); + for near in [ + "convert wsl and windows paths", + " Convert WSL and Windows paths", + "WSL 路径转换", + ] { + assert_eq!(name_in(Lang::Zh, None, near), near); + } + // 不看当前语言:按码说中文的那一边(`core_text`)传的是 Zh + crate::i18n::with_lang(Lang::En, || { + assert_eq!(name_in(Lang::Zh, None, wsl), "WSL 路径转换"); assert_eq!(name(None, wsl), wsl); }); } + + /// 默认插件的英文名不会是一个 id:消息里 `{plugin}` 有时是 id(`config.plugin.*` 这些), + /// 按名字认的时候不会把一个 id 认成默认插件 + #[test] + fn no_manifest_name_reads_as_an_id() { + for e in &table().plugins { + let id_like = !e.manifest_name.is_empty() + && e.manifest_name + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); + assert!(!id_like, "{}", e.manifest_name); + } + } } diff --git a/src/RequestDrawer.tsx b/src/RequestDrawer.tsx index 0e1bdb52..99862897 100644 --- a/src/RequestDrawer.tsx +++ b/src/RequestDrawer.tsx @@ -863,7 +863,7 @@ function PluginRuns({ runs, attempts }: { runs: PluginRunView[]; attempts: Attem {run.error && ( - + )} diff --git a/src/i18n/core.i18n.ts b/src/i18n/core.i18n.ts index b3d3540c..9932c168 100644 --- a/src/i18n/core.i18n.ts +++ b/src/i18n/core.i18n.ts @@ -1,4 +1,5 @@ import type { Msg } from "@/types"; +import { localizedPluginNames, pluginName } from "@/plugins/defaults"; import { getLang } from "./index"; import CORE_ZH from "./core.zh.json"; import { render, wordsOf, type Args, type Tables } from "./template"; @@ -75,24 +76,29 @@ export function plain(text: string): Msg { * 一条 core 消息在界面上怎么说。 * * 传字符串进来也行:控制面之外的错误(连不上 socket 之类)本来就是 - * 一句现成的话。 + * 一句现成的话。`pluginId`:知道这句话说的是哪个插件时给(见 `zhOf`)。 */ -export function coreText(m: Msg | string | null | undefined): string { +export function coreText(m: Msg | string | null | undefined, pluginId?: string | null): string { if (m == null) return ""; if (typeof m === "string") return m; if (getLang() === "en") return m.text; - return zhOf(m) ?? m.text; + return zhOf(m, pluginId) ?? m.text; } /** * 按码说中文,连同外面套的场合。**说不出来是 `undefined`**:码不在表里,或者 * 缺参数、词表里查不到。少一个参数时不能写出半句中文或一个「undefined」—— * core 改了参数名而这张表还没跟上时,一句完整的英文比一句缺了主语的中文好。 + * + * **插件名按界面说**:`{plugin}` 是默认插件 core 发的英文名时,换成它的中文名(`pluginName`, + * 插件页上也叫这个名字)。知道是哪个插件的(`pluginId`)按 id 认,不知道的按名字认。 */ -export function zhOf(m: Msg): string | undefined { +export function zhOf(m: Msg, pluginId?: string | null): string | undefined { const say = MESSAGES[m.code]; if (!say || m.code.startsWith("//")) return undefined; const args: Args = m.args ?? {}; + // 填进中文句子的参数。认场合比的是英文原句,那边照旧用原样的参数 + const zhArgs: Args = args.plugin === undefined ? args : { ...args, plugin: pluginName(pluginId, args.plugin, "zh") }; const leads: string[] = []; for (let rest = m.text; ; ) { let hit: { zh: string; en: string } | undefined; @@ -100,7 +106,7 @@ export function zhOf(m: Msg): string | undefined { if (args[c.arg] === undefined) continue; const en = render(c.en, args, CORE_TABLES); if (en === undefined || !rest.startsWith(`${en}: `)) continue; - const zh = render(c.zh, args, CORE_TABLES); + const zh = render(c.zh, zhArgs, CORE_TABLES); if (zh === undefined) return undefined; hit = { zh, en }; break; @@ -109,7 +115,7 @@ export function zhOf(m: Msg): string | undefined { leads.push(`${hit.zh}:`); rest = rest.slice(hit.en.length + 2); } - const zh = render(say, args, CORE_TABLES); + const zh = render(say, zhArgs, CORE_TABLES); return zh === undefined ? undefined : leads.join("") + zh; } @@ -122,14 +128,19 @@ export function zhOf(m: Msg): string | undefined { * 英文原句里本来就有,core 按 `q` 对原句就对上了。**宁可多给**:多给的那几条,界面拿到 * 之后照着屏幕上的那句话再筛一遍(见 `traffic/historySearch.ts`)。 * + * **默认插件的中文名也算**:句子里的插件名换成了它(见 `zhOf`),英文原句里却是 core 发的 + * 英文名。搜的是其中一个中文名的,句子里有插件名的码都交过去。 + * * 英文界面上看到的就是原句,交空的。`q` 已经去掉首尾空白、转了小写。 */ export function codesMatching(q: string): string[] { if (!q || getLang() === "en") return []; + const pluginNamed = localizedPluginNames("zh").some((n) => n.toLowerCase().includes(q)); const out: string[] = []; for (const [code, say] of Object.entries(MESSAGES)) { if (code.startsWith("//")) continue; - if (wordsOf(say, CORE_TABLES).some((w) => w.toLowerCase().includes(q))) out.push(code); + if (wordsOf(say, CORE_TABLES).some((w) => w.toLowerCase().includes(q)) || (pluginNamed && say.includes("{plugin}"))) + out.push(code); } return out; } @@ -140,9 +151,10 @@ export function codesMatching(q: string): string[] { * **命令失败时交出来的是一条 `Msg` 形状的对象**(src-tauri 的 `CmdError`): * 控制面的失败带着 core 的码,按码翻;桌面端自己的失败码是空串,照 `text` * 显示。Tauri 自己拒掉的调用(命令不存在、没有权限)仍然是一句字符串。 + * `pluginId` 同 `coreText`。 */ -export function errorText(e: unknown): string { - if (isMsg(e)) return coreText(e); +export function errorText(e: unknown, pluginId?: string | null): string { + if (isMsg(e)) return coreText(e, pluginId); if (typeof e === "string") return e; if (e instanceof Error) return e.message; return String(e); diff --git a/src/i18n/core.test.ts b/src/i18n/core.test.ts index c9cde125..b6df7942 100644 --- a/src/i18n/core.test.ts +++ b/src/i18n/core.test.ts @@ -136,6 +136,39 @@ describe("core 的错误:码加参数", () => { }); }); +describe("消息里的插件名", () => { + const refusal = (plugin: string) => ({ + code: "control.plugin.needs_confirmation", + args: { plugin }, + text: `Plugin \`${plugin}\` can change the tool calls in replies, so …`, + }); + const WSL = "Convert WSL and Windows paths"; + + it("默认插件 core 发的英文名,中文界面说插件页上的名字", () => { + expect(inLang("zh", () => coreText(refusal(WSL)))).toMatch(/^插件「WSL 路径转换」可以修改回答中的工具调用/); + expect(inLang("zh", () => errorText(refusal(WSL)))).toMatch(/^插件「WSL 路径转换」/); + // 英文界面照 core 的原句 + expect(inLang("en", () => coreText(refusal(WSL)))).toBe(refusal(WSL).text); + }); + + it("只有名字时一字不差才换;别人写的插件照它自己写的", () => { + for (const name of ["convert wsl and windows paths", `${WSL} `, "Rename tools"]) { + expect(inLang("zh", () => coreText(refusal(name)))).toMatch(new RegExp(`^插件「${name}」`)); + } + }); + + it("知道是哪个插件的按 id 认:用了默认插件英文名的别的插件照它自己写的", () => { + expect(inLang("zh", () => coreText(refusal(WSL), "wsl-paths"))).toMatch(/^插件「WSL 路径转换」/); + expect(inLang("zh", () => coreText(refusal(WSL), "my-paths"))).toMatch(/^插件「Convert WSL and Windows paths」/); + expect(inLang("zh", () => errorText(refusal(WSL), "my-paths"))).toMatch(/^插件「Convert WSL and Windows paths」/); + }); + + it("参数是 ID 的那几句照原样", () => { + const m = { code: "control.plugin.id_taken", args: { plugin: "wsl-paths" }, text: "There is already a plugin `wsl-paths`." }; + expect(inLang("zh", () => coreText(m, "wsl-paths"))).toBe("已存在 ID 为「wsl-paths」的插件。"); + }); +}); + describe("扫描发现:句子由词拼出来", () => { it("按 kind 和 rule 拼出中文", () => { const m = { diff --git a/src/i18n/core.zh.cases.json b/src/i18n/core.zh.cases.json index 8a70af85..4cca53d3 100644 --- a/src/i18n/core.zh.cases.json +++ b/src/i18n/core.zh.cases.json @@ -260,5 +260,46 @@ "text": "Plugin `统一用词` was not started for this answer: the limit of 8 plugins running on answers at the same time was reached." }, "zh": "插件「统一用词」未处理此回答:同时处理回答的插件已达上限(8 个)。" + }, + { + "msg": { + "code": "gw.plugin.rejected", + "args": { + "plugin": "Convert WSL and Windows paths", + "reason": "the path is outside the workspace" + }, + "text": "Plugin `Convert WSL and Windows paths` refused this request: the path is outside the workspace" + }, + "zh": "插件「WSL 路径转换」拒绝了此请求:the path is outside the workspace" + }, + { + "msg": { + "code": "control.plugin.needs_confirmation", + "args": { + "plugin": "Answer in a chosen language" + }, + "text": "Plugin `Answer in a chosen language` can change the tool calls in replies, so installing it, turning it on, changing its code or approving a change to its file has to be confirmed in the app." + }, + "zh": "插件「指定回答语言」可以修改回答中的工具调用,因此安装、启用此插件,修改其代码或确认其文件的更改,都需要在应用中确认。" + }, + { + "msg": { + "code": "gw.plugin.file_changed", + "args": { + "plugin": "convert wsl and windows paths" + }, + "text": "The file of plugin `convert wsl and windows paths` changed on disk, so it no longer runs. Review the change and approve it in the app." + }, + "zh": "插件「convert wsl and windows paths」的文件已更改,此插件不再运行。请在应用中审核并确认更改。" + }, + { + "msg": { + "code": "control.plugin.not_found", + "args": { + "plugin": "wsl-paths" + }, + "text": "There is no plugin `wsl-paths`." + }, + "zh": "插件「wsl-paths」不存在。" } ] diff --git a/src/plugins/ChangedDialog.tsx b/src/plugins/ChangedDialog.tsx index f6ad972d..512eaee3 100644 --- a/src/plugins/ChangedDialog.tsx +++ b/src/plugins/ChangedDialog.tsx @@ -72,7 +72,7 @@ export function ChangedDialog({ if (r === "done") onApproved(); else setCancelled(true); } catch (e) { - setError(errorText(e)); + setError(errorText(e, plugin.id)); } finally { setWriting(false); } @@ -118,7 +118,7 @@ export function ChangedDialog({ {loadError && (

- +

{loadError.line != null &&

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

}
@@ -136,7 +136,7 @@ export function ChangedDialog({ )} - {read.error !== undefined && !read.loading && !read.data && } + {read.error !== undefined && !read.loading && !read.data && }
diff --git a/src/plugins/ListDialogs.tsx b/src/plugins/ListDialogs.tsx index 74e06c62..953611fb 100644 --- a/src/plugins/ListDialogs.tsx +++ b/src/plugins/ListDialogs.tsx @@ -66,7 +66,7 @@ export function DeleteDialog({ {t.deleteDescription} - {error !== null && errorText(error)} + {error !== null && errorText(error, target.id)} {common.cancel} diff --git a/src/plugins/PluginsPage.tsx b/src/plugins/PluginsPage.tsx index ab922720..cef78282 100644 --- a/src/plugins/PluginsPage.tsx +++ b/src/plugins/PluginsPage.tsx @@ -385,7 +385,7 @@ function Stopped({ > {p.status.kind === "error" && (

- +

)}

{reject ? t.failedRejecting : t.failedSkipping}

@@ -472,7 +472,7 @@ function PluginList({
{p.status.kind === "error" ? (

- +

) : ( w.description && ( @@ -494,7 +494,7 @@ function PluginList({ {t.appliesTo} - + )} diff --git a/src/plugins/TrialDialog.tsx b/src/plugins/TrialDialog.tsx index 6d95512c..817547f5 100644 --- a/src/plugins/TrialDialog.tsx +++ b/src/plugins/TrialDialog.tsx @@ -51,7 +51,7 @@ export function TrialDialog({ plugin, onClose }: { plugin: PluginView; onClose: const r = await call("TrialPlugin", { request_id: selected }, plugin.id); setResult({ id: selected, r }); } catch (e) { - setError(errorText(e)); + setError(errorText(e, plugin.id)); } finally { setRunning(false); } @@ -98,7 +98,7 @@ export function TrialDialog({ plugin, onClose }: { plugin: PluginView; onClose: - {result && } + {result && }
@@ -120,7 +120,7 @@ function pick(rows: HistoryRow[], plugin: PluginView): { rows: HistoryRow[]; out } /** 一次试运行的结果:两头的改动、报错,和日志 */ -function Result({ r }: { r: PluginTrialResult }) { +function Result({ r, pluginId }: { r: PluginTrialResult; pluginId: string }) { const t = useText(trialDialogText); const sides = (["request", "reply"] as const).filter((s) => r[s] != null); // 先看改了的那一头:两头都跑了、只有回答被改写时,落在「请求」上看到的是「未改动」 @@ -143,7 +143,7 @@ function Result({ r }: { r: PluginTrialResult }) { {r.error && (

- +

)} diff --git a/src/plugins/defaults.ts b/src/plugins/defaults.ts index 21e97155..0a0aeaed 100644 --- a/src/plugins/defaults.ts +++ b/src/plugins/defaults.ts @@ -1,17 +1,20 @@ /** * core 自带的默认插件在界面上的说法:名字、说明、设置项的标签。 * - * **表在 `src/i18n/plugin-defaults.json`**,Rust(系统的确认框、通知)读的是同一份:插件页上 - * 叫「指定回答语言」,确认框和通知里也得是这几个字。默认插件的 manifest 里名字、说明和标签 - * 都是英文:中文界面取表里的名字、说明和标签,英文界面只取标签。 + * **表在 `src/i18n/plugin-defaults.json`**,Rust(系统的确认框、通知、core 消息的中文)读的是 + * 同一份:插件页上叫「指定回答语言」,确认框、通知和报错里也得是这几个字。默认插件的 + * manifest 里名字、说明和标签都是英文:中文界面取表里的名字、说明和标签,英文界面只取标签。 * * **按 id 认,manifest 的名字也得对得上**(core 发的那一个):用户删掉默认插件之后自己装了 * 一个、恰好用了同一个 id 的,照它自己写的显示 —— 不拿默认插件的说明替别人写的插件作保。 * + * **只有名字的,按名字认**:core 的消息里嵌着的插件名(`{plugin}`)不带 id,和 core 发的英文名 + * 一字不差的按默认插件的名字说(`pluginName`)。说明和设置项的标签一律要 id。 + * * 按调用那一刻的语言取,**只在渲染时调用**(和 `textOf` 一样)。 */ import TABLE from "@/i18n/plugin-defaults.json"; -import { getLang } from "@/i18n"; +import { getLang, type Lang } from "@/i18n"; import type { SettingSpecView } from "@/types"; interface Words { @@ -35,9 +38,20 @@ function wordsOf(id: string | null | undefined, name: string): Words | null { return e ? e[getLang()] : null; } -/** 插件叫什么。`id` 不知道(还没装上)就照它自己写的 */ -export function pluginName(id: string | null | undefined, name: string): string { - return wordsOf(id, name)?.name ?? name; +/** + * 插件叫什么:界面上、core 的消息里(`{plugin}`)**都用这一个**。知道 id 的按 id 认(manifest + * 的名字也得对得上);只知道名字的按 core 发的英文名认,一字不差才算。别的照它自己写的。 + * + * `lang` 默认是此刻的界面语言;按码说中文时(`zhOf`)传 `zh`。 + */ +export function pluginName(id: string | null | undefined, name: string, lang: Lang = getLang()): string { + const e = id ? ENTRIES.find((x) => x.id === id && x.manifest_name === name) : ENTRIES.find((x) => x.manifest_name === name); + return e?.[lang].name ?? name; +} + +/** 默认插件在这种语言里另起的名字(中文名)。按屏幕上的字搜索请求记录时用 */ +export function localizedPluginNames(lang: Lang = getLang()): string[] { + return ENTRIES.flatMap((e) => (e[lang].name ? [e[lang].name] : [])); } /** 插件的说明 */ diff --git a/src/plugins/model.test.ts b/src/plugins/model.test.ts index 409f5120..f3c4b6b3 100644 --- a/src/plugins/model.test.ts +++ b/src/plugins/model.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { setLang } from "@/i18n"; import type { ManifestView, PluginView } from "@/types"; -import { localSchema, pluginDescription, pluginName } from "./defaults"; +import { localizedPluginNames, localSchema, pluginDescription, pluginName } from "./defaults"; import { hunks, lineDiff, tally } from "./diff"; import { cpuMs, @@ -173,7 +173,27 @@ describe("默认插件的说法", () => { expect(pluginName(theirs.id, theirs.name)).toBe("路径小工具"); expect(pluginDescription(theirs)).toBe("别人写的"); expect(localSchema(theirs.id, theirs.name, theirs.settings_schema)).toBe(theirs.settings_schema); - expect(pluginName(null, "Answer in a chosen language")).toBe("Answer in a chosen language"); + // 用了默认插件的英文名、id 不一样的:知道 id 时照它自己写的,说明和标签也不换 + const copy = plugin({ id: "my-paths" }); + expect(pluginName(copy.id, copy.name)).toBe(copy.name); + expect(pluginDescription(copy)).toBe(copy.description); + expect(localSchema(copy.id, copy.name, copy.settings_schema)).toBe(copy.settings_schema); + }); + + it("只知道名字(core 消息里的 `{plugin}`):按 core 发的英文名认,一字不差才算", () => { + setLang("zh"); + expect(pluginName(null, "Answer in a chosen language")).toBe("指定回答语言"); + expect(pluginName(undefined, "Convert WSL and Windows paths")).toBe("WSL 路径转换"); + for (const near of ["answer in a chosen language", "Answer in a chosen language.", "指定回答语言", "wsl-paths"]) { + expect(pluginName(null, near)).toBe(near); + } + // 语言可以指定:按码说中文时不看此刻的界面语言 + setLang("en"); + expect(pluginName(null, "Convert WSL and Windows paths")).toBe("Convert WSL and Windows paths"); + expect(pluginName(null, "Convert WSL and Windows paths", "zh")).toBe("WSL 路径转换"); + expect(localizedPluginNames()).toEqual([]); + expect(localizedPluginNames("zh")).toEqual(["指定回答语言", "WSL 路径转换"]); + setLang("zh"); }); }); diff --git a/src/plugins/parts.tsx b/src/plugins/parts.tsx index 021b3b05..c3d98336 100644 --- a/src/plugins/parts.tsx +++ b/src/plugins/parts.tsx @@ -254,8 +254,9 @@ export function ScopeSummary({ scope }: { scope: PluginScope }) { /** * 运行统计的一格:运行几次、改写几次,出错的标红。悬停是全部的数(core 启动以来)。 + * `pluginId`:是哪个插件的(最近一次出错的那一句里有插件名,见 `coreText`) */ -export function StatsCell({ stats }: { stats: PluginStats }) { +export function StatsCell({ stats, pluginId }: { stats: PluginStats; pluginId: string }) { const t = useText(pluginStatsText); const lt = useText(pluginLabelsText); if (stats.calls === 0) return {t.noRuns}; @@ -282,7 +283,7 @@ export function StatsCell({ stats }: { stats: PluginStats }) { {t.lines.lastError} · {when(stats.last_error.at_ms)} {/* core 的那一句按码说,里面嵌着的插件写的字照样只是字 */} - + )} diff --git a/src/traffic/historySearch.test.ts b/src/traffic/historySearch.test.ts index b818223b..22417e36 100644 --- a/src/traffic/historySearch.test.ts +++ b/src/traffic/historySearch.test.ts @@ -135,6 +135,17 @@ describe("译文里可能含着搜索词的码", () => { it("没有搜索词时一个都不给", () => { expect(codesMatching("")).toEqual([]); }); + + /** 句子里默认插件的英文名换成了中文名,库里的英文原句里没有它 */ + it("默认插件的中文名:带插件名的码都交", () => { + const codes = codesMatching("wsl 路径"); + expect(codes).toContain("gw.plugin.rejected"); + expect(codes).toContain("control.plugin.needs_confirmation"); + expect(codes).not.toContain("gw.upstream.timeout"); + expect(codesMatching("指定回答语言")).toContain("gw.plugin.request_failed"); + setLang("en"); + expect(codesMatching("wsl 路径")).toEqual([]); + }); }); describe("表里该有的行", () => { From f5e476423928d218c87554d6bdd81cc92d85809e Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:03:25 +0800 Subject: [PATCH 6/8] plugins: add a plugin in the same editor as editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Add plugin dialog still had the old flow: a Choose a file / Paste code switch, a drop zone, Next and a separate review step. Adding now opens the plugin editor in a "new plugin" mode, so it works exactly like editing: - It opens on the Code tab with a short starter template that loads: a pure-data manifest written the way core rewrites it (the system permission, one note setting, on_error) and an onRequest hook that does nothing until the note is filled in. zh and en templates differ only in their text. - The code is edited in place, can be pasted, and "Import from file…" stays at the top right. The header (name, ID, SHA-256, permission chips) and the Settings tab follow the code through PluginInspect, with inline errors, as when editing. - The Settings tab adds a Plugin ID field. It follows the imported file name or the code's name until edited, and is checked for format, core's reserved words and existing IDs; suggestId now avoids reserved words the way core does. The enable switch starts off. - One primary button, Install. A plugin whose manifest holds reply_tool_calls goes straight to plugin_install_confirmed (one native confirmation, CreatePluginConfirmed); any other installs with CreatePlugin. There is no review step. - Closing with changes asks once, saying the plugin will not be installed. SourceDialog, its strings, the drop zone, the segmented switch and the review step are gone. Editor.test.tsx covers the new mode and the template; model tests cover the ID checks. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/plugins/words.rs | 3 +- src-tauri/src/wire.rs | 2 +- src/generated/lite-api.ts | 2 +- src/plugins/Editor.i18n.ts | 79 ++++++++ src/plugins/Editor.test.tsx | 140 +++++++++++++ src/plugins/Editor.tsx | 301 +++++++++++++++++++++------- src/plugins/PluginsPage.tsx | 11 +- src/plugins/SourceDialog.i18n.ts | 60 ------ src/plugins/SourceDialog.tsx | 332 ------------------------------- src/plugins/model.test.ts | 23 ++- src/plugins/model.ts | 17 +- src/plugins/useEditing.ts | 5 +- src/plugins/write.ts | 2 +- 13 files changed, 496 insertions(+), 481 deletions(-) create mode 100644 src/plugins/Editor.test.tsx delete mode 100644 src/plugins/SourceDialog.i18n.ts delete mode 100644 src/plugins/SourceDialog.tsx diff --git a/src-tauri/src/plugins/words.rs b/src-tauri/src/plugins/words.rs index 9ebca26c..bfb7f51b 100644 --- a/src-tauri/src/plugins/words.rs +++ b/src-tauri/src/plugins/words.rs @@ -142,7 +142,8 @@ fn invisible(c: char) -> bool { matches!(c as u32, 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x2069 | 0xFEFF) } -/// SHA-256 的前 16 位,四个一组。审核窗口里写的是同一段,对得上就是同一份代码 +/// SHA-256 的前 16 位,四个一组。应用里(插件编辑器的标题栏、审核更改)写的是同一段,对得上就是 +/// 同一份代码 pub fn sha_prefix(hex: &str) -> String { let head: Vec = hex.chars().take(16).collect(); head.chunks(4) diff --git a/src-tauri/src/wire.rs b/src-tauri/src/wire.rs index 5cd18e31..7730bf6e 100644 --- a/src-tauri/src/wire.rs +++ b/src-tauri/src/wire.rs @@ -763,7 +763,7 @@ pub struct ImportProposal { #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct PluginInstallRequest { pub source: String, - /// 审核窗口里填的 ID。不给由 core 按名字起 + /// 添加插件时「设置」页上的插件 ID。不给由 core 按名字起 #[serde(default, skip_serializing_if = "Option::is_none")] pub id: Option, pub enabled: bool, diff --git a/src/generated/lite-api.ts b/src/generated/lite-api.ts index 0399d06f..baa0634e 100644 --- a/src/generated/lite-api.ts +++ b/src/generated/lite-api.ts @@ -483,7 +483,7 @@ export type PluginApproveRequest = { id: string, base_version?: string | null, } */ export type PluginInstallRequest = { source: string, /** - * 审核窗口里填的 ID。不给由 core 按名字起 + * 添加插件时「设置」页上的插件 ID。不给由 core 按名字起 */ id?: string | null, enabled: boolean, base_version?: string | null, }; diff --git a/src/plugins/Editor.i18n.ts b/src/plugins/Editor.i18n.ts index 5166cf17..87bc6d0d 100644 --- a/src/plugins/Editor.i18n.ts +++ b/src/plugins/Editor.i18n.ts @@ -1,5 +1,12 @@ import { messages } from "@/i18n"; +/** + * 添加插件时编辑器里的那一段模板:**装得上**(manifest 是纯数据,写成 core 改写它时的样子, + * 权限和钩子对得上),附加内容为空时什么都不改。注释写在 manifest 外面:core 改写 manifest + * 时不保留它里面的注释。 + */ +const template = (lines: string[]) => `${lines.join("\n")}\n`; + export const editorText = messages( { tabSettings: "设置", @@ -10,6 +17,40 @@ export const editorText = messages( id: (id: string) => `ID ${id}`, sha: (prefix: string) => `SHA-256 ${prefix}`, + /** 添加插件:代码里的名字还没读出来时的标题 */ + newTitle: "添加插件", + install: "安装", + idLabel: "插件 ID", + idHint: "小写字母、数字和连字符,最多 40 个;安装后不可修改。", + idProblems: { + bad: "只能使用小写字母、数字和连字符,最多 40 个。", + reserved: "此 ID 为保留词,不能使用。", + taken: "已有插件使用此 ID。", + }, + templateFailed: "无法打开插件模板", + template: template([ + "// 插件在请求发往上游之前改写请求,也可以在回答交给客户端之前改写回答。", + "// manifest 须为纯数据;「设置」页中的修改写回 manifest。", + "", + "export const manifest = {", + ' name: "新插件",', + " api: 1,", + ' description: "在系统提示词末尾附加「附加内容」中的文字。",', + ' permissions: ["system"],', + ' on_error: "reject",', + ' settings: { note: { type: "string", label: "附加内容", value: "" } },', + "};", + "", + "// 请求发往上游之前调用:req.system 为系统提示词,ctx.settings 为设置项的值。", + "// 返回修改后的 req;返回 undefined 表示不修改。", + "export function onRequest(req, ctx) {", + ' const note = String(ctx.settings.note ?? "").trim();', + ' if (note === "" || req.system.includes(note)) return undefined;', + ' req.system = req.system ? req.system + "\\n\\n" + note : note;', + " return req;", + "}", + ]), + enabled: "启用", enabledHint: "停用后此插件不运行,适用范围内的请求照常转发。", @@ -39,6 +80,8 @@ export const editorText = messages( discardTitle: "放弃未保存的更改", discardDescription: "关闭之后,对设置和代码的更改都不会保存。", + /** 添加插件时关掉 */ + discardNewDescription: "关闭之后,此插件不会安装,代码和设置都不会保留。", keepEditing: "继续编辑", discard: "放弃更改", }, @@ -50,6 +93,41 @@ export const editorText = messages( id: (id: string) => `ID ${id}`, sha: (prefix: string) => `SHA-256 ${prefix}`, + newTitle: "Add plugin", + install: "Install", + idLabel: "Plugin ID", + idHint: "Lowercase letters, digits and hyphens, up to 40. It cannot be changed after installing.", + idProblems: { + bad: "Use only lowercase letters, digits and hyphens, up to 40.", + reserved: "This ID is reserved and cannot be used.", + taken: "Another plugin already uses this ID.", + }, + templateFailed: "The plugin template could not be opened", + template: template([ + "// A plugin changes requests before they reach an upstream, and can change answers before", + "// they reach the client. The manifest must be plain data: the Settings tab writes its", + "// changes into it.", + "", + "export const manifest = {", + ' name: "New plugin",', + " api: 1,", + ' description: "Adds the text of the Note setting to the end of the system prompt.",', + ' permissions: ["system"],', + ' on_error: "reject",', + ' settings: { note: { type: "string", label: "Note", value: "" } },', + "};", + "", + "// Runs before a request goes to the upstream: req.system is the system prompt, and", + "// ctx.settings holds the values of the settings. Return the changed req, or undefined", + "// to leave the request unchanged.", + "export function onRequest(req, ctx) {", + ' const note = String(ctx.settings.note ?? "").trim();', + ' if (note === "" || req.system.includes(note)) return undefined;', + ' req.system = req.system ? req.system + "\\n\\n" + note : note;', + " return req;", + "}", + ]), + enabled: "Enabled", enabledHint: "When disabled, the plugin does not run and the requests it applies to are forwarded as usual.", @@ -76,6 +154,7 @@ export const editorText = messages( discardTitle: "Discard unsaved changes", discardDescription: "Once the editor closes, the changes to the settings and the code are not saved.", + discardNewDescription: "Once the editor closes, the plugin is not installed and the code and settings are not kept.", keepEditing: "Keep editing", discard: "Discard changes", }, diff --git a/src/plugins/Editor.test.tsx b/src/plugins/Editor.test.tsx new file mode 100644 index 00000000..f7c47a86 --- /dev/null +++ b/src/plugins/Editor.test.tsx @@ -0,0 +1,140 @@ +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { setLang, textOf } from "@/i18n"; +import { TooltipRoot } from "@/ui/tip"; +import type { ManifestView, Overview } from "@/types"; + +/* + 添加插件:和编辑同一个编辑器(`Editing`),从模板起头。对话框挂在 portal 里、服务端渲染 + 画不出来,换成原地画的外壳;适用范围的建议要取数,换成空的。 +*/ +vi.mock("@/ui/dialog", () => { + const box = ({ children }: { children?: ReactNode }) =>
{children}
; + return { Dialog: box, DialogContent: box, DialogDescription: box, DialogFooter: box, DialogHeader: box, DialogTitle: box }; +}); +vi.mock("./suggestions", () => ({ useScopeSuggestions: () => ({ clients: [], models: [], upstreams: [] }) })); +vi.mock("@/control", () => ({ call: vi.fn() })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +const { Editing } = await import("./Editor"); +const { editorText } = await import("./Editor.i18n"); + +const ov = { providers: [], config_version: "v1" } as unknown as Overview; +const noop = () => {}; + +/** core 读模板读出来的样子(中文那一份) */ +function templateManifest(over: Partial = {}): ManifestView { + return { + name: "新插件", + description: "在系统提示词末尾附加「附加内容」中的文字。", + permissions: ["system"], + requests: ["conversation"], + scope: { clients: [], models: [], upstreams: [] }, + on_error: "reject", + reply_mode: "block", + settings_schema: [{ key: "note", kind: "string", label: "附加内容", value: "" }], + hooks: { request: true, reply_text: false, tool_call: false }, + ...over, + }; +} + +function renderNew(taken: string[] = [], manifest = templateManifest()) { + const source = textOf(editorText).template; + return renderToStaticMarkup( + + Promise.resolve("done")} + onState={noop} + onCancel={noop} + onDone={noop} + /> + , + ); +} + +describe("添加插件:和编辑同一个编辑器", () => { + it("先开着「代码」页,可以从文件导入;按钮是「安装」,没有选文件、粘贴和下一步", () => { + const html = renderNew(); + expect(html).toMatch(/]*role="tab"[^>]*aria-selected="true"[^>]*>代码/); + expect(html).toMatch(/]*role="tab"[^>]*aria-selected="false"[^>]*>设置/); + expect(html).toContain("从文件导入…"); + expect(html).toMatch(/]*>安装<\/button>/); + for (const gone of ["下一步", "选择文件", "粘贴代码", "审核插件", "保存"]) expect(html).not.toContain(gone); + }); + + it("标题栏和「设置」页从代码读出来:名字、权限、设置项;ID 按名字给好,启用关着", () => { + const html = renderNew(); + expect(html).toContain("新插件"); + expect(html).toContain("系统提示词"); + expect(html).toContain("ID plugin"); + expect(html).toMatch(/id="plugin-new-id"[^>]*value="plugin"/); + expect(html).toMatch(/id="plugin-enabled-new"[^>]*aria-checked="false"|aria-checked="false"[^>]*id="plugin-enabled-new"/); + // 设置项的标签照代码里写的;出错时、适用范围和编辑时一样 + expect(html).toContain("附加内容"); + expect(html).toContain("适用范围"); + }); + + it("建议的 ID 避开已有的;按代码里的名字给", () => { + expect(renderNew(["plugin"])).toMatch(/id="plugin-new-id"[^>]*value="plugin-2"/); + expect(renderNew([], templateManifest({ name: "Mask ticket IDs" }))).toMatch(/id="plugin-new-id"[^>]*value="mask-ticket-ids"/); + }); + + it("英文界面:英文的模板和按钮", () => { + setLang("en"); + const html = renderNew([], templateManifest({ name: "New plugin" })); + expect(html).toContain("Import from file…"); + expect(html).toMatch(/]*>Install<\/button>/); + expect(html).toMatch(/id="plugin-new-id"[^>]*value="new-plugin"/); + setLang("zh"); + }); +}); + +/** 模板里的 manifest 字面量:`export const manifest = ` 后面那一对花括号 */ +function literal(src: string): string { + const open = src.indexOf("{", src.indexOf("export const manifest = ")); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === "{") depth++; + else if (src[i] === "}" && --depth === 0) return src.slice(open, i + 1); + } + throw new Error("no manifest literal"); +} + +/** 去掉注释和字符串:两种语言的模板只差在这两样 */ +const code = (src: string) => src.replace(/\/\/.*$/gm, "").replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/\s+/g, " "); + +describe("插件模板", () => { + const both = (["zh", "en"] as const).map((lang) => ({ lang, src: editorText[lang].template })); + + it("manifest 是纯数据,写成 core 改写它时的样子,注释在它外面", () => { + for (const { lang, src } of both) { + const lit = literal(src); + expect(lit, lang).not.toMatch(/\/\/|\/\*|\$\{|`/); + // 两格缩进、一个字段一行;里面的对象和数组放得进 80 列(不算后面的逗号)就写成一行 + for (const line of lit.split("\n").slice(1, -1)) { + expect(line, lang).toMatch(/^ {2}[a-z_]+: .*,$/); + if (/: [[{]/.test(line)) expect(line.length - 1, lang).toBeLessThan(80); + } + // 照 JSON 读得出来:键加上引号、去掉结尾的逗号 + const m = JSON.parse(lit.replace(/([{,]\s*)([a-z_]+):/g, '$1"$2":').replace(/,(\s*[}\]])/g, "$1")); + expect(m.api, lang).toBe(1); + expect(m.permissions, lang).toEqual(["system"]); + expect(m.on_error, lang).toBe("reject"); + expect(m.settings.note, lang).toMatchObject({ type: "string", value: "" }); + expect(m.name.length, lang).toBeGreaterThan(0); + } + }); + + it("只有一个请求钩子,用得上申请的权限;两种语言只差在文字", () => { + for (const { src } of both) { + expect(src.match(/^export function (\w+)\(/gm)).toEqual(["export function onRequest("]); + expect(src.endsWith("}\n")).toBe(true); + } + expect(code(both[0]!.src)).toBe(code(both[1]!.src)); + }); +}); diff --git a/src/plugins/Editor.tsx b/src/plugins/Editor.tsx index 74abd9db..b67500ae 100644 --- a/src/plugins/Editor.tsx +++ b/src/plugins/Editor.tsx @@ -12,6 +12,7 @@ import { 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 { Skeleton } from "@/ui/skeleton"; import { ErrorState } from "@/ui/states"; import { StatusDot } from "@/ui/status-dot"; @@ -24,42 +25,55 @@ import { commonText } from "@/i18n/common.i18n"; import { coreText, errorText } from "@/i18n/core.i18n"; import { size } from "@/format"; import { ConfirmAction, focusSelf, useDialogFocus } from "@/keys/parts"; -import { DialogError } from "@/upstreams/parts"; +import { DialogError, FormItem } from "@/upstreams/parts"; import type { ManifestView, Overview, PluginInspection, PluginView } from "@/types"; import { pluginName } from "./defaults"; import { editorText } from "./Editor.i18n"; import { OnErrorField, ScopeFields, SettingsFields } from "./fields"; import { pluginFieldsText } from "./fields.i18n"; import { pluginLabelsText } from "./labels.i18n"; -import { manifestUnknown, MAX_SOURCE, saveAsks, shaPrefix, shapeChanged } from "./model"; +import { idProblem, manifestUnknown, MAX_SOURCE, saveAsks, shaPrefix, shapeChanged, suggestId } from "./model"; import { CodeLoading, CodeView, codeErrorOf, PermissionChips, PluginText, RequestKinds } from "./parts"; import { pluginPartsText } from "./parts.i18n"; import { useScopeSuggestions } from "./suggestions"; import { useEditing, valuesOf, type Form } from "./useEditing"; -import { savePlugin, type NativeWrite } from "./write"; +import { installPlugin, savePlugin, type NativeWrite } from "./write"; export type EditorTab = "settings" | "code"; +/** + * 编辑器开着的是哪一个:装着的插件(`from` 是从哪一份代码起头),或者一个还没装上的新插件 + * (`taken` 是已有的插件 ID) + */ +export type Target = + | { kind: "edit"; plugin: PluginView; from: "approved" | "current" } + | { kind: "new"; taken: readonly string[] }; + /** 编辑器打开时手上的东西 */ -interface Loaded { - /** core 拿来比的那一份:确认过的代码。读不到是 `null` */ +export interface Loaded { + /** core 拿来比的那一份:确认过的代码。读不到(和新插件)是 `null` */ approved: string | null; - /** 确认过的那一份读出来的 manifest。读不了是 `null` */ + /** 确认过的那一份读出来的 manifest。读不了(和新插件)是 `null` */ approvedManifest: ManifestView | null; /** 编辑器起头的那一份代码,和读它的结果 */ start: { source: string; inspection: PluginInspection }; } /** - * 取编辑器起头的代码:一般是确认过的那一份;从「审核更改」过来改磁盘上那一份的(`current`), - * 是磁盘上现在的那一份。两份都交给 core 读一遍:起头的那一份填表单,确认过的那一份用来 - * 判断保存时要不要在系统的确认框里点头。 + * 取编辑器起头的代码。新插件是一段装得上的模板(按界面语言)。装着的插件一般是确认过的 + * 那一份;从「审核更改」过来改磁盘上那一份的(`current`),是磁盘上现在的那一份。都交给 + * core 读一遍:起头的那一份填表单,确认过的那一份用来判断保存时要不要在系统的确认框里点头。 */ -async function load(id: string, from: "approved" | "current"): Promise { - const s = await call("PluginSourceDiff", null, id); +async function load(target: Target): Promise { + if (target.kind === "new") { + const source = textOf(editorText).template; + const inspection = await call("PluginInspect", { source }); + return { approved: null, approvedManifest: null, start: { source, inspection } }; + } + const s = await call("PluginSourceDiff", null, target.plugin.id); const approved = s.approved !== "" ? s.approved : s.current != null && s.current_sha256 === s.approved_sha256 ? s.current : null; - const source = from === "current" && s.current != null ? s.current : (approved ?? s.current); + const source = target.from === "current" && s.current != null ? s.current : (approved ?? s.current); if (source == null) throw { code: "", args: {}, text: textOf(editorText).noCode }; const [a, b] = await Promise.all([ approved != null ? call("PluginInspect", { source: approved }) : Promise.resolve(null), @@ -83,7 +97,7 @@ async function load(id: string, from: "approved" | "current"): Promise { */ export function PluginEditor({ plugin, - tab: initialTab, + tab, from = "approved", ov, native, @@ -101,6 +115,76 @@ export function PluginEditor({ onSaved: () => void; /** 打开「审核更改」(磁盘上的文件改过时) */ onReview: () => void; +}) { + return ( + + ); +} + +/** + * 添加插件:**和编辑同一个编辑器**,从一段装得上的模板起头,先开着「代码」页。 + * + * 代码可以就地改、粘贴,也可以从本地的 `.js` 文件导入(只从本地来:不从链接装,没有插件 + * 市场)。「设置」页和标题栏跟着代码走,和编辑时一样;「设置」页多一个插件 ID,按导入的 + * 文件名或代码里的名字给好,可以改。启用默认关着。 + * + * **按一次「安装」就装上**,没有另外的审核一步:改得了回答里工具调用的插件,紧接着在系统的 + * 确认框里点头(`plugin_install_confirmed`,Rust 再读一遍这份代码,写明插件名、权限和 SHA-256 + * 的前几位,和标题栏上的是同一段);别的插件直接装上(`CreatePlugin`)。 + */ +export function NewPluginEditor({ + taken, + ov, + native, + onClose, + onInstalled, +}: { + /** 已有的插件 ID */ + taken: readonly string[]; + ov: Overview; + native: NativeWrite; + onClose: () => void; + /** 装上了:插件的 ID */ + onInstalled: (id: string) => void; +}) { + return ( + + ); +} + +/** 编辑器的对话框:先取代码(取的时候是骨架),取到了交给 `Editing`;带着改动关掉时问一次 */ +function EditorDialog({ + target, + tab: initialTab, + ov, + native, + onClose, + onDone, + onReview, +}: { + target: Target; + tab: EditorTab; + ov: Overview; + native: NativeWrite; + onClose: () => void; + /** 保存了、装上了:插件的 ID */ + onDone: (id: string) => void; + onReview?: () => void; }) { const t = useText(editorText); const common = useText(commonText); @@ -111,7 +195,7 @@ export function PluginEditor({ useEffect(() => { let live = true; setLoadError(null); - load(plugin.id, from).then( + load(target).then( (l) => live && setLoaded(l), (e) => live && setLoadError(e), ); @@ -132,9 +216,7 @@ export function PluginEditor({ else then(); }; const requestClose = () => leave(onClose); - - const unknown = manifestUnknown(plugin); - const name = unknown ? {plugin.id} : ; + const isNew = target.kind === "new"; return ( !o && requestClose()}> @@ -152,26 +234,29 @@ export function PluginEditor({ > {loaded ? ( { state.current = s; }} onCancel={requestClose} - onSaved={onSaved} - onReview={() => leave(onReview)} + onDone={onDone} + onReview={onReview && (() => leave(onReview))} /> ) : ( <> -
+
: t.newTitle} />
{loadError != null ? ( - setAttempt((n) => n + 1)} compact /> + setAttempt((n) => n + 1)} + compact + /> ) : (
@@ -184,7 +269,7 @@ export function PluginEditor({ - + )} @@ -194,7 +279,7 @@ export function PluginEditor({ {t.discardTitle} - {t.discardDescription} + {isNew ? t.discardNewDescription : t.discardDescription} {t.keepEditing} @@ -216,6 +301,15 @@ export function PluginEditor({ ); } +/** 装着的插件叫什么:默认插件按界面语言说;读不出 manifest 的只有 id */ +function InstalledName({ plugin }: { plugin: PluginView }) { + return manifestUnknown(plugin) ? ( + {plugin.id} + ) : ( + + ); +} + /** 标题和下面一行:ID、SHA-256、权限、处理的请求种类,和贴着底边的两个标签 */ function Header({ title, meta, tabs }: { title: ReactNode; meta?: ReactNode; tabs?: ReactNode }) { return ( @@ -246,49 +340,70 @@ function useSha256(text: string): string | null { return sha?.text === text ? sha.hex : null; } -function Editing({ - plugin, +/** + * 取到代码之后的编辑器。**编辑和添加是同一个**,只差在几处: + * + * - 标题:装着的插件写它现在的名字;新插件写代码里的名字(照它自己写的),跟着代码变。 + * - 新插件的「设置」页多一个插件 ID(`idProblem` 查写法、保留词、重名);启用默认关着。 + * - 按钮:「保存」(`SavePlugin`,有改动才能按)和「安装」(`CreatePlugin`)。 + * + * 导出给测试用。 + */ +export function Editing({ + target, loaded, initialTab, - from, ov, native, - title, onState, onCancel, - onSaved, + onDone, onReview, }: { - plugin: PluginView; + target: Target; loaded: Loaded; initialTab: EditorTab; - from: "approved" | "current"; ov: Overview; native: NativeWrite; - title: ReactNode; onState: (s: { dirty: boolean; busy: boolean }) => void; onCancel: () => void; - onSaved: () => void; - onReview: () => void; + onDone: (id: string) => void; + onReview?: () => void; }) { const t = useText(editorText); const lt = useText(pluginLabelsText); const ft = useText(pluginFieldsText); const common = useText(commonText); - const ed = useEditing(plugin.id, loaded.start); + const plugin = target.kind === "edit" ? target.plugin : null; + const taken = target.kind === "new" ? target.taken : null; + const ed = useEditing(plugin?.id ?? null, loaded.start); const [tab, setTab] = useState( // 代码读不了、也没有可以填的表单:直接看代码 initialTab === "settings" && !loaded.start.inspection.manifest ? "code" : initialTab, ); - const [enabled, setEnabled] = useState(plugin.enabled); + const [enabled, setEnabled] = useState(plugin?.enabled ?? false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [cancelled, setCancelled] = useState(false); const suggestions = useScopeSuggestions(ov); + /** 新插件自己改过的 ID。没改过(`null`)就跟着建议走 */ + const [idDraft, setIdDraft] = useState(null); + /** 新插件最近一次从哪个文件导入的:建议的 ID 先按文件名 */ + const [fileName, setFileName] = useState(null); - const dirty = ed.source !== loaded.start.source || enabled !== plugin.enabled; + const m = ed.manifest; + const idOf = (name: string) => plugin?.id ?? idDraft ?? suggestId(name, fileName, taken ?? []); + const id = idOf(m?.name ?? ""); + const badId = taken ? idProblem(id, taken) : null; + const dirty = ed.source !== loaded.start.source || enabled !== (plugin?.enabled ?? false) || idDraft !== null; const problem = ed.form ? valuesOf(ed.form).problem : null; - const problemText = !problem ? null : problem.kind === "scope" ? ft.needOne(lt.scopeParts[problem.part]) : ft.numberBad(problem.label); + const problemText = problem + ? problem.kind === "scope" + ? ft.needOne(lt.scopeParts[problem.part]) + : ft.numberBad(problem.label) + : badId + ? t.idProblems[badId] + : null; useEffect(() => onState({ dirty, busy: saving }), [dirty, saving, onState]); const switchTab = (next: EditorTab) => { @@ -297,39 +412,50 @@ function Editing({ setTab(next); }; - async function save() { + async function commit() { setSaving(true); setError(null); setCancelled(false); + /** 写的是哪一个插件 */ + let wrote = id; try { await ed.flush(); - // 刚打的字可能刚读完:按「现在」的判断,不按点下保存那一刻渲染的样子 + // 刚打的字可能刚读完:按「现在」的判断,不按点下按钮那一刻渲染的样子 const { source, manifest: next, error: broken } = ed.now(); if (broken || !next) return; - const old = loaded.approvedManifest; - const asks = saveAsks({ - old: old?.permissions ?? (plugin.permissions.length > 0 ? plugin.permissions : null), - next: next.permissions, - turningOn: enabled && !plugin.enabled, - // 一定改了代码:manifest 里数据以外的东西变了,或者确认过的那一份读不了而代码不一样。 - // 别的(manifest 以外改了没有)交给 core 判断,它说要点头再请 Rust - codeChanged: source !== loaded.approved && (old == null || shapeChanged(old, next)), - }); - const r = await native((base) => savePlugin({ id: plugin.id, source, enabled, base_version: base }, asks)); + let r: "done" | "cancelled"; + if (plugin) { + const old = loaded.approvedManifest; + const asks = saveAsks({ + old: old?.permissions ?? (plugin.permissions.length > 0 ? plugin.permissions : null), + next: next.permissions, + turningOn: enabled && !plugin.enabled, + // 一定改了代码:manifest 里数据以外的东西变了,或者确认过的那一份读不了而代码不一样。 + // 别的(manifest 以外改了没有)交给 core 判断,它说要点头再请 Rust + codeChanged: source !== loaded.approved && (old == null || shapeChanged(old, next)), + }); + r = await native((base) => savePlugin({ id: plugin.id, source, enabled, base_version: base }, asks)); + } else { + // 代码里的名字可能刚变:跟着建议走的 ID 按现在的名字给 + wrote = idOf(next.name); + if (idProblem(wrote, taken ?? [])) return; + // 改得了工具调用的直接请 Rust(一个系统的确认框),别的直接装上 + const newId = wrote; + r = await native((base) => installPlugin({ source, id: newId, enabled, base_version: base }, next)); + } if (r === "cancelled") setCancelled(true); - else onSaved(); + else onDone(wrote); } catch (e) { - setError(errorText(e)); + setError(errorText(e, wrote)); } finally { setSaving(false); } } - const m = ed.manifest; const sha = useSha256(ed.source); const meta = ( <> - {t.id(plugin.id)} + {id && {t.id(id)}} {sha && {t.sha(shaPrefix(sha))}} {m && ( } ); + // 新插件的名字照代码里写的,跟着代码变(还没读出来时说这是在添加插件) + const title = plugin ? : m ? : t.newTitle; return ( switchTab(v as EditorTab)} className="flex min-h-0 flex-1 flex-col gap-0"> @@ -360,11 +488,12 @@ function Editing({
ed.editCode(s)} - onImport={(s) => ed.editCode(s, true)} + onImport={(s, name) => { + ed.editCode(s, true); + setFileName(name); + }} error={ed.error} checking={ed.checking} /> @@ -392,17 +524,17 @@ function Editing({ {problemText ? ( {problemText} ) : ( - dirty && {t.unsaved} + plugin && dirty && {t.unsaved} )} @@ -413,7 +545,8 @@ function Editing({ const PANE = "absolute inset-0 data-[state=inactive]:hidden"; function SettingsPane({ - plugin, + switchId, + idField, form, onForm, codeError, @@ -424,7 +557,10 @@ function SettingsPane({ onViewCode, onReview, }: { - plugin: PluginView; + /** 启用开关的 id(标签用) */ + switchId: string; + /** 新插件的 ID:输入框里的值、哪里不对、改了 */ + idField: { value: string; problem: string | null; onChange: (id: string) => void } | null; form: Form | null; onForm: (f: Form) => void; codeError: boolean; @@ -433,7 +569,7 @@ function SettingsPane({ onEnabled: (on: boolean) => void; suggestions: ReturnType; onViewCode: () => void; - onReview: () => void; + onReview?: () => void; }) { const t = useText(editorText); const lt = useText(pluginLabelsText); @@ -447,9 +583,11 @@ function SettingsPane({ tone="warning" title={t.diskChanged} actions={ - + onReview && ( + + ) } > {t.diskChangedHint} @@ -475,14 +613,30 @@ function SettingsPane({ <> {banners}
+ {idField && ( + + idField.onChange(e.target.value.trim())} + /> + + )} +
-
- +
{form && ( @@ -519,7 +673,8 @@ function CodePane({ }: { source: string; onChange: (s: string) => void; - onImport: (s: string) => void; + /** 从文件导入的整份代码,和文件名 */ + onImport: (s: string, fileName: string) => void; error: ReturnType["error"]; checking: boolean; }) { @@ -549,7 +704,7 @@ function CodePane({ return; } try { - onImport(await f.text()); + onImport(await f.text(), f.name); } catch { setImportError(t.readFailed); } diff --git a/src/plugins/PluginsPage.tsx b/src/plugins/PluginsPage.tsx index cef78282..1e201b5b 100644 --- a/src/plugins/PluginsPage.tsx +++ b/src/plugins/PluginsPage.tsx @@ -23,13 +23,12 @@ import { useConfigVersion } from "@/keys/data"; import type { Overview, PluginView, PluginWrite } from "@/types"; import { ChangedDialog } from "./ChangedDialog"; import { pluginDescription, pluginName } from "./defaults"; -import { PluginEditor, type EditorTab } from "./Editor"; +import { NewPluginEditor, PluginEditor, type EditorTab } from "./Editor"; import { DeleteDialog, ReorderDialog } from "./ListDialogs"; import { LogsDialog } from "./LogsDialog"; import { holdsToolCalls, manifestUnknown } from "./model"; import { PermissionChips, PluginText, RequestKinds, ScopeSummary, StatsCell, StatusOf } from "./parts"; import { pluginsPageText } from "./PluginsPage.i18n"; -import { SourceDialog } from "./SourceDialog"; import { TrialDialog } from "./TrialDialog"; import { setEnabled, type NativeWrite } from "./write"; @@ -57,7 +56,8 @@ type DialogState = * - **插件写的字一律按纯文本画**(名字、说明、设置项的标签、日志、报错),见 `PluginText`。 * core 自带的默认插件按界面语言说(`defaults.ts`)。 * - **插件的 JS 文件是唯一的真相**:出错时怎么办、适用范围、设置都写在代码里。点一个插件 - * 打开它的编辑器(`Editor`),「设置」和「代码」两页、一个保存。 + * 打开它的编辑器(`Editor`),「设置」和「代码」两页、一个保存。添加插件用的是同一个编辑器, + * 从一段模板起头,按钮是「安装」。 * - **只有改得了回答里工具调用的插件**,装上它、打开它、改它的代码、批准它改过的文件,要在 * 系统原生对话框里点头(`write.ts`)。别的写入不问;删除在应用里确认一次。 * - 启用、停用可以撤销,一按就写。 @@ -235,11 +235,12 @@ export default function PluginsPage({ ov, onChanged }: { ov: Overview; onChanged {dialog?.kind === "add" && ( - setDialog(null)} - onDone={(id) => { + onInstalled={(id) => { setDialog(null); setFocus(id); }} diff --git a/src/plugins/SourceDialog.i18n.ts b/src/plugins/SourceDialog.i18n.ts deleted file mode 100644 index a131381d..00000000 --- a/src/plugins/SourceDialog.i18n.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { messages } from "@/i18n"; - -export const sourceDialogText = messages( - { - addTitle: "添加插件", - 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: "代码", - id: "插件 ID", - idHint: "小写字母、数字和连字符,最多 40 个。", - idBad: "只能使用小写字母、数字和连字符,最多 40 个。", - idTaken: "已有插件使用此 ID。", - install: "安装", - /** 原生对话框里点了「取消」:什么都没写 */ - cancelled: "已取消,配置未改动。", - /** 只有回答钩子的插件,没有申请改请求的权限 */ - noPermissions: "未申请任何权限。", - }, - { - addTitle: "Add plugin", - 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", - 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", - cancelled: "Cancelled. The configuration was not changed.", - noPermissions: "No permissions requested.", - }, -); diff --git a/src/plugins/SourceDialog.tsx b/src/plugins/SourceDialog.tsx deleted file mode 100644 index 70f6e3d2..00000000 --- a/src/plugins/SourceDialog.tsx +++ /dev/null @@ -1,332 +0,0 @@ -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 { call } from "@/control"; -import { cn } from "@/lib/utils"; -import { useText } from "@/i18n"; -import { commonText } from "@/i18n/common.i18n"; -import { coreText, errorText } from "@/i18n/core.i18n"; -import { size } from "@/format"; -import { focusSelf } from "@/keys/parts"; -import { DialogError, FormItem } from "@/upstreams/parts"; -import type { ManifestView, PluginInspection } from "@/types"; -import { pluginLabelsText } from "./labels.i18n"; -import { ID_RE, MAX_SOURCE, shaPrefix, suggestId } from "./model"; -import { CodeBox, codeErrorOf, PermissionList, PluginText, RequestKinds, ScopeSummary } from "./parts"; -import { pluginPartsText } from "./parts.i18n"; -import { sourceDialogText } from "./SourceDialog.i18n"; -import { installPlugin, type NativeWrite } from "./write"; - -/** - * 添加插件:两步。 - * - * 1. **代码从哪儿来**:选一个本地的 `.js` 文件,或者粘贴。只从本地来 —— 不从链接装, - * 没有插件市场。选好之后交给 core 读一遍(`PluginInspect`,不写任何东西)。 - * 2. **审核**:完整的代码、申请的每一项权限和它的后果、代码里写的适用范围和出错时怎么办、 - * ID;读不了的写明第几行第几列。**按一次「安装」就装上**:改得了回答里工具调用的插件, - * 紧接着在系统的确认框里点头(Rust 再读一遍这份代码,写明插件名、权限和 SHA-256 的前几位, - * 和这里显示的是同一段),没有第二遍审核。 - * - * 出错时怎么办、适用范围、设置都写在代码里:装上之后在插件的编辑器里改。 - */ -export function SourceDialog({ - taken, - native, - onClose, - onDone, -}: { - /** 已有的插件 ID */ - taken: readonly string[]; - native: NativeWrite; - onClose: () => void; - /** 装上了:插件的 ID */ - onDone: (id: string) => void; -}) { - const t = useText(sourceDialogText); - 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 } | null>(null); - const [writing, setWriting] = useState(false); - const [cancelled, setCancelled] = useState(false); - const picker = useRef(null); - const [id, setId] = useState(""); - - const source = how === "file" ? (file?.text ?? "") : paste; - - 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 call("PluginInspect", { source }); - setRead({ result, source }); - const m = result.manifest; - if (m) setId((cur) => cur || suggestId(m.name, how === "file" ? (file?.name ?? null) : null, taken)); - 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 = !ID_RE.test(id) ? t.idBad : taken.includes(id) ? t.idTaken : null; - const blocked = !manifest || loadError != null || idProblem != null; - - async function install() { - if (!read || !manifest || blocked) return; - setWriting(true); - setError(null); - setCancelled(false); - try { - const r = await native((base) => installPlugin({ source: read.source, id, enabled: true, base_version: base }, manifest)); - if (r === "done") onDone(id); - else setCancelled(true); - } catch (e) { - setError(errorText(e)); - } finally { - setWriting(false); - } - } - - return ( - !o && !writing && onClose()}> - - - {step === "source" ? t.addTitle : t.reviewTitle} - {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 = ""; - }} - /> -
- ) : ( -