diff --git a/apps/desktop/src-tauri/src/desktop.rs b/apps/desktop/src-tauri/src/desktop.rs index 35c270c..31f2d85 100644 --- a/apps/desktop/src-tauri/src/desktop.rs +++ b/apps/desktop/src-tauri/src/desktop.rs @@ -175,7 +175,12 @@ pub fn run() -> ExitCode { tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec![AUTOSTART_ARG]), ))?; - let config = Config::desktop()?; + let config = { + let mut config = Config::desktop()?; + // 插件的 minAppVersion 按桌面应用版本判定,而不是内嵌 server 库的版本。 + config.app_version = env!("CARGO_PKG_VERSION").into(); + config + }; #[cfg(dev)] let config = { let mut config = config; diff --git a/apps/desktop/src/features/models/CursorModelCards.tsx b/apps/desktop/src/features/models/CursorModelCards.tsx index f6f426e..b1de27a 100644 --- a/apps/desktop/src/features/models/CursorModelCards.tsx +++ b/apps/desktop/src/features/models/CursorModelCards.tsx @@ -2,10 +2,10 @@ import type { IconifyIcon } from "@iconify/react/offline"; import { useEffect, useRef, useState, type ReactNode } from "react"; import Sortable from "sortablejs"; import type { Model, PluginModelDescriptor } from "../../shared/api"; -import { Button } from "../../shared/ui/Button"; import { Card } from "../../shared/ui/Card"; import { Icon } from "../../shared/ui/Icon"; import { chevronDownIcon, chevronRightIcon, claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons"; +import { TruncatedButton } from "../../shared/ui/TruncatedButton"; import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult"; import styles from "./CursorSettings.module.scss"; @@ -147,10 +147,10 @@ function ModelListRow({ model, disabled, testing, result, onTest, onEdit, onDupl
- - - - + + + +
; } @@ -170,8 +170,8 @@ function PluginModelRow({ model, disabled, testing, result, onTest, onSettings }
- - + +
; } @@ -261,10 +261,10 @@ function ModelGrid({
- - - - + onTest(model)} /> + onEdit(model)} /> + onDuplicate(model)} /> + onDelete(model)} />
; diff --git a/apps/desktop/src/features/models/CursorSettings.module.scss b/apps/desktop/src/features/models/CursorSettings.module.scss index 88d10cb..a96a8d3 100644 --- a/apps/desktop/src/features/models/CursorSettings.module.scss +++ b/apps/desktop/src/features/models/CursorSettings.module.scss @@ -183,9 +183,15 @@ } .modelCardActions { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; justify-content: flex-end; gap: 8px; + + // 空间不足时按钮收缩显示省略号,而不是换行。 + > button { + min-width: 0; + flex: 0 1 auto; + } } .deleteButton:hover { color: var(--vscode-errorForeground, #f48771); diff --git a/apps/desktop/src/features/plugins/PluginManagementPage.module.scss b/apps/desktop/src/features/plugins/PluginManagementPage.module.scss index 17753f5..8e2cf0f 100644 --- a/apps/desktop/src/features/plugins/PluginManagementPage.module.scss +++ b/apps/desktop/src/features/plugins/PluginManagementPage.module.scss @@ -31,9 +31,9 @@ justify-content: center; width: 42px; height: 42px; - background: var(--vscode-list-hoverBackground); - border: 1px solid var(--vscode-sideBar-border); + border-radius: 10px; + } .pluginIdentity { @@ -98,8 +98,21 @@ .cardActions { display: flex; align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; gap: 7px; + + > button { + min-width: 0; + flex: 0 1 auto; + } +} + + +// 主操作靠左,"更多"推到行尾,两端对齐。 +.moreAction { + display: flex; + flex: 0 0 auto; + margin-left: auto; } .gate, diff --git a/apps/desktop/src/features/plugins/PluginManagementPage.tsx b/apps/desktop/src/features/plugins/PluginManagementPage.tsx index 02479ba..2965aa2 100644 --- a/apps/desktop/src/features/plugins/PluginManagementPage.tsx +++ b/apps/desktop/src/features/plugins/PluginManagementPage.tsx @@ -3,11 +3,12 @@ import { api, pluginText, type PluginDescriptor, type PluginImportFile, type Plu import { useI18n } from "../../i18n/store"; import { PageContent } from "../../shell/layout/PageContent"; import { appStore, useAppStore } from "../../shared/store/appStore"; +import { ActionMenu } from "../../shared/ui/ActionMenu"; import { Button } from "../../shared/ui/Button"; import { Card } from "../../shared/ui/Card"; -import { Icon } from "../../shared/ui/Icon"; import { Modal } from "../../shared/ui/Modal"; import { useMessage } from "../../shared/ui/message"; +import { TruncatedButton } from "../../shared/ui/TruncatedButton"; import { PluginAddPanel, PluginSettingsPanel } from "./PluginResourcePanels"; import styles from "./PluginManagementPage.module.scss"; @@ -167,43 +168,93 @@ function PluginCard({ plugin, onOpen }: { } }; - return -
- -
- {plugin.name} - {subtitle} + return ( + +
+ +
+ {plugin.name} + {subtitle} +
+ + {configured ? t("已配置") : t("未配置")} +
- - {configured ? t("已配置") : t("未配置")} - -
-
- {t("{accounts} 个账号 · {models} 个模型", { accounts: accountCount, models: modelCount })} - {plugin.author && {plugin.author}} -
-
- - {configured && } - {importResource && } - {exportResource && } - {importResource && void importFiles(event.target.files)} - />} -
- ; +
+ + {t("{accounts} 个账号 · {models} 个模型", { + accounts: accountCount, + models: modelCount, + })} + + + {[`v${plugin.version}`, plugin.author].filter(Boolean).join(" · ")} + +
+
+ onOpen(plugin.id, "add")} + /> + {configured && ( + onOpen(plugin.id, "settings")} + /> + )} + {(importResource || exportResource) && ( + + importInput.current?.click(), + }, + ] + : []), + ...(exportResource + ? [ + { + id: "export", + label: t("批量导出"), + onSelect: () => + void api.openExternalUrl( + api.pluginResourceExportUrl( + ports.service_port, + plugin.id, + exportResource.type, + ), + ), + }, + ] + : []), + ]} + /> + + )} + {importResource && ( + void importFiles(event.target.files)} + /> + )} +
+ + ); } function RuntimeProgressModal({ open, status, starting, onClose }: { open: boolean; status: PluginRuntimeStatus | null; starting: boolean; onClose: () => void }) { diff --git a/apps/desktop/src/i18n/generated/catalog.json b/apps/desktop/src/i18n/generated/catalog.json index c3cf1f2..0888729 100644 --- a/apps/desktop/src/i18n/generated/catalog.json +++ b/apps/desktop/src/i18n/generated/catalog.json @@ -165,7 +165,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 251, + "line": 259, "column": 21 } ] @@ -225,7 +225,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 182, + "line": 183, "column": 14 } ] @@ -594,7 +594,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 120, + "line": 121, "column": 16 } ] @@ -638,7 +638,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 154, + "line": 155, "column": 23 } ] @@ -878,7 +878,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 235, + "line": 243, "column": 122 } ] @@ -996,7 +996,7 @@ }, { "file": "features/plugins/PluginManagementPage.tsx", - "line": 223, + "line": 231, "column": 70 }, { @@ -1318,6 +1318,18 @@ } ] }, + "38844b135cf70dfc": { + "source": "更多", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 190, + "column": 16 + } + ] + }, "393e1241552b1870": { "source": "请求", "kind": "text", @@ -1510,7 +1522,7 @@ }, { "file": "features/plugins/PluginManagementPage.tsx", - "line": 223, + "line": 231, "column": 80 } ] @@ -1522,7 +1534,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 109, + "line": 110, "column": 68 } ] @@ -1786,7 +1798,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 222, + "line": 230, "column": 12 } ] @@ -1810,7 +1822,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 109, + "line": 110, "column": 46 } ] @@ -2152,7 +2164,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 215, + "line": 223, "column": 7 } ] @@ -2682,7 +2694,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 250, + "line": 258, "column": 31 } ] @@ -2788,7 +2800,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 178, + "line": 179, "column": 34 } ] @@ -2812,7 +2824,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 102, + "line": 103, "column": 25 } ] @@ -2842,7 +2854,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 235, + "line": 243, "column": 20 } ] @@ -2958,7 +2970,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 247, + "line": 255, "column": 32 } ] @@ -3131,7 +3143,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 186, + "line": 187, "column": 88 } ] @@ -3167,7 +3179,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 249, + "line": 257, "column": 31 } ] @@ -3302,12 +3314,12 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 95, + "line": 96, "column": 9 }, { "file": "features/plugins/PluginManagementPage.tsx", - "line": 217, + "line": 225, "column": 9 } ] @@ -3345,7 +3357,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 77, + "line": 78, "column": 11 } ] @@ -3357,7 +3369,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 121, + "line": 122, "column": 14 } ] @@ -3369,7 +3381,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 248, + "line": 256, "column": 30 } ] @@ -3383,7 +3395,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 156, + "line": 157, "column": 17 }, { @@ -3630,7 +3642,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 78, + "line": 79, "column": 11 } ] @@ -3876,12 +3888,12 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 100, + "line": 101, "column": 7 }, { "file": "features/plugins/PluginManagementPage.tsx", - "line": 238, + "line": 246, "column": 70 } ] @@ -3905,7 +3917,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 98, + "line": 99, "column": 11 } ] @@ -4158,7 +4170,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 109, + "line": 110, "column": 83 } ] @@ -4196,7 +4208,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 223, + "line": 231, "column": 45 } ] @@ -4239,7 +4251,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 97, + "line": 98, "column": 11 } ] @@ -4369,7 +4381,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 103, + "line": 104, "column": 9 } ] @@ -4480,8 +4492,8 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 195, - "column": 10 + "line": 200, + "column": 20 } ] }, @@ -4605,7 +4617,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 239, + "line": 247, "column": 44 } ] @@ -4728,7 +4740,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 178, + "line": 179, "column": 23 } ] @@ -5146,7 +5158,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 187, + "line": 188, "column": 90 } ] @@ -5274,8 +5286,8 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 189, - "column": 22 + "line": 194, + "column": 32 } ] }, @@ -5286,7 +5298,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 64, + "line": 65, "column": 14 }, { @@ -5391,7 +5403,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 230, + "line": 238, "column": 23 } ] @@ -5403,7 +5415,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 109, + "line": 110, "column": 19 }, { @@ -5432,12 +5444,12 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 93, + "line": 94, "column": 7 }, { "file": "features/plugins/PluginManagementPage.tsx", - "line": 246, + "line": 254, "column": 29 } ] @@ -5493,8 +5505,8 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 189, - "column": 35 + "line": 194, + "column": 45 } ] }, diff --git a/apps/desktop/src/i18n/locales/en-US.json b/apps/desktop/src/i18n/locales/en-US.json index ade820b..3598f84 100644 --- a/apps/desktop/src/i18n/locales/en-US.json +++ b/apps/desktop/src/i18n/locales/en-US.json @@ -86,6 +86,7 @@ "378bb0eec39fa8a2": "Last page", "37cb98ff4d5dcfcc": "Successful {successful} / failed {failed}", "382f2e3419a02fef": "Only clear detailed records", + "38844b135cf70dfc": "More", "393e1241552b1870": "Request", "398f8e6c6f0a0b97": "Continue selecting or typing", "39f52eee100131d7": "Cached input", diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json index e8d15f9..6412993 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/i18n/locales/zh-CN.json @@ -86,6 +86,7 @@ "378bb0eec39fa8a2": "最后一页", "37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}", "382f2e3419a02fef": "仅清理详细记录", + "38844b135cf70dfc": "更多", "393e1241552b1870": "请求", "398f8e6c6f0a0b97": "继续选择或输入", "39f52eee100131d7": "缓存输入", diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts index c2c3af4..03d8e22 100644 --- a/apps/desktop/src/shared/api.ts +++ b/apps/desktop/src/shared/api.ts @@ -254,6 +254,7 @@ export interface PluginProviderDescriptor { export interface PluginDescriptor { id: string; name: string; + version: string; author: string | null; icon: string; providers: PluginProviderDescriptor[]; diff --git a/apps/desktop/src/shared/ui/ActionMenu.module.scss b/apps/desktop/src/shared/ui/ActionMenu.module.scss new file mode 100644 index 0000000..3eb10b8 --- /dev/null +++ b/apps/desktop/src/shared/ui/ActionMenu.module.scss @@ -0,0 +1,41 @@ +@use "../../styles/typography" as type; + +.menu { + position: fixed; + z-index: 14000; + min-width: 132px; + overflow: hidden; + padding: 4px; + background: var(--vscode-dropdown-background); + border: 1px solid var(--vscode-dropdown-border); + border-radius: 6px; + box-shadow: var(--oa-dropdown-shadow); + + button { + width: 100%; + min-height: 30px; + display: block; + padding: 5px 8px; + color: var(--vscode-dropdown-foreground); + text-align: left; + background: transparent; + border: 0; + border-radius: 4px; + font-size: type.$font-size-xs; + white-space: nowrap; + + &:hover:not(:disabled) { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); + } + + &:disabled { + color: var(--vscode-descriptionForeground); + cursor: not-allowed; + } + } +} + +.openIcon { + transform: rotate(180deg); +} diff --git a/apps/desktop/src/shared/ui/ActionMenu.tsx b/apps/desktop/src/shared/ui/ActionMenu.tsx new file mode 100644 index 0000000..c335e46 --- /dev/null +++ b/apps/desktop/src/shared/ui/ActionMenu.tsx @@ -0,0 +1,99 @@ +import { autoUpdate, computePosition, flip, offset, shift } from "@floating-ui/dom"; +import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Button } from "./Button"; +import { Icon } from "./Icon"; +import { chevronDownIcon } from "./icons"; +import styles from "./ActionMenu.module.scss"; + +export type ActionMenuItem = { + id: string; + label: string; + disabled?: boolean; + onSelect: () => void; +}; + +/** 触发器 + 动作列表的下拉菜单,用于容纳卡片上的次要操作。 */ +export function ActionMenu({ label, items, disabled }: { + label: string; + items: ActionMenuItem[]; + disabled?: boolean; +}) { + const trigger = useRef(null); + const menu = useRef(null); + const menuId = useId(); + const [open, setOpen] = useState(false); + const [position, setPosition] = useState({ left: 0, top: 0 }); + + useLayoutEffect(() => { + if (!open || !trigger.current || !menu.current) return; + return autoUpdate(trigger.current, menu.current, () => + void computePosition(trigger.current!, menu.current!, { + placement: "bottom-end", + middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 })], + }).then(({ x, y }) => setPosition({ left: x, top: y }))); + }, [open]); + + useEffect(() => { + if (!open) return; + const outside = (event: PointerEvent) => { + if (!trigger.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("pointerdown", outside); + return () => document.removeEventListener("pointerdown", outside); + }, [open]); + + const close = () => { + setOpen(false); + trigger.current?.focus(); + }; + + return <> + + {open && createPortal( + , + document.body, + )} + ; +} diff --git a/apps/desktop/src/shared/ui/Button.tsx b/apps/desktop/src/shared/ui/Button.tsx index 3b9d73a..d40b667 100644 --- a/apps/desktop/src/shared/ui/Button.tsx +++ b/apps/desktop/src/shared/ui/Button.tsx @@ -1,4 +1,4 @@ -import type { ButtonHTMLAttributes } from "react"; +import type { ComponentProps } from "react"; import controls from "./Controls.module.scss"; export type ButtonVariant = "primary" | "secondary"; @@ -10,7 +10,7 @@ export function Button({ className, type = "button", ...props -}: ButtonHTMLAttributes & { +}: ComponentProps<"button"> & { variant?: ButtonVariant; size?: ButtonSize; }) { diff --git a/apps/desktop/src/shared/ui/TruncatedButton.module.scss b/apps/desktop/src/shared/ui/TruncatedButton.module.scss new file mode 100644 index 0000000..7d8c1f3 --- /dev/null +++ b/apps/desktop/src/shared/ui/TruncatedButton.module.scss @@ -0,0 +1,6 @@ +.label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/apps/desktop/src/shared/ui/TruncatedButton.tsx b/apps/desktop/src/shared/ui/TruncatedButton.tsx new file mode 100644 index 0000000..3b5c111 --- /dev/null +++ b/apps/desktop/src/shared/ui/TruncatedButton.tsx @@ -0,0 +1,22 @@ +import { useRef, useState, type ComponentProps } from "react"; +import { Button } from "./Button"; +import { TooltipTrigger } from "./TooltipTrigger"; +import styles from "./TruncatedButton.module.scss"; + +/** + * 文本被省略号截断时才显示完整文案悬浮提示的按钮。 + * 按钮是 flex 容器,省略号只作用在内层文本 span 上; + * 截断在悬停/聚焦时现测——挂载时字体可能未加载,提前测会得到错误结果。 + */ +export function TruncatedButton({ label, ...props }: ComponentProps & { label: string }) { + const element = useRef(null); + const [truncated, setTruncated] = useState(false); + const measure = () => { + const text = element.current; + if (text) setTruncated(text.scrollWidth > text.clientWidth); + }; + const button = ; + return truncated ? {button} : button; +} diff --git a/apps/docs/content/docs/plugin-development.en.mdx b/apps/docs/content/docs/plugin-development.en.mdx index c10ad77..b435127 100644 --- a/apps/docs/content/docs/plugin-development.en.mdx +++ b/apps/docs/content/docs/plugin-development.en.mdx @@ -21,7 +21,7 @@ Plugins implement three capability interfaces defined by the core: **Provider** └── models-.json # model catalogs persisted by the core ``` -Built-in plugins live at `server/plugins/build-in/` (such as `codex-auth`); debug builds discover them automatically, while release builds only read `plugins/installed/`. When the user directory contains a plugin with the same ID, the user directory wins. +Built-in plugin sources live at `server/plugins/build-in/` (such as `codex-auth`); they are bundled into the binary and pre-installed into `plugins/installed/` keyed by version. Debug builds load the source directory first so edits take effect immediately. ## Static manifest @@ -30,6 +30,9 @@ Built-in plugins live at `server/plugins/build-in/` (such as `codex-auth`); debu "apiVersion": 1, "id": "com.example.subscription", "name": "Example Subscription", + "version": "0.1.0", + "author": "@example", + "minAppVersion": "0.1.0", "icon": "assets/icon.svg", "entry": "main.ts", "permissions": { @@ -38,7 +41,7 @@ Built-in plugins live at `server/plugins/build-in/` (such as `codex-auth`); debu } ``` -`permissions.network` accepts exact hostnames only. All plugin network requests must use HTTPS and hit this allowlist. +`permissions.network` accepts exact hostnames only. All plugin network requests must use HTTPS and hit this allowlist. `version` is required; the plugin is ignored when the app version is older than `minAppVersion`. Built-in plugins are pre-installed into `plugins/installed/` keyed by `version`: startup writes nothing when the version matches and resyncs the whole directory (pruning stale files) when it changes. ## Entry and capabilities diff --git a/apps/docs/content/docs/plugin-development.mdx b/apps/docs/content/docs/plugin-development.mdx index 205f53e..eaef8eb 100644 --- a/apps/docs/content/docs/plugin-development.mdx +++ b/apps/docs/content/docs/plugin-development.mdx @@ -21,7 +21,7 @@ icon: Blocks └── models-.json # 核心持久化的模型目录 ``` -内置插件位于 `server/plugins/build-in/`(如 `codex-auth`),Debug 构建自动发现;发布构建只读取 `plugins/installed/`。用户目录中存在同 ID 插件时,以用户目录为准。 +内置插件源码位于 `server/plugins/build-in/`(如 `codex-auth`),随二进制打包并按版本预装进 `plugins/installed/`;Debug 构建下源码目录优先加载,便于热改。 ## 静态清单 @@ -30,6 +30,9 @@ icon: Blocks "apiVersion": 1, "id": "com.example.subscription", "name": "Example Subscription", + "version": "0.1.0", + "author": "@example", + "minAppVersion": "0.1.0", "icon": "assets/icon.svg", "entry": "main.ts", "permissions": { @@ -38,7 +41,7 @@ icon: Blocks } ``` -`permissions.network` 只能包含精确主机名。所有插件网络请求都必须是 HTTPS 且命中该白名单。 +`permissions.network` 只能包含精确主机名。所有插件网络请求都必须是 HTTPS 且命中该白名单。`version` 必填;应用版本低于 `minAppVersion` 时插件会被忽略。内置插件按 `version` 预装进 `plugins/installed/`:版本一致时启动零写盘,版本变化时整目录同步并清理旧文件。 ## 入口与能力 diff --git a/server/plugins/build-in/codex-auth/plugin.json b/server/plugins/build-in/codex-auth/plugin.json index bf2108b..6f8536d 100644 --- a/server/plugins/build-in/codex-auth/plugin.json +++ b/server/plugins/build-in/codex-auth/plugin.json @@ -2,7 +2,9 @@ "apiVersion": 1, "id": "dev.cursorbyok.examples.codex-auth", "name": "Codex", + "version": "0.1.0", "author": "@leookun", + "minAppVersion": "0.1.0", "icon": "assets/codex.svg", "entry": "main.ts", "permissions": { diff --git a/server/src/app.rs b/server/src/app.rs index 3893d2d..7b4a536 100644 --- a/server/src/app.rs +++ b/server/src/app.rs @@ -39,7 +39,11 @@ impl App { let assets = PromptAssets::embedded()?; let compiler = PromptCompiler::new(assets); let plugin_runtime = PluginRuntime::managed()?; - let plugins = PluginRegistry::managed(store.clone(), plugin_runtime.clone())?; + let plugins = PluginRegistry::managed( + store.clone(), + plugin_runtime.clone(), + config.app_version.clone(), + )?; let provider = std::sync::Arc::new(ProviderRouter::new( store.clone(), plugins.clone(), diff --git a/server/src/config.rs b/server/src/config.rs index a9c1964..ed8ce51 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -58,6 +58,8 @@ pub struct Config { pub provider_stream_idle_timeout: Duration, pub console: Option, pub use_persisted_ports: bool, + /// 面向用户的应用版本;桌面壳会覆盖为自身版本,用于插件 minAppVersion 门控。 + pub app_version: String, } #[derive(Clone)] @@ -109,6 +111,7 @@ impl Config { provider_stream_idle_timeout: DEFAULT_PROVIDER_STREAM_IDLE_TIMEOUT, console, use_persisted_ports: false, + app_version: env!("CARGO_PKG_VERSION").into(), }) } @@ -122,6 +125,7 @@ impl Config { provider_stream_idle_timeout: DEFAULT_PROVIDER_STREAM_IDLE_TIMEOUT, console: None, use_persisted_ports: true, + app_version: env!("CARGO_PKG_VERSION").into(), }) } } diff --git a/server/src/plugin/builtin.rs b/server/src/plugin/builtin.rs index 5c9fd6d..10bcca9 100644 --- a/server/src/plugin/builtin.rs +++ b/server/src/plugin/builtin.rs @@ -1,10 +1,10 @@ -//! Materializes built-in plugins bundled in the binary into the managed dir. -use std::path::PathBuf; +//! Pre-installs bundled built-in plugins into the user's installed directory. +use std::path::Path; use super::definition::write_if_changed; -use crate::{config, Result}; +use crate::Result; -/// 随二进制打包的内置插件文件;发布构建没有源码目录,靠这里落盘。 +/// 随二进制打包的内置插件文件;发布构建没有源码目录,靠这里预装。 const CODEX_AUTH: &[(&str, &str)] = &[ ( "plugin.json", @@ -57,14 +57,42 @@ const CODEX_AUTH: &[(&str, &str)] = &[ ), ]; -/// 把内置插件写入受管目录并返回该目录,作为插件目录的扫描根之一。 -pub(super) fn materialize() -> Result { - let root = config::managed_data_dir()?.join("plugins/build-in"); - write_plugin(&root.join("codex-auth"), CODEX_AUTH)?; - Ok(root) +const PLUGINS: &[(&str, &[(&str, &str)])] = &[("codex-auth", CODEX_AUTH)]; + +/// 把内置插件预装到 installed 目录。manifest 的 version 是缓存键: +/// 版本一致时零写盘;版本变化时整目录同步并清理旧版本残留文件。 +pub(super) fn install(installed: &Path) -> Result<()> { + for (name, files) in PLUGINS { + let directory = installed.join(name); + if disk_version(&directory) == Some(embedded_version(files)?) { + continue; + } + write_plugin(&directory, files)?; + } + Ok(()) } -fn write_plugin(directory: &std::path::Path, files: &[(&str, &str)]) -> Result<()> { +fn embedded_version(files: &[(&str, &str)]) -> Result { + let manifest = files + .iter() + .find(|(name, _)| *name == "plugin.json") + .map(|(_, content)| *content) + .expect("built-in plugin bundles plugin.json"); + let value: serde_json::Value = serde_json::from_str(manifest)?; + value + .get("version") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + .ok_or_else(|| crate::Error::Config("built-in plugin manifest requires version".into())) +} + +fn disk_version(directory: &Path) -> Option { + let manifest = std::fs::read_to_string(directory.join("plugin.json")).ok()?; + let value: serde_json::Value = serde_json::from_str(&manifest).ok()?; + Some(value.get("version")?.as_str()?.to_owned()) +} + +fn write_plugin(directory: &Path, files: &[(&str, &str)]) -> Result<()> { for (relative, content) in files { let path = directory.join(relative); let parent = path.parent().expect("plugin file path has a parent"); @@ -81,5 +109,75 @@ fn write_plugin(directory: &std::path::Path, files: &[(&str, &str)]) -> Result<( std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; } } + prune_unknown_files(directory, directory, files)?; Ok(()) } + +/// 删除插件目录中不在嵌入清单里的文件与空目录(旧版本残留)。 +fn prune_unknown_files(root: &Path, directory: &Path, files: &[(&str, &str)]) -> Result<()> { + for entry in std::fs::read_dir(directory)? { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_dir() { + prune_unknown_files(root, &path, files)?; + if std::fs::read_dir(&path)?.next().is_none() { + std::fs::remove_dir(&path)?; + } + continue; + } + let known = files + .iter() + .any(|(relative, _)| root.join(relative) == path); + if !known { + std::fs::remove_file(&path)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn embedded_main() -> &'static str { + CODEX_AUTH + .iter() + .find(|(name, _)| *name == "main.ts") + .unwrap() + .1 + } + + #[test] + fn install_is_version_gated_and_syncs_on_version_change() { + let root = tempfile::tempdir().unwrap(); + let plugin = root.path().join("codex-auth"); + + install(root.path()).unwrap(); + assert_eq!( + std::fs::read_to_string(plugin.join("main.ts")).unwrap(), + embedded_main() + ); + + // 版本一致:本地改动与额外文件保持原样,不发生任何写盘。 + std::fs::write(plugin.join("main.ts"), "edited").unwrap(); + std::fs::write(plugin.join("stale.ts"), "extra").unwrap(); + install(root.path()).unwrap(); + assert_eq!( + std::fs::read_to_string(plugin.join("main.ts")).unwrap(), + "edited" + ); + assert!(plugin.join("stale.ts").exists()); + + // 版本变化:整目录同步回嵌入内容并清理残留。 + let manifest = std::fs::read_to_string(plugin.join("plugin.json")).unwrap(); + let mut value: serde_json::Value = serde_json::from_str(&manifest).unwrap(); + value["version"] = serde_json::Value::String("0.0.1".into()); + std::fs::write(plugin.join("plugin.json"), value.to_string()).unwrap(); + install(root.path()).unwrap(); + assert_eq!( + std::fs::read_to_string(plugin.join("main.ts")).unwrap(), + embedded_main() + ); + assert!(!plugin.join("stale.ts").exists()); + } +} diff --git a/server/src/plugin/catalog.rs b/server/src/plugin/catalog.rs index 3d010d5..69fda85 100644 --- a/server/src/plugin/catalog.rs +++ b/server/src/plugin/catalog.rs @@ -21,6 +21,7 @@ const MAX_ICON_BYTES: u64 = 1024 * 1024; pub struct PluginCatalog { roots: Vec, definition_loader: PluginDefinitionLoader, + app_version: String, } #[derive(Clone)] @@ -33,7 +34,7 @@ pub(crate) struct PluginEntry { } impl PluginCatalog { - pub fn managed() -> Result { + pub fn managed(app_version: String) -> Result { let installed = config::managed_data_dir()?.join("plugins/installed"); fs::create_dir_all(&installed)?; #[cfg(unix)] @@ -41,15 +42,21 @@ impl PluginCatalog { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&installed, fs::Permissions::from_mode(0o700))?; } - // 扫描顺序即优先级:用户安装目录 > 源码内置目录(仅 debug,便于热改) - // > 随二进制打包后落盘的内置目录;同 ID 时靠前的覆盖靠后的。 - let mut roots = vec![installed]; + // 内置插件按版本预装进 installed;版本一致时不写盘。 + super::builtin::install(&installed)?; + // 扫描顺序即优先级:debug 下源码目录优先,保证内置插件热改生效; + // 发布构建只有 installed 一个根。 #[cfg(debug_assertions)] - roots.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("plugins/build-in")); - roots.push(super::builtin::materialize()?); + let roots = vec![ + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("plugins/build-in"), + installed, + ]; + #[cfg(not(debug_assertions))] + let roots = vec![installed]; Ok(Self { roots, definition_loader: PluginDefinitionLoader::managed()?, + app_version, }) } @@ -69,7 +76,14 @@ impl PluginCatalog { }; directories.sort(); for directory in directories { - match load_plugin(&directory, &self.definition_loader, executable).await { + match load_plugin( + &directory, + &self.definition_loader, + executable, + &self.app_version, + ) + .await + { Ok(entry) => { if plugins.contains_key(&entry.manifest.id) { tracing::warn!(plugin = %entry.manifest.id, path = %directory.display(), "ignoring duplicate plugin"); @@ -98,6 +112,7 @@ impl PluginCatalog { let manifest: PluginManifest = serde_json::from_slice(&fs::read(directory.join(MANIFEST_FILE_NAME))?)?; manifest.validate(&directory)?; + require_app_version(&manifest, &self.app_version)?; let icon = icon_data_url(&directory, &manifest.icon)?; Ok((manifest, icon)) })(); @@ -126,14 +141,30 @@ fn child_directories(root: &Path) -> Result> { Ok(directories) } +/// 应用过旧时拒绝加载,让插件的 minAppVersion 声明生效。 +fn require_app_version(manifest: &PluginManifest, app_version: &str) -> Result<()> { + let Some(minimum) = &manifest.min_app_version else { + return Ok(()); + }; + if super::manifest::version_at_least(app_version, minimum) { + return Ok(()); + } + Err(Error::Config(format!( + "plugin '{}' requires app version {minimum} or newer (current {app_version})", + manifest.id + ))) +} + async fn load_plugin( directory: &Path, loader: &PluginDefinitionLoader, executable: &Path, + app_version: &str, ) -> Result { let manifest: PluginManifest = serde_json::from_slice(&fs::read(directory.join(MANIFEST_FILE_NAME))?)?; manifest.validate(directory)?; + require_app_version(&manifest, app_version)?; let icon = icon_data_url(directory, &manifest.icon)?; let entry = directory.join(&manifest.entry).canonicalize()?; let definition = loader.load(executable, directory, &entry).await?; @@ -279,6 +310,7 @@ mod tests { let catalog = PluginCatalog { roots: vec![root], definition_loader: PluginDefinitionLoader::for_test(sdk.path()).unwrap(), + app_version: env!("CARGO_PKG_VERSION").into(), }; assert!(!catalog.manifests().is_empty()); } diff --git a/server/src/plugin/descriptor.rs b/server/src/plugin/descriptor.rs index b8f264c..89ac1b0 100644 --- a/server/src/plugin/descriptor.rs +++ b/server/src/plugin/descriptor.rs @@ -71,6 +71,7 @@ pub const OAUTH2_ADD_METHOD: &str = "oauth2.0"; pub struct PluginDescriptor { pub id: String, pub name: String, + pub version: String, pub author: Option, pub icon: String, pub providers: Vec, diff --git a/server/src/plugin/manifest.rs b/server/src/plugin/manifest.rs index 31cf457..1039382 100644 --- a/server/src/plugin/manifest.rs +++ b/server/src/plugin/manifest.rs @@ -14,8 +14,13 @@ pub struct PluginManifest { pub api_version: u32, pub id: String, pub name: String, + /// 插件自身版本;内置插件预装时以它为缓存键决定是否重新落盘。 + pub version: String, #[serde(default)] pub author: Option, + /// 插件要求的最低应用版本;应用过旧时插件被忽略。 + #[serde(default)] + pub min_app_version: Option, pub icon: String, pub entry: String, #[serde(default)] @@ -39,6 +44,12 @@ impl PluginManifest { } validate_id(&self.id, "plugin id")?; required(&self.name, "plugin name")?; + parse_version(&self.version) + .ok_or_else(|| Error::Config(format!("invalid plugin version: {}", self.version)))?; + if let Some(minimum) = &self.min_app_version { + parse_version(minimum) + .ok_or_else(|| Error::Config(format!("invalid plugin minAppVersion: {minimum}")))?; + } validate_entry_path(directory, &self.entry)?; validate_asset_path(directory, &self.icon)?; let mut hosts = HashSet::new(); @@ -55,6 +66,23 @@ impl PluginManifest { } } +/// 解析 semver 的核心三段(忽略预发布/构建后缀),格式非法返回 None。 +pub(super) fn parse_version(value: &str) -> Option<(u64, u64, u64)> { + let core = value.split(['-', '+']).next()?; + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + parts.next().is_none().then_some((major, minor, patch)) +} + +pub(super) fn version_at_least(actual: &str, minimum: &str) -> bool { + match (parse_version(actual), parse_version(minimum)) { + (Some(actual), Some(minimum)) => actual >= minimum, + _ => false, + } +} + pub(super) fn validate_id(value: &str, label: &str) -> Result<()> { static ID: std::sync::OnceLock = std::sync::OnceLock::new(); let expression = ID.get_or_init(|| Regex::new(r"^[a-z0-9]+(?:[._-][a-z0-9]+)*$").unwrap()); @@ -172,4 +200,14 @@ mod tests { assert!(validate_network_host("example.com:443").is_err()); assert!(validate_network_host("example.com").is_ok()); } + + #[test] + fn compares_semver_cores_and_ignores_prerelease_suffixes() { + assert_eq!(parse_version("0.1.5-beta.1"), Some((0, 1, 5))); + assert_eq!(parse_version("1.2"), None); + assert!(version_at_least("0.1.5-beta.1", "0.1.5")); + assert!(version_at_least("0.2.0", "0.1.9")); + assert!(!version_at_least("0.1.4", "0.1.5")); + assert!(!version_at_least("bogus", "0.1.0")); + } } diff --git a/server/src/plugin/registry.rs b/server/src/plugin/registry.rs index 2fe6d76..945c700 100644 --- a/server/src/plugin/registry.rs +++ b/server/src/plugin/registry.rs @@ -97,13 +97,13 @@ pub struct PluginInvocationPlan { } impl PluginRegistry { - pub fn managed(store: Store, runtime: PluginRuntime) -> Result { + pub fn managed(store: Store, runtime: PluginRuntime, app_version: String) -> Result { let data = PluginDataStore::managed()?; Ok(Self { inner: Arc::new(RegistryInner { store, runtime, - catalog: PluginCatalog::managed()?, + catalog: PluginCatalog::managed(app_version)?, state: PluginStateStore::new(data), entries: RwLock::new(None), workers: Mutex::new(HashMap::new()), @@ -122,6 +122,7 @@ impl PluginRegistry { .map(|(manifest, icon)| PluginDescriptor { id: manifest.id, name: manifest.name, + version: manifest.version, author: manifest.author, icon, providers: Vec::new(), @@ -625,6 +626,7 @@ impl PluginRegistry { PluginDescriptor { id: plugin_id.clone(), name: entry.manifest.name.clone(), + version: entry.manifest.version.clone(), author: entry.manifest.author.clone(), icon: entry.icon.clone(), providers, diff --git a/server/src/provider/openai_chat.rs b/server/src/provider/openai_chat.rs index 77290bd..32835a4 100644 --- a/server/src/provider/openai_chat.rs +++ b/server/src/provider/openai_chat.rs @@ -17,11 +17,8 @@ use crate::{ }; use super::{ -<<<<<<< HEAD - apply_body_allowlist, apply_openai_prompt_cache_key, merge_extra_params, -======= - apply_openai_prompt_cache_key, map_sse_error, merge_extra_params, provider_event_error, ->>>>>>> main + apply_body_allowlist, apply_openai_prompt_cache_key, map_sse_error, merge_extra_params, + provider_event_error, recorder::recorded_headers, retry::{send_with_retry, Attempt, RetryPolicy}, CallRecorder, FinishReason, ModelEvent, Provider, ProviderStream, diff --git a/server/src/provider/openai_responses.rs b/server/src/provider/openai_responses.rs index 03feb5f..993f936 100644 --- a/server/src/provider/openai_responses.rs +++ b/server/src/provider/openai_responses.rs @@ -14,11 +14,8 @@ use crate::{ }; use super::{ -<<<<<<< HEAD - apply_body_allowlist, apply_openai_prompt_cache_key, merge_extra_params, -======= - apply_openai_prompt_cache_key, map_sse_error, merge_extra_params, provider_event_error, ->>>>>>> main + apply_body_allowlist, apply_openai_prompt_cache_key, map_sse_error, merge_extra_params, + provider_event_error, recorder::recorded_headers, retry::{send_with_retry, Attempt, RetryPolicy}, CallRecorder, FinishReason, ModelEvent, Provider, ProviderStream, diff --git a/server/src/provider/router.rs b/server/src/provider/router.rs index 2be0eb4..7b501de 100644 --- a/server/src/provider/router.rs +++ b/server/src/provider/router.rs @@ -14,8 +14,8 @@ use crate::{ }; use super::{ - normalize::NormalizedProvider, AnthropicProvider, CallRecorder, OpenAiChatProvider, - OpenAiResponsesProvider, Provider, ProviderStream, + normalize::NormalizedProvider, recorder::CancelOnDrop, AnthropicProvider, CallRecorder, + OpenAiChatProvider, OpenAiResponsesProvider, Provider, ProviderStream, }; const BUILTIN_PROVIDER_RETRIES: u32 = 5; @@ -28,11 +28,12 @@ pub struct ProviderRouter { } impl ProviderRouter { -<<<<<<< HEAD - pub fn new(store: Store, plugins: PluginRegistry, request_timeout: Duration) -> Self { -======= - pub fn new(store: Store, request_timeout: Duration, stream_idle_timeout: Duration) -> Self { ->>>>>>> main + pub fn new( + store: Store, + plugins: PluginRegistry, + request_timeout: Duration, + stream_idle_timeout: Duration, + ) -> Self { Self { store, plugins, @@ -54,87 +55,57 @@ impl Provider for ProviderRouter { let stream_idle_timeout = self.stream_idle_timeout; Box::pin(try_stream! { let selected = invocation.request.model.model_id.clone(); -<<<<<<< HEAD - if selected.starts_with(ADAPTER_ID_PREFIX) { - // 插件模型与内置模型走完全相同的流程:Recorder、统一事件、 - // 规范化包装。资源选择与将来的负载均衡都在插件 Provider 内部。 - let plan = plugins.plan_model(&selected).await?; - let recorder = start_recorder(&store, &invocation, &selected, &plan.model.display_name, ProviderType::Plugin, &plan.request_url, &plan.model.model_id).await?; - let _cancel_on_drop = recorder.cancel_on_drop(); - recorder.request(serde_json::json!({}), &crate::plugin::plugin_llm_request(&invocation)?).await?; - let mut routed = invocation.clone(); - routed.request.model.display_name = Some(plan.model.display_name.clone()); - if let Some(tokens) = plan.model.context_window_tokens { - routed.request.model.context_window_tokens.get_or_insert(tokens); - } - if let Some(tokens) = plan.model.max_output_tokens { - routed.request.model.max_output_tokens.get_or_insert(tokens); - } - let provider: Arc = Arc::new(NormalizedProvider::new(Arc::new(PluginModelProvider { - registry: plugins.clone(), - }))); - let mut stream = provider.stream(routed, cancellation.clone()); - while let Some(item) = stream.next().await { - match item { - Ok(event) => { recorder.event(&event).await?; yield event; } - Err(error) => { recorder.failed(&error).await?; Err(error)?; } -======= - let model = store - .model(&selected) - .await? - .ok_or_else(|| Error::Provider(format!("unknown model: {selected}")))?; - let provider_type = model.provider_type(); - let request_url = model.request_url()?; - model.configure(&mut invocation.request.model); - invocation.request.model.extra_params = model.extra_params().clone(); - invocation.request.model.model_id = model.model_id.clone(); - let recorder = CallRecorder::start(store.clone(), NewLlmCall { - call_id: invocation.call_id.clone(), - run_id: invocation.run_id.clone(), - conversation_id: invocation.conversation_id.clone(), - provider_call_index: invocation.provider_call_index.min(i64::MAX as u64) as i64, - model_hash: model.model_hash.clone(), - provider_type, - provider_url: model.base_url.clone(), - request_type: provider_type, - request_url: request_url.clone(), - model_id: model.model_id.clone(), - display_name: model.display_name.clone(), - reasoning_effort: invocation.request.model.reasoning.effort.clone(), - fast: invocation.request.model.latency == ModelLatency::Fast, - message_count: invocation.request.history.len(), - tool_count: invocation.request.prompt.tools.len(), - detailed: false, - }).await?; - let _cancel_on_drop = recorder.cancel_on_drop(); - let config = ProviderConfig { - kind: match provider_type { - ProviderType::OpenAiChat => ProviderKind::OpenAiChat, - ProviderType::OpenAiResponses => ProviderKind::OpenAiResponses, - ProviderType::Anthropic => ProviderKind::Anthropic, - }, - request_url, - api_key: model.api_key.clone(), - custom_headers: if model.custom_headers_enabled { - custom_headers(&model.custom_headers)? + // 两条分支只负责装配 Recorder 与 Provider 流; + // 事件消费(空闲超时看门狗、记录、错误规范化)对两者完全一致。 + let (recorder, _cancel_on_drop, mut stream): (CallRecorder, CancelOnDrop, ProviderStream) = + if selected.starts_with(ADAPTER_ID_PREFIX) { + // 插件模型与内置模型走完全相同的流程:资源选择与将来的 + // 负载均衡都在插件 Provider 内部。 + let plan = plugins.plan_model(&selected).await?; + let recorder = start_recorder(&store, &invocation, &selected, &plan.model.display_name, ProviderType::Plugin, &plan.request_url, &plan.model.model_id).await?; + let guard = recorder.cancel_on_drop(); + recorder.request(serde_json::json!({}), &crate::plugin::plugin_llm_request(&invocation)?).await?; + let mut routed = invocation.clone(); + routed.request.model.display_name = Some(plan.model.display_name.clone()); + if let Some(tokens) = plan.model.context_window_tokens { + routed.request.model.context_window_tokens.get_or_insert(tokens); + } + if let Some(tokens) = plan.model.max_output_tokens { + routed.request.model.max_output_tokens.get_or_insert(tokens); + } + let provider: Arc = Arc::new(NormalizedProvider::new(Arc::new(PluginModelProvider { + registry: plugins.clone(), + }))); + (recorder, guard, provider.stream(routed, cancellation.clone())) } else { - reqwest::header::HeaderMap::new() - }, - max_output_tokens: model.max_output_tokens(), - request_timeout, - }; - let client = crate::network::client_builder(&store) - .await? - .timeout(config.request_timeout) - .build()?; - let provider = build_observed(&config, recorder.clone(), client)?; - let stream_cancellation = cancellation.clone(); - let mut stream = provider.stream(invocation, cancellation); + let mut routed = invocation.clone(); + let model = store.model(&selected).await?.ok_or_else(|| Error::Provider(format!("unknown model: {selected}")))?; + let provider_type = model.provider_type(); + let request_url = model.request_url()?; + model.configure(&mut routed.request.model); + routed.request.model.extra_params = model.extra_params().clone(); + routed.request.model.model_id = model.model_id.clone(); + let recorder = start_recorder(&store, &invocation, &model.model_hash, &model.display_name, provider_type, &request_url, &model.model_id).await?; + let guard = recorder.cancel_on_drop(); + let config = ProviderConfig { + kind: provider_kind(provider_type), + request_url, + api_key: model.api_key.clone(), + custom_headers: if model.custom_headers_enabled { custom_headers(&model.custom_headers)? } else { reqwest::header::HeaderMap::new() }, + max_output_tokens: model.max_output_tokens(), + request_timeout, + retry_count: BUILTIN_PROVIDER_RETRIES, + allowed_body_fields: None, + }; + let client = crate::network::client_builder(&store).await?.timeout(request_timeout).build()?; + let provider = build_observed(&config, recorder.clone(), client)?; + (recorder, guard, provider.stream(routed, cancellation.clone())) + }; + let stream_started = std::time::Instant::now(); tracing::debug!( model = %selected, - provider_type = ?provider_type, - request_timeout_ms = config.request_timeout.as_millis() as u64, + request_timeout_ms = request_timeout.as_millis() as u64, stream_idle_timeout_ms = stream_idle_timeout.as_millis() as u64, "provider stream created" ); @@ -163,26 +134,11 @@ impl Provider for ProviderRouter { event_count += 1; match event { Ok(event) => { - let event_name = match &event { - super::ModelEvent::Start { .. } => "Start", - super::ModelEvent::TextStart => "TextStart", - super::ModelEvent::TextDelta(_) => "TextDelta", - super::ModelEvent::TextEnd => "TextEnd", - super::ModelEvent::ThinkingStart => "ThinkingStart", - super::ModelEvent::ThinkingDelta(_) => "ThinkingDelta", - super::ModelEvent::ThinkingEnd => "ThinkingEnd", - super::ModelEvent::ToolCallStart { .. } => "ToolCallStart", - super::ModelEvent::ToolCallArgumentsDelta { .. } => "ToolCallArgsDelta", - super::ModelEvent::ToolCallEnd { .. } => "ToolCallEnd", - super::ModelEvent::ProviderReplayState(_) => "ReplayState", - super::ModelEvent::Usage(_) => "Usage", - super::ModelEvent::Done(_) => "Done", - }; if gap_ms > 5000 { tracing::debug!( gap_ms, elapsed_ms, - event = event_name, + event = event_name(&event), event_count, "slow gap detected between provider events" ); @@ -202,46 +158,32 @@ impl Provider for ProviderRouter { ); recorder.failed(&error).await?; Err(error)?; ->>>>>>> main } } - finish_stream(&recorder, &cancellation).await?; - } else { - let mut routed = invocation.clone(); - let model = store.model(&selected).await?.ok_or_else(|| Error::Provider(format!("unknown model: {selected}")))?; - let provider_type = model.provider_type(); - let request_url = model.request_url()?; - model.configure(&mut routed.request.model); - routed.request.model.extra_params = model.extra_params().clone(); - routed.request.model.model_id = model.model_id.clone(); - let recorder = start_recorder(&store, &invocation, &model.model_hash, &model.display_name, provider_type, &request_url, &model.model_id).await?; - let _cancel_on_drop = recorder.cancel_on_drop(); - let config = ProviderConfig { - kind: provider_kind(provider_type), - request_url, - api_key: model.api_key.clone(), - custom_headers: if model.custom_headers_enabled { custom_headers(&model.custom_headers)? } else { reqwest::header::HeaderMap::new() }, - max_output_tokens: model.max_output_tokens(), - request_timeout, - retry_count: BUILTIN_PROVIDER_RETRIES, - allowed_body_fields: None, - }; - let client = crate::network::client_builder(&store).await?.timeout(request_timeout).build()?; - let provider = build_observed(&config, recorder.clone(), client)?; - let mut stream = provider.stream(routed, cancellation.clone()); - while let Some(item) = stream.next().await { - match item { - Ok(event) => { recorder.event(&event).await?; yield event; } - Err(error) => { recorder.failed(&error).await?; Err(error)?; } - } - } - finish_stream(&recorder, &cancellation).await?; } + finish_stream(&recorder, &cancellation).await?; }) } } -<<<<<<< HEAD +fn event_name(event: &super::ModelEvent) -> &'static str { + match event { + super::ModelEvent::Start { .. } => "Start", + super::ModelEvent::TextStart => "TextStart", + super::ModelEvent::TextDelta(_) => "TextDelta", + super::ModelEvent::TextEnd => "TextEnd", + super::ModelEvent::ThinkingStart => "ThinkingStart", + super::ModelEvent::ThinkingDelta(_) => "ThinkingDelta", + super::ModelEvent::ThinkingEnd => "ThinkingEnd", + super::ModelEvent::ToolCallStart { .. } => "ToolCallStart", + super::ModelEvent::ToolCallArgumentsDelta { .. } => "ToolCallArgsDelta", + super::ModelEvent::ToolCallEnd { .. } => "ToolCallEnd", + super::ModelEvent::ProviderReplayState(_) => "ReplayState", + super::ModelEvent::Usage(_) => "Usage", + super::ModelEvent::Done(_) => "Done", + } +} + async fn start_recorder( store: &Store, invocation: &ModelInvocation, @@ -311,7 +253,8 @@ fn provider_kind(provider_type: ProviderType) -> ProviderKind { // 内置模型的 provider_type 只来自 ModelType,不可能是插件。 ProviderType::Plugin => unreachable!("plugin models never use built-in provider configs"), } -======= +} + async fn next_provider_event( stream: &mut ProviderStream, idle_timeout: Duration, @@ -352,7 +295,6 @@ fn root_error_message(error: &(dyn std::error::Error + 'static)) -> String { current = source; } current.to_string() ->>>>>>> main } fn custom_headers(value: &serde_json::Value) -> Result {