refactor: replace Button with TruncatedButton in CursorModelCards and PluginManagementPage

- Updated the UI components in CursorModelCards and PluginManagementPage to use TruncatedButton for better text handling and display.
- Adjusted styles in CursorSettings and PluginManagementPage to ensure proper button layout and responsiveness.
- Added new ActionMenu component for handling additional actions in PluginManagementPage.
- Enhanced localization files to include new strings for the ActionMenu and TruncatedButton components.
This commit is contained in:
leookun
2026-08-30 20:45:27 +08:00
parent 05181f9e8a
commit a5bbe67845
27 changed files with 651 additions and 270 deletions
+6 -1
View File
@@ -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;
@@ -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
</div>
<CursorModelTestResult compact state={result} testing={testing} />
<div className={styles.modelCardActions}>
<Button size="small" disabled={disabled && !testing} onClick={onTest}>{testing ? t("取消测试") : t("测试")}</Button>
<Button size="small" disabled={disabled} onClick={onEdit}>{t("编辑")}</Button>
<Button size="small" disabled={disabled} onClick={onDuplicate}>{t("复制")}</Button>
<Button size="small" className={styles.deleteButton} disabled={disabled} onClick={onDelete}>{t("删除")}</Button>
<TruncatedButton size="small" disabled={disabled && !testing} label={testing ? t("取消测试") : t("测试")} onClick={onTest} />
<TruncatedButton size="small" disabled={disabled} label={t("编辑")} onClick={onEdit} />
<TruncatedButton size="small" disabled={disabled} label={t("复制")} onClick={onDuplicate} />
<TruncatedButton size="small" className={styles.deleteButton} disabled={disabled} label={t("删除")} onClick={onDelete} />
</div>
</div>;
}
@@ -170,8 +170,8 @@ function PluginModelRow({ model, disabled, testing, result, onTest, onSettings }
</div>
<CursorModelTestResult compact state={result} testing={testing} />
<div className={styles.modelCardActions}>
<Button size="small" disabled={disabled && !testing} onClick={onTest}>{testing ? t("取消测试") : t("测试")}</Button>
<Button size="small" disabled={disabled} onClick={onSettings}>{t("设置")}</Button>
<TruncatedButton size="small" disabled={disabled && !testing} label={testing ? t("取消测试") : t("测试")} onClick={onTest} />
<TruncatedButton size="small" disabled={disabled} label={t("设置")} onClick={onSettings} />
</div>
</div>;
}
@@ -261,10 +261,10 @@ function ModelGrid({
<CursorModelTestResult state={result} testing={testing} />
</div>
<div className={styles.modelCardActions}>
<Button size="small" disabled={disabled && !testing} onClick={() => onTest(model)}>{testing ? t("取消测试") : t("测试")}</Button>
<Button size="small" disabled={disabled} onClick={() => onEdit(model)}>{t("编辑")}</Button>
<Button size="small" disabled={disabled} onClick={() => onDuplicate(model)}>{t("复制")}</Button>
<Button size="small" className={styles.deleteButton} disabled={disabled} onClick={() => onDelete(model)}>{t("删除")}</Button>
<TruncatedButton size="small" disabled={disabled && !testing} label={testing ? t("取消测试") : t("测试")} onClick={() => onTest(model)} />
<TruncatedButton size="small" disabled={disabled} label={t("编辑")} onClick={() => onEdit(model)} />
<TruncatedButton size="small" disabled={disabled} label={t("复制")} onClick={() => onDuplicate(model)} />
<TruncatedButton size="small" className={styles.deleteButton} disabled={disabled} label={t("删除")} onClick={() => onDelete(model)} />
</div>
</div>
</Card>;
@@ -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);
@@ -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,
@@ -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 <Card className={styles.pluginCard}>
<div className={styles.pluginCardTop}>
<span className={styles.pluginIcon}><Icon src={plugin.icon} size="1.75em" /></span>
<div className={styles.pluginIdentity}>
<span className={styles.pluginName}>{plugin.name}</span>
<span className={styles.pluginId}>{subtitle}</span>
return (
<Card className={styles.pluginCard}>
<div className={styles.pluginCardTop}>
<img className={styles.pluginIcon} src={plugin.icon} />
<div className={styles.pluginIdentity}>
<span className={styles.pluginName}>{plugin.name}</span>
<span className={styles.pluginId}>{subtitle}</span>
</div>
<span
className={`${styles.stateBadge} ${configured ? styles.stateReady : ""}`}
>
{configured ? t("已配置") : t("未配置")}
</span>
</div>
<span className={`${styles.stateBadge} ${configured ? styles.stateReady : ""}`}>
{configured ? t("已配置") : t("未配置")}
</span>
</div>
<div className={styles.pluginMeta}>
<span>{t("{accounts} 个账号 · {models} 个模型", { accounts: accountCount, models: modelCount })}</span>
{plugin.author && <span className={styles.pluginAuthor}>{plugin.author}</span>}
</div>
<div className={styles.cardActions}>
<Button size="small" variant="primary" onClick={() => onOpen(plugin.id, "add")}>{t("添加账号")}</Button>
{configured && <Button size="small" onClick={() => onOpen(plugin.id, "settings")}>{t("账号管理")}</Button>}
{importResource && <Button size="small" disabled={importing} onClick={() => importInput.current?.click()}>
{importing ? t("正在导入…") : t("批量导入")}
</Button>}
{exportResource && <Button
size="small"
onClick={() => void api.openExternalUrl(api.pluginResourceExportUrl(ports.service_port, plugin.id, exportResource.type))}
>
{t("批量导出")}
</Button>}
{importResource && <input
ref={importInput}
type="file"
hidden
accept={importResource.import?.accept.join(",")}
multiple={importResource.import?.multiple ?? false}
onChange={(event) => void importFiles(event.target.files)}
/>}
</div>
</Card>;
<div className={styles.pluginMeta}>
<span>
{t("{accounts} 个账号 · {models} 个模型", {
accounts: accountCount,
models: modelCount,
})}
</span>
<span className={styles.pluginAuthor}>
{[`v${plugin.version}`, plugin.author].filter(Boolean).join(" · ")}
</span>
</div>
<div className={styles.cardActions}>
<TruncatedButton
size="small"
variant="primary"
label={t("添加账号")}
onClick={() => onOpen(plugin.id, "add")}
/>
{configured && (
<TruncatedButton
size="small"
label={t("账号管理")}
onClick={() => onOpen(plugin.id, "settings")}
/>
)}
{(importResource || exportResource) && (
<span className={styles.moreAction}>
<ActionMenu
label={t("更多")}
items={[
...(importResource
? [
{
id: "import",
label: importing ? t("正在导入…") : t("批量导入"),
disabled: importing,
onSelect: () => importInput.current?.click(),
},
]
: []),
...(exportResource
? [
{
id: "export",
label: t("批量导出"),
onSelect: () =>
void api.openExternalUrl(
api.pluginResourceExportUrl(
ports.service_port,
plugin.id,
exportResource.type,
),
),
},
]
: []),
]}
/>
</span>
)}
{importResource && (
<input
ref={importInput}
type="file"
hidden
accept={importResource.import?.accept.join(",")}
multiple={importResource.import?.multiple ?? false}
onChange={(event) => void importFiles(event.target.files)}
/>
)}
</div>
</Card>
);
}
function RuntimeProgressModal({ open, status, starting, onClose }: { open: boolean; status: PluginRuntimeStatus | null; starting: boolean; onClose: () => void }) {
+58 -46
View File
@@ -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
}
]
},
+1
View File
@@ -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",
+1
View File
@@ -86,6 +86,7 @@
"378bb0eec39fa8a2": "最后一页",
"37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}",
"382f2e3419a02fef": "仅清理详细记录",
"38844b135cf70dfc": "更多",
"393e1241552b1870": "请求",
"398f8e6c6f0a0b97": "继续选择或输入",
"39f52eee100131d7": "缓存输入",
+1
View File
@@ -254,6 +254,7 @@ export interface PluginProviderDescriptor {
export interface PluginDescriptor {
id: string;
name: string;
version: string;
author: string | null;
icon: string;
providers: PluginProviderDescriptor[];
@@ -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);
}
+99
View File
@@ -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<HTMLButtonElement>(null);
const menu = useRef<HTMLDivElement>(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 <>
<Button
ref={trigger}
size="small"
disabled={disabled}
aria-haspopup="menu"
aria-controls={open ? menuId : undefined}
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
onKeyDown={(event) => {
if (event.key === "Escape") close();
}}
>
{label}
<Icon icon={chevronDownIcon} size="1em" className={open ? styles.openIcon : undefined} />
</Button>
{open && createPortal(
<div
id={menuId}
ref={menu}
className={styles.menu}
role="menu"
style={{ left: position.left, top: position.top }}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
close();
}
}}
>
{items.map((item) => <button
key={item.id}
type="button"
role="menuitem"
disabled={item.disabled}
onClick={() => {
setOpen(false);
item.onSelect();
}}
>
{item.label}
</button>)}
</div>,
document.body,
)}
</>;
}
+2 -2
View File
@@ -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<HTMLButtonElement> & {
}: ComponentProps<"button"> & {
variant?: ButtonVariant;
size?: ButtonSize;
}) {
@@ -0,0 +1,6 @@
.label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -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<typeof Button> & { label: string }) {
const element = useRef<HTMLSpanElement>(null);
const [truncated, setTruncated] = useState(false);
const measure = () => {
const text = element.current;
if (text) setTruncated(text.scrollWidth > text.clientWidth);
};
const button = <Button {...props} onPointerEnter={measure} onFocus={measure}>
<span ref={element} className={styles.label}>{label}</span>
</Button>;
return truncated ? <TooltipTrigger label={label}>{button}</TooltipTrigger> : button;
}
@@ -21,7 +21,7 @@ Plugins implement three capability interfaces defined by the core: **Provider**
└── models-<provider>.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
@@ -21,7 +21,7 @@ icon: Blocks
└── models-<provider>.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/`:版本一致时启动零写盘,版本变化时整目录同步并清理旧文件。
## 入口与能力