From 2cb15b541b6d480843dcb4b5cb8138f8f22d3947 Mon Sep 17 00:00:00 2001 From: leokun Date: Sat, 5 Sep 2026 00:12:14 +0800 Subject: [PATCH] feat(desktop): add plugin resource actions --- .../features/plugins/PluginManagementPage.tsx | 74 ++-- .../plugins/PluginResourcePanels.module.scss | 31 ++ .../features/plugins/PluginResourcePanels.tsx | 135 +++++- apps/desktop/src/i18n/generated/catalog.json | 419 +++++++++++------- apps/desktop/src/i18n/locales/en-US.json | 15 +- apps/desktop/src/i18n/locales/zh-CN.json | 15 +- apps/desktop/src/shared/api.ts | 31 ++ .../src/shared/ui/ActionMenu.module.scss | 15 +- apps/desktop/src/shared/ui/ActionMenu.tsx | 49 +- .../build-in/antigravity-auth/models.ts | 8 +- .../build-in/antigravity-auth/plugin.json | 4 +- .../build-in/antigravity-auth/provider.ts | 9 +- .../antigravity-auth/provider_test.ts | 146 ++++++ .../plugins/build-in/codex-auth/codex_test.ts | 3 + server/plugins/build-in/codex-auth/main.ts | 10 +- .../plugins/build-in/codex-auth/resources.ts | 179 +++++++- server/src/control/mod.rs | 4 + server/src/control/plugins.rs | 17 + server/src/control/service.rs | 13 + server/src/plugin/descriptor.rs | 71 +++ server/src/plugin/registry.rs | 58 ++- server/src/plugin/sdk/plugin.ts | 7 + server/src/plugin/sdk/resource.ts | 40 ++ server/src/plugin/sdk/worker.ts | 11 + 24 files changed, 1110 insertions(+), 254 deletions(-) create mode 100644 server/plugins/build-in/antigravity-auth/provider_test.ts diff --git a/apps/desktop/src/features/plugins/PluginManagementPage.tsx b/apps/desktop/src/features/plugins/PluginManagementPage.tsx index 1b037ef..9878d9a 100644 --- a/apps/desktop/src/features/plugins/PluginManagementPage.tsx +++ b/apps/desktop/src/features/plugins/PluginManagementPage.tsx @@ -3,7 +3,7 @@ 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 { ActionMenu, type ActionMenuItem } from "../../shared/ui/ActionMenu"; import { Button } from "../../shared/ui/Button"; import { Card } from "../../shared/ui/Card"; import { Modal } from "../../shared/ui/Modal"; @@ -170,6 +170,38 @@ function PluginCard({ plugin, onOpen }: { } }; + const moreItems: ActionMenuItem[] = [ + ...(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, + ), + ), + }, + ] + : []), + ...(plugin.version + ? [{ id: "version", type: "text" as const, label: `v${plugin.version}` }] + : []), + ]; + return (
@@ -191,9 +223,9 @@ function PluginCard({ plugin, onOpen }: { models: modelCount, })} - - {[`v${plugin.version}`, plugin.author].filter(Boolean).join(" · ")} - + {plugin.author && ( + {plugin.author} + )}
onOpen(plugin.id, "settings")} /> )} - {(importResource || exportResource) && ( + {moreItems.length > 0 && ( - importInput.current?.click(), - }, - ] - : []), - ...(exportResource - ? [ - { - id: "export", - label: t("批量导出"), - onSelect: () => - void api.openExternalUrl( - api.pluginResourceExportUrl( - ports.service_port, - plugin.id, - exportResource.type, - ), - ), - }, - ] - : []), - ]} - /> + )} {importResource && ( diff --git a/apps/desktop/src/features/plugins/PluginResourcePanels.module.scss b/apps/desktop/src/features/plugins/PluginResourcePanels.module.scss index 3b58d37..666db8e 100644 --- a/apps/desktop/src/features/plugins/PluginResourcePanels.module.scss +++ b/apps/desktop/src/features/plugins/PluginResourcePanels.module.scss @@ -162,6 +162,37 @@ } } +.actionDescription { + color: var(--vscode-descriptionForeground); + font-size: type.$font-size-xs; +} + +.actionCardList { + display: flex; + flex-direction: column; + gap: 8px; +} + +.actionCard { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 12px; +} + +.actionCardMain { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + + span { + color: var(--vscode-descriptionForeground); + font-size: type.$font-size-xs; + } +} + .ready { color: var(--vscode-testing-iconPassed, #73c991) !important; } diff --git a/apps/desktop/src/features/plugins/PluginResourcePanels.tsx b/apps/desktop/src/features/plugins/PluginResourcePanels.tsx index 2a547a6..8a4fbcb 100644 --- a/apps/desktop/src/features/plugins/PluginResourcePanels.tsx +++ b/apps/desktop/src/features/plugins/PluginResourcePanels.tsx @@ -6,6 +6,9 @@ import { type PluginDescriptor, type PluginOAuthBegin, type PluginProviderDescriptor, + type PluginResourceAction, + type PluginResourceActionCard, + type PluginResourceActionResult, type PluginResourceDescriptor, type PluginResourceView, } from "../../shared/api"; @@ -13,6 +16,7 @@ import { useI18n } from "../../i18n/store"; import { appStore } from "../../shared/store/appStore"; import { Button } from "../../shared/ui/Button"; import { Card } from "../../shared/ui/Card"; +import { ConfirmDialog } from "../../shared/ui/ConfirmDialog"; import { FormField, TextInput } from "../../shared/ui/FormControls"; import { Modal } from "../../shared/ui/Modal"; import { Switch } from "../../shared/ui/Switch"; @@ -145,6 +149,12 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) { const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [modelProviderId, setModelProviderId] = useState(null); + const [resourceAction, setResourceAction] = useState<{ + resource: PluginResourceDescriptor; + item: PluginResourceView; + } | null>(null); + const [resourceActionResult, setResourceActionResult] = useState(null); + const [resourceActionError, setResourceActionError] = useState(null); const modelProvider = modelProviderId ? plugin.providers.find((provider) => provider.id === modelProviderId) ?? null : null; const run = async (key: string, task: () => Promise) => { @@ -160,6 +170,38 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) { } }; + const executeResourceAction = async ( + target: { resource: PluginResourceDescriptor; item: PluginResourceView }, + action: PluginResourceAction, + input: unknown = {}, + ) => { + const key = `action:${target.item.id}:${action.id}`; + setBusy(key); + setResourceActionError(null); + try { + const result = await api.pluginResourceAction( + plugin.id, + target.resource.type, + target.item.id, + action.id, + input, + ); + setResourceActionResult(result); + await appStore.refreshPlugins(); + } catch (cause) { + setResourceActionError(errorText(cause)); + } finally { + setBusy(null); + } + }; + + const openResourceAction = (resource: PluginResourceDescriptor, item: PluginResourceView, action: PluginResourceAction) => { + setResourceAction({ resource, item }); + setResourceActionResult(null); + setResourceActionError(null); + void executeResourceAction({ resource, item }, action); + }; + return
{plugin.providers.map((provider) => openResourceAction(resource, item, action)} onRefresh={(item) => void run(`refresh:${item.id}`, async () => { await api.refreshPluginResource(plugin.id, resource.type, item.id); })} @@ -194,6 +237,15 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) { } })} />} + {resourceAction && item.target === "resource") ?? null} + cardAction={resourceAction.resource.actions.find((item) => item.target === "card") ?? null} + result={resourceActionResult} + busy={busy !== null} + error={resourceActionError} + onClose={() => setResourceAction(null)} + onCardAction={(action, card) => void executeResourceAction(resourceAction, action, { cardId: card.id })} + />}
; } @@ -278,9 +330,10 @@ function ModelManagementModal({ provider, busy, onClose, onSubmit }: { ; } -function ResourceList({ resource, busy, onRefresh, onDelete }: { +function ResourceList({ resource, busy, onAction, onRefresh, onDelete }: { resource: PluginResourceDescriptor; busy: boolean; + onAction: (item: PluginResourceView, action: PluginResourceAction) => void; onRefresh: (item: PluginResourceView) => void; onDelete: (item: PluginResourceView) => void; }) { @@ -305,8 +358,10 @@ function ResourceList({ resource, busy, onRefresh, onDelete }: { {visible.map((item) => action.target === "resource")} canRefresh={resource.canRefresh} disabled={busy} + onAction={(action) => onAction(item, action)} onRefresh={() => onRefresh(item)} onDelete={() => onDelete(item)} />)} @@ -321,10 +376,12 @@ function ResourceList({ resource, busy, onRefresh, onDelete }: { ; } -function ResourceRow({ item, canRefresh, disabled, onRefresh, onDelete }: { +function ResourceRow({ item, actions, canRefresh, disabled, onAction, onRefresh, onDelete }: { item: PluginResourceView; + actions: PluginResourceAction[]; canRefresh: boolean; disabled: boolean; + onAction: (action: PluginResourceAction) => void; onRefresh: () => void; onDelete: () => void; }) { @@ -341,12 +398,86 @@ function ResourceRow({ item, canRefresh, disabled, onRefresh, onDelete }: {
+ {actions.map((action) => )} {canRefresh && }
; } +function ResourceActionModal({ action, cardAction, result, busy, error, onClose, onCardAction }: { + action: PluginResourceAction | null; + cardAction: PluginResourceAction | null; + result: PluginResourceActionResult | null; + busy: boolean; + error: string | null; + onClose: () => void; + onCardAction: (action: PluginResourceAction, card: PluginResourceActionCard) => void; +}) { + const { locale } = useI18n(); + const [pendingCard, setPendingCard] = useState(null); + const title = result ? pluginText(result.title, locale) : action ? pluginText(action.displayName, locale) : t("资源详情"); + const cardActions = cardAction ? [cardAction] : []; + + return <> + + {result?.description && {pluginText(result.description, locale)}} + {busy && {t("正在加载…")}} + {error && {error}} + {!busy && !error && result && result.cards.length === 0 && {t("没有可用的重置卡。")}} + {!busy && !error && result &&
+ {result.cards.map((card) => +
+ {pluginText(card.title, locale)} + {card.status && {formatActionStatus(card.status, locale)}} + {card.grantedAtMs !== null && card.grantedAtMs !== undefined && {t("发放时间:{time}", { time: formatActionDate(card.grantedAtMs, locale) })}} + {card.expiresAtMs !== null && card.expiresAtMs !== undefined && {t("到期时间:{time}", { time: formatActionDate(card.expiresAtMs, locale) })}} + {card.fields.map((field) => {pluginText(field.label, locale)}: {field.value})} +
+ {cardActions.length > 0 &&
+ {cardActions.map((cardActionItem) => )} +
} +
)} +
} +
+ {pendingCard && cardAction && setPendingCard(null)} + onConfirm={() => { + const card = pendingCard; + setPendingCard(null); + onCardAction(cardAction, card); + }} + > +

{t("使用后会立即消耗这张重置卡,且无法恢复。确定继续吗?")}

+ {pluginText(pendingCard.title, locale)} +
} + ; +} + +function formatActionStatus(status: PluginResourceActionCard["status"], locale: string) { + const value = typeof status === "string" ? status : pluginText(status, locale); + switch (value.toLowerCase()) { + case "available": return t("可用"); + case "redeemed": + case "used": return t("已使用"); + case "expired": return t("已过期"); + default: return value; + } +} + +function formatActionDate(value: number, locale: string) { + return new Date(value).toLocaleString(locale); +} + function StateBadge({ state }: { state: PluginResourceView["state"] }) { if (state.status === "cooling") { return {t("冷却中")}; diff --git a/apps/desktop/src/i18n/generated/catalog.json b/apps/desktop/src/i18n/generated/catalog.json index 902327b..8d08f84 100644 --- a/apps/desktop/src/i18n/generated/catalog.json +++ b/apps/desktop/src/i18n/generated/catalog.json @@ -47,7 +47,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 214, + "line": 266, "column": 39 } ] @@ -78,7 +78,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 317, + "line": 372, "column": 16 } ] @@ -182,7 +182,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 310, + "line": 314, "column": 21 } ] @@ -242,7 +242,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 187, + "line": 221, "column": 12 } ] @@ -306,12 +306,12 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 214, + "line": 266, "column": 93 }, { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 276, + "line": 328, "column": 72 } ] @@ -440,7 +440,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 43, + "line": 42, "column": 42 } ] @@ -616,7 +616,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 121, + "line": 123, "column": 16 } ] @@ -677,7 +677,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 155, + "line": 157, "column": 23 } ] @@ -689,7 +689,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 127, + "line": 131, "column": 15 } ] @@ -783,6 +783,18 @@ } ] }, + "1d19efc260a0a18f": { + "source": "使用重置卡", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginResourcePanels.tsx", + "line": 450, + "column": 14 + } + ] + }, "1d27f02ed278ebc9": { "source": "配置文件", "kind": "text", @@ -858,6 +870,18 @@ } ] }, + "21bd738e0d7191a7": { + "source": "正在加载…", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginResourcePanels.tsx", + "line": 425, + "column": 48 + } + ] + }, "22c6b4eb4caee6ae": { "source": "代理设置已保存", "kind": "text", @@ -917,7 +941,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 294, + "line": 298, "column": 122 } ] @@ -1020,7 +1044,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 135, + "line": 139, "column": 73 } ] @@ -1032,7 +1056,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 179, + "line": 125, "column": 105 }, { @@ -1052,7 +1076,7 @@ }, { "file": "features/plugins/PluginManagementPage.tsx", - "line": 274, + "line": 278, "column": 70 }, { @@ -1246,11 +1270,23 @@ }, { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 345, + "line": 403, "column": 68 } ] }, + "2fe0e3339ac4d1bb": { + "source": "已过期", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginResourcePanels.tsx", + "line": 472, + "column": 28 + } + ] + }, "2fe5a8d0eee9f14c": { "source": "已失效", "kind": "text", @@ -1258,7 +1294,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 355, + "line": 486, "column": 81 } ] @@ -1270,12 +1306,12 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 302, + "line": 355, "column": 32 }, { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 302, + "line": 355, "column": 56 } ] @@ -1304,7 +1340,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 139, + "line": 143, "column": 64 } ] @@ -1406,8 +1442,8 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 213, - "column": 22 + "line": 246, + "column": 32 } ] }, @@ -1471,7 +1507,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 255, + "line": 307, "column": 106 }, { @@ -1555,7 +1591,7 @@ "refs": [ { "file": "shell/ads/AdMenu.tsx", - "line": 18, + "line": 45, "column": 56 } ] @@ -1596,8 +1632,13 @@ }, { "file": "features/plugins/PluginManagementPage.tsx", - "line": 274, + "line": 278, "column": 80 + }, + { + "file": "features/plugins/PluginResourcePanels.tsx", + "line": 423, + "column": 74 } ] }, @@ -1608,7 +1649,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 110, + "line": 112, "column": 68 } ] @@ -1637,18 +1678,6 @@ } ] }, - "4256064de039de89": { - "source": "显示粒度", - "kind": "text", - "placeholders": [], - "refs": [ - { - "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 188, - "column": 19 - } - ] - }, "42655ed8e4108ae2": { "source": "输入(非缓存)", "kind": "text", @@ -1754,7 +1783,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 135, + "line": 139, "column": 92 } ] @@ -1889,7 +1918,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 273, + "line": 277, "column": 12 } ] @@ -1913,7 +1942,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 110, + "line": 112, "column": 46 } ] @@ -1937,7 +1966,7 @@ "refs": [ { "file": "shell/ads/AdMenu.tsx", - "line": 28, + "line": 55, "column": 99 }, { @@ -1954,7 +1983,12 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 357, + "line": 469, + "column": 30 + }, + { + "file": "features/plugins/PluginResourcePanels.tsx", + "line": 488, "column": 42 } ] @@ -1978,7 +2012,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 126, + "line": 92, "column": 9 }, { @@ -2003,18 +2037,6 @@ } ] }, - "4f0f45b0312ca4ba": { - "source": "1小时", - "kind": "text", - "placeholders": [], - "refs": [ - { - "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 49, - "column": 37 - } - ] - }, "51d04bc3d286f018": { "source": "近1自然日", "kind": "text", @@ -2022,7 +2044,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 36, + "line": 35, "column": 30 } ] @@ -2048,7 +2070,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 176, + "line": 122, "column": 70 } ] @@ -2251,6 +2273,18 @@ } ] }, + "5acb7d61688e3c4f": { + "source": "确认使用", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginResourcePanels.tsx", + "line": 452, + "column": 21 + } + ] + }, "5b17f59d33bde39e": { "source": "错误:{error}", "kind": "template", @@ -2296,7 +2330,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 266, + "line": 270, "column": 7 } ] @@ -2360,7 +2394,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 220, + "line": 272, "column": 102 } ] @@ -2389,7 +2423,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 150, + "line": 113, "column": 56 } ] @@ -2458,7 +2492,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 180, + "line": 126, "column": 109 } ] @@ -2480,7 +2514,7 @@ }, { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 130, + "line": 134, "column": 30 } ] @@ -2494,7 +2528,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 248, + "line": 300, "column": 12 } ] @@ -2506,7 +2540,7 @@ "refs": [ { "file": "shared/api.ts", - "line": 490, + "line": 521, "column": 43 } ] @@ -2657,7 +2691,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 175, + "line": 121, "column": 21 } ] @@ -2833,7 +2867,7 @@ "refs": [ { "file": "shared/api.ts", - "line": 485, + "line": 516, "column": 43 } ] @@ -2869,7 +2903,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 309, + "line": 313, "column": 31 } ] @@ -2963,7 +2997,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 338, + "line": 395, "column": 13 } ] @@ -2975,7 +3009,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 182, + "line": 216, "column": 36 } ] @@ -2987,7 +3021,7 @@ "refs": [ { "file": "shared/api.ts", - "line": 420, + "line": 450, "column": 21 } ] @@ -2999,7 +3033,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 103, + "line": 105, "column": 25 } ] @@ -3029,7 +3063,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 294, + "line": 298, "column": 20 } ] @@ -3092,18 +3126,6 @@ } ] }, - "859757ea792a73da": { - "source": "30分钟", - "kind": "text", - "placeholders": [], - "refs": [ - { - "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 48, - "column": 37 - } - ] - }, "864597982c308d72": { "source": "已开启静默启动", "kind": "text", @@ -3123,7 +3145,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 222, + "line": 274, "column": 33 } ] @@ -3174,7 +3196,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 306, + "line": 310, "column": 32 } ] @@ -3198,7 +3220,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 318, + "line": 373, "column": 110 }, { @@ -3297,7 +3319,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 130, + "line": 134, "column": 19 } ] @@ -3359,7 +3381,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 200, + "line": 234, "column": 18 } ] @@ -3426,7 +3448,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 308, + "line": 312, "column": 31 } ] @@ -3450,7 +3472,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 222, + "line": 274, "column": 20 } ] @@ -3489,6 +3511,18 @@ } ] }, + "9845c165151daee3": { + "source": "已使用", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginResourcePanels.tsx", + "line": 471, + "column": 25 + } + ] + }, "9850ed41a5bfbb0c": { "source": "已选 {count} 项", "kind": "template", @@ -3515,6 +3549,18 @@ } ] }, + "9a84733cc9ab1706": { + "source": "资源详情", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginResourcePanels.tsx", + "line": 419, + "column": 111 + } + ] + }, "9b1b7ed518ee401d": { "source": "将在系统浏览器中打开使用教程,是否继续?", "kind": "text", @@ -3534,7 +3580,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 137, + "line": 141, "column": 147 } ] @@ -3573,12 +3619,12 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 96, + "line": 98, "column": 9 }, { "file": "features/plugins/PluginManagementPage.tsx", - "line": 268, + "line": 272, "column": 9 } ] @@ -3602,7 +3648,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 175, + "line": 121, "column": 70 } ] @@ -3628,7 +3674,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 122, + "line": 124, "column": 14 } ] @@ -3640,7 +3686,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 307, + "line": 311, "column": 30 } ] @@ -3654,12 +3700,12 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 157, + "line": 159, "column": 17 }, { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 88, + "line": 92, "column": 22 } ] @@ -3683,7 +3729,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 38, + "line": 37, "column": 29 } ] @@ -4027,7 +4073,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 256, + "line": 308, "column": 107 }, { @@ -4120,17 +4166,17 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 128, + "line": 94, "column": 28 }, { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 128, + "line": 94, "column": 89 }, { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 344, + "line": 402, "column": 84 }, { @@ -4145,6 +4191,20 @@ } ] }, + "affb73206cfa035d": { + "source": "到期时间:{time}", + "kind": "template", + "placeholders": [ + "time" + ], + "refs": [ + { + "file": "features/plugins/PluginResourcePanels.tsx", + "line": 434, + "column": 84 + } + ] + }, "b06325c5660f0c29": { "source": "直连", "kind": "text", @@ -4186,12 +4246,12 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 101, + "line": 103, "column": 7 }, { "file": "features/plugins/PluginManagementPage.tsx", - "line": 297, + "line": 301, "column": 70 } ] @@ -4232,7 +4292,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 99, + "line": 101, "column": 11 } ] @@ -4359,7 +4419,7 @@ "refs": [ { "file": "shell/ads/AdMenu.tsx", - "line": 44, + "line": 71, "column": 24 } ] @@ -4429,7 +4489,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 352, + "line": 483, "column": 81 } ] @@ -4497,7 +4557,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 110, + "line": 112, "column": 83 } ] @@ -4526,7 +4586,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 110, + "line": 76, "column": 51 } ] @@ -4552,7 +4612,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 274, + "line": 278, "column": 45 } ] @@ -4595,7 +4655,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 98, + "line": 100, "column": 11 } ] @@ -4607,7 +4667,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 313, + "line": 368, "column": 66 } ] @@ -4660,7 +4720,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 177, + "line": 123, "column": 77 } ] @@ -4672,7 +4732,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 316, + "line": 371, "column": 102 }, { @@ -4718,7 +4778,7 @@ }, { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 260, + "line": 312, "column": 37 } ] @@ -4730,7 +4790,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 104, + "line": 106, "column": 9 } ] @@ -4788,7 +4848,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 39, + "line": 38, "column": 30 } ] @@ -4841,8 +4901,8 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 229, - "column": 32 + "line": 188, + "column": 20 } ] }, @@ -4923,7 +4983,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 216, + "line": 268, "column": 32 } ] @@ -4983,7 +5043,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 298, + "line": 302, "column": 44 } ] @@ -5009,7 +5069,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 270, + "line": 322, "column": 22 } ] @@ -5057,7 +5117,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 35, + "line": 34, "column": 29 } ] @@ -5096,18 +5156,6 @@ } ] }, - "dbaa1c565cb1f108": { - "source": "15分钟", - "kind": "text", - "placeholders": [], - "refs": [ - { - "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 47, - "column": 37 - } - ] - }, "dbd3596e4a86f3c2": { "source": "配置模型", "kind": "text", @@ -5139,11 +5187,23 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 182, + "line": 216, "column": 25 } ] }, + "de878020fe02f0e9": { + "source": "使用后会立即消耗这张重置卡,且无法恢复。确定继续吗?", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginResourcePanels.tsx", + "line": 460, + "column": 11 + } + ] + }, "dea7749c4cd77e6d": { "source": "总请求 Token 包含提示词和模型输出。", "kind": "text", @@ -5238,7 +5298,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 31, + "line": 35, "column": 71 } ] @@ -5324,7 +5384,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 135, + "line": 139, "column": 34 } ] @@ -5336,7 +5396,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 96, + "line": 100, "column": 36 } ] @@ -5415,7 +5475,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 37, + "line": 36, "column": 36 } ] @@ -5456,7 +5516,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 216, + "line": 268, "column": 43 } ] @@ -5507,18 +5567,6 @@ } ] }, - "e85373502c372776": { - "source": "1分钟", - "kind": "text", - "placeholders": [], - "refs": [ - { - "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 46, - "column": 32 - } - ] - }, "e8b1268c1e3610f2": { "source": "已存在", "kind": "text", @@ -5531,6 +5579,18 @@ } ] }, + "e9d8d890d33584e8": { + "source": "没有可用的重置卡。", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginResourcePanels.tsx", + "line": 427, + "column": 98 + } + ] + }, "ea26b760e930a7ca": { "source": "调用观测", "kind": "text", @@ -5598,7 +5658,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 206, + "line": 240, "column": 20 } ] @@ -5656,6 +5716,20 @@ } ] }, + "eec6bf2dad677b9f": { + "source": "发放时间:{time}", + "kind": "template", + "placeholders": [ + "time" + ], + "refs": [ + { + "file": "features/plugins/PluginResourcePanels.tsx", + "line": 433, + "column": 84 + } + ] + }, "f4694c46b1e19602": { "source": "最终请求类型", "kind": "text", @@ -5690,7 +5764,7 @@ "refs": [ { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 260, + "line": 312, "column": 69 } ] @@ -5714,7 +5788,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 176, + "line": 122, "column": 21 } ] @@ -5738,8 +5812,8 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 219, - "column": 44 + "line": 178, + "column": 32 } ] }, @@ -5781,7 +5855,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 42, + "line": 41, "column": 35 } ] @@ -5793,7 +5867,7 @@ "refs": [ { "file": "features/home/overview/OverviewTimeRangeFilter.tsx", - "line": 136, + "line": 102, "column": 19 } ] @@ -5865,9 +5939,14 @@ "kind": "text", "placeholders": [], "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 82, + "column": 20 + }, { "file": "features/plugins/PluginResourcePanels.tsx", - "line": 252, + "line": 304, "column": 18 } ] @@ -5879,7 +5958,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 283, + "line": 287, "column": 23 } ] @@ -5891,7 +5970,7 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 110, + "line": 112, "column": 19 }, { @@ -5920,12 +5999,12 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 94, + "line": 96, "column": 7 }, { "file": "features/plugins/PluginManagementPage.tsx", - "line": 305, + "line": 309, "column": 29 } ] @@ -5988,8 +6067,8 @@ "refs": [ { "file": "features/plugins/PluginManagementPage.tsx", - "line": 219, - "column": 57 + "line": 178, + "column": 45 } ] }, @@ -6012,7 +6091,7 @@ "refs": [ { "file": "features/home/HomePage.tsx", - "line": 206, + "line": 201, "column": 25 } ] diff --git a/apps/desktop/src/i18n/locales/en-US.json b/apps/desktop/src/i18n/locales/en-US.json index c0d2f98..c4dc152 100644 --- a/apps/desktop/src/i18n/locales/en-US.json +++ b/apps/desktop/src/i18n/locales/en-US.json @@ -51,11 +51,13 @@ "1c6926877b1bbfb2": "Total requests: {tokens}", "1cb7e646ac883fb2": "Proxy port", "1cdde778cfe6a130": "Formula: cache read / (cache read + non-cached input)", + "1d19efc260a0a18f": "Use reset card", "1d27f02ed278ebc9": "Configuration file", "1f9b46e61efcccb5": "Filter: {label}, {summary}", "202064bb84804852": "Leave blank to use the default.", "20e14248fd4fb981": "{label} must be an integer greater than 0", "217cfe7db1e3d10a": "Use system language", + "21bd738e0d7191a7": "Loading…", "22c6b4eb4caee6ae": "Proxy settings saved", "22d7895ea5fca72e": "By provider", "23ae7a90b1b9816d": "Clear scope", @@ -80,6 +82,7 @@ "2f6416a2c424856b": "Final request URL", "2f7ba5fd1d12f7f9": "Open the tutorial?", "2f9daa828907b93f": "Delete", + "2fe0e3339ac4d1bb": "Expired", "2fe5a8d0eee9f14c": "Invalid", "303c30f301514250": "Search resources", "3260348163d03b8e": "Leave blank to keep unchanged", @@ -108,7 +111,6 @@ "402495402ce333b1": "Reinitialize plugins", "40a08e7cf320ae07": "Clear detailed records?", "4125fc7ba333524c": "Default light", - "4256064de039de89": "Display granularity", "42655ed8e4108ae2": "Input (non-cached)", "42a1d9e5b037c210": "Bytes", "42aa8e01e98c0d8c": "Total duration", @@ -132,7 +134,6 @@ "4d99c976beb8827e": "Ready", "4e30d7c9ed2b0eee": "Not set", "4eafa9e925b30bcd": "Custom", - "4f0f45b0312ca4ba": "1 hour", "51d04bc3d286f018": "Last calendar day", "51de3bcec137ab1b": "Connectivity tests succeeded for all {count} models", "5228358a6db59fe7": "For example: now, 2026-08-23 18:00", @@ -150,6 +151,7 @@ "59346e82b3dd2998": "TAB service address", "5a284a1a2be8da0e": "Read models from the local legacy configuration. New and existing models are shown before confirmation.", "5a3bd99fa69a40c1": "Use public service", + "5acb7d61688e3c4f": "Confirm use", "5b17f59d33bde39e": "Error: {error}", "5ba65a74c4e792c5": "By type", "5c55a67935af8f45": "All", @@ -211,7 +213,6 @@ "842b9f11cdd96bda": "Launch at login", "843ac7e15a5047a7": "Confirm legacy model configuration import", "844b8cc8dff7c1d8": "Default", - "859757ea792a73da": "30 minutes", "864597982c308d72": "Silent start enabled", "86de7c4ee8fa7689": "Sync models", "8716e1344b0daddb": "Cursor official", @@ -240,8 +241,10 @@ "954ec984cd4f49d1": "Syncing…", "966498853d801a52": "TAB connection", "96f7642963ca0dbf": "Enable Cursor takeover", + "9845c165151daee3": "Used", "9850ed41a5bfbb0c": "{count} selected", "997ec8201c2adeda": "Open terminal to install CA", + "9a84733cc9ab1706": "Resource details", "9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?", "9b9bc9cd7c76406f": "Open authorization page", "9c41b3a9e12ac994": "Reasoning effort", @@ -283,6 +286,7 @@ "ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}", "aecb952b1e6cce36": "Hide the Dock icon when disabled. You can still open the app from the menu bar icon.", "aee88743413144a2": "Refresh", + "affb73206cfa035d": "Expires: {time}", "b06325c5660f0c29": "Direct", "b16c3b2ecedd6fe1": "Cursor integration is active. Add a model configuration to use a BYOK model.", "b254ff315d861346": "Try initializing again", @@ -350,10 +354,10 @@ "da7ae985487c38e6": "Last hour", "daede9881787abe7": "Notes", "db340a9896306d08": "Test cancelled", - "dbaa1c565cb1f108": "15 minutes", "dbd3596e4a86f3c2": "Configured models", "ddde16f8839da3ce": "Total requests", "de8184da1ef88d03": "Configured", + "de878020fe02f0e9": "Using this card consumes it immediately and cannot be undone. Continue?", "dea7749c4cd77e6d": "Total request Tokens include the prompt and model output.", "df1baa9f706d970b": "To add", "df3d58c7d84b85f2": "Settings", @@ -381,8 +385,8 @@ "e77e3d58b0dcffaa": "Duration", "e825a2a42c22380e": "Model type", "e828bd3a0151edc2": "The local CA must be trusted by the system", - "e85373502c372776": "1 minute", "e8b1268c1e3610f2": "Existing", + "e9d8d890d33584e8": "No reset cards available.", "ea26b760e930a7ca": "Call observability", "eb11e2df1d8ae387": "Provider URL", "eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.", @@ -393,6 +397,7 @@ "edc70de18c6da1a6": "Install local CA", "ee239f3943293f87": "Sunday", "ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.", + "eec6bf2dad677b9f": "Granted: {time}", "f4694c46b1e19602": "Final request type", "f4dcb6a3ceb32247": "Page {page} of {count}", "f4f0ead1116b5b62": "Enabled", diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json index 0cac350..9946ebc 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/i18n/locales/zh-CN.json @@ -51,11 +51,13 @@ "1c6926877b1bbfb2": "总请求:{tokens}", "1cb7e646ac883fb2": "代理端口", "1cdde778cfe6a130": "公式:缓存读取 /(缓存读取 + 非缓存输入)", + "1d19efc260a0a18f": "使用重置卡", "1d27f02ed278ebc9": "配置文件", "1f9b46e61efcccb5": "筛选项:{label},{summary}", "202064bb84804852": "留空时使用默认值。", "20e14248fd4fb981": "{label} 必须是大于 0 的整数", "217cfe7db1e3d10a": "跟随系统", + "21bd738e0d7191a7": "正在加载…", "22c6b4eb4caee6ae": "代理设置已保存", "22d7895ea5fca72e": "按供应商", "23ae7a90b1b9816d": "清理范围", @@ -80,6 +82,7 @@ "2f6416a2c424856b": "最终请求地址", "2f7ba5fd1d12f7f9": "打开使用教程?", "2f9daa828907b93f": "删除", + "2fe0e3339ac4d1bb": "已过期", "2fe5a8d0eee9f14c": "已失效", "303c30f301514250": "搜索资源", "3260348163d03b8e": "留空保持不变", @@ -108,7 +111,6 @@ "402495402ce333b1": "重新初始化插件", "40a08e7cf320ae07": "确定要清理详细记录吗?", "4125fc7ba333524c": "默认亮色", - "4256064de039de89": "显示粒度", "42655ed8e4108ae2": "输入(非缓存)", "42a1d9e5b037c210": "字节数", "42aa8e01e98c0d8c": "总耗时", @@ -132,7 +134,6 @@ "4d99c976beb8827e": "可用", "4e30d7c9ed2b0eee": "不设置", "4eafa9e925b30bcd": "自定义", - "4f0f45b0312ca4ba": "1小时", "51d04bc3d286f018": "近1自然日", "51de3bcec137ab1b": "全部 {count} 个模型连通性测试成功", "5228358a6db59fe7": "如:现在、2026-08-23 18:00", @@ -150,6 +151,7 @@ "59346e82b3dd2998": "TAB 服务地址", "5a284a1a2be8da0e": "从本机旧版配置读取模型;确认前会显示新增和已存在的模型。", "5a3bd99fa69a40c1": "使用公益服务", + "5acb7d61688e3c4f": "确认使用", "5b17f59d33bde39e": "错误:{error}", "5ba65a74c4e792c5": "按类型", "5c55a67935af8f45": "全部", @@ -211,7 +213,6 @@ "842b9f11cdd96bda": "开机启动", "843ac7e15a5047a7": "确认导入旧版模型配置", "844b8cc8dff7c1d8": "默认", - "859757ea792a73da": "30分钟", "864597982c308d72": "已开启静默启动", "86de7c4ee8fa7689": "同步模型", "8716e1344b0daddb": "Cursor 官方", @@ -240,8 +241,10 @@ "954ec984cd4f49d1": "正在同步…", "966498853d801a52": "TAB 选择", "96f7642963ca0dbf": "开启接管Cursor", + "9845c165151daee3": "已使用", "9850ed41a5bfbb0c": "已选 {count} 项", "997ec8201c2adeda": "打开终端安装 CA", + "9a84733cc9ab1706": "资源详情", "9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?", "9b9bc9cd7c76406f": "打开授权网页", "9c41b3a9e12ac994": "思考强度", @@ -283,6 +286,7 @@ "ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}", "aecb952b1e6cce36": "关闭后隐藏 Dock 栏图标,仍可通过菜单栏图标打开应用。", "aee88743413144a2": "刷新", + "affb73206cfa035d": "到期时间:{time}", "b06325c5660f0c29": "直连", "b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。", "b254ff315d861346": "请重试初始化", @@ -350,10 +354,10 @@ "da7ae985487c38e6": "近1小时", "daede9881787abe7": "备注", "db340a9896306d08": "测试已取消", - "dbaa1c565cb1f108": "15分钟", "dbd3596e4a86f3c2": "配置模型", "ddde16f8839da3ce": "总请求", "de8184da1ef88d03": "已配置", + "de878020fe02f0e9": "使用后会立即消耗这张重置卡,且无法恢复。确定继续吗?", "dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。", "df1baa9f706d970b": "将新增", "df3d58c7d84b85f2": "设置", @@ -381,8 +385,8 @@ "e77e3d58b0dcffaa": "耗时", "e825a2a42c22380e": "模型类型", "e828bd3a0151edc2": "需要在系统中信任本地 CA", - "e85373502c372776": "1分钟", "e8b1268c1e3610f2": "已存在", + "e9d8d890d33584e8": "没有可用的重置卡。", "ea26b760e930a7ca": "调用观测", "eb11e2df1d8ae387": "上游地址", "eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。", @@ -393,6 +397,7 @@ "edc70de18c6da1a6": "安装本地 CA", "ee239f3943293f87": "周日", "ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。", + "eec6bf2dad677b9f": "发放时间:{time}", "f4694c46b1e19602": "最终请求类型", "f4dcb6a3ceb32247": "第 {page} / {count} 页", "f4f0ead1116b5b62": "启用", diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts index a11b8bd..c3f05ae 100644 --- a/apps/desktop/src/shared/api.ts +++ b/apps/desktop/src/shared/api.ts @@ -227,6 +227,35 @@ export interface PluginImportDescriptor { multiple: boolean; } +export interface PluginResourceAction { + id: string; + displayName: PluginLocalizedText; + description: PluginLocalizedText | null; + target: "resource" | "card"; + destructive: boolean; +} + +export interface PluginResourceActionField { + id: string; + label: PluginLocalizedText; + value: string; +} + +export interface PluginResourceActionCard { + id: string; + title: PluginLocalizedText; + status: PluginLocalizedText | null; + grantedAtMs: number | null; + expiresAtMs: number | null; + fields: PluginResourceActionField[]; +} + +export interface PluginResourceActionResult { + title: PluginLocalizedText; + description: PluginLocalizedText | null; + cards: PluginResourceActionCard[]; +} + export interface PluginResourceDescriptor { type: string; displayName: PluginLocalizedText; @@ -234,6 +263,7 @@ export interface PluginResourceDescriptor { import: PluginImportDescriptor | null; canRefresh: boolean; canRemove: boolean; + actions: PluginResourceAction[]; resources: PluginResourceView[]; } @@ -473,6 +503,7 @@ export const api = { pluginOAuthPoll: (sessionId: string, signal?: AbortSignal) => request(`/plugins/oauth/${encodeURIComponent(sessionId)}/poll`, { method: "POST", signal }), importPluginResources: (pluginId: string, resourceType: string, files: PluginImportFile[]) => request(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/import`, { method: "POST", body: JSON.stringify(files) }), refreshPluginResource: (pluginId: string, resourceType: string, resourceId: string) => request(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/refresh`, { method: "POST" }), + pluginResourceAction: (pluginId: string, resourceType: string, resourceId: string, actionId: string, input: unknown = {}) => request(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/actions/${encodeURIComponent(actionId)}`, { method: "POST", body: JSON.stringify(input) }), deletePluginResource: (pluginId: string, resourceType: string, resourceId: string) => request(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}`, { method: "DELETE" }), syncPluginModels: (pluginId: string, providerId: string) => request<{ models: number }>(`/plugins/${encodeURIComponent(pluginId)}/providers/${encodeURIComponent(providerId)}/models/sync`, { method: "POST" }), setPluginModelEnabled: (pluginId: string, providerId: string, modelId: string, enabled: boolean) => request(`/plugins/${encodeURIComponent(pluginId)}/providers/${encodeURIComponent(providerId)}/models/enabled`, { method: "PUT", body: JSON.stringify({ modelId, enabled }) }), diff --git a/apps/desktop/src/shared/ui/ActionMenu.module.scss b/apps/desktop/src/shared/ui/ActionMenu.module.scss index 3eb10b8..a60fbf8 100644 --- a/apps/desktop/src/shared/ui/ActionMenu.module.scss +++ b/apps/desktop/src/shared/ui/ActionMenu.module.scss @@ -11,18 +11,22 @@ border-radius: 6px; box-shadow: var(--oa-dropdown-shadow); - button { + button, + .textItem { 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; + } + + button { + color: var(--vscode-dropdown-foreground); + background: transparent; &:hover:not(:disabled) { background: var(--vscode-list-activeSelectionBackground); @@ -34,6 +38,11 @@ cursor: not-allowed; } } + + .textItem { + color: var(--vscode-descriptionForeground); + cursor: default; + } } .openIcon { diff --git a/apps/desktop/src/shared/ui/ActionMenu.tsx b/apps/desktop/src/shared/ui/ActionMenu.tsx index c335e46..8c0e3df 100644 --- a/apps/desktop/src/shared/ui/ActionMenu.tsx +++ b/apps/desktop/src/shared/ui/ActionMenu.tsx @@ -6,12 +6,19 @@ 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 type ActionMenuItem = + | { + id: string; + label: string; + type?: "action"; + disabled?: boolean; + onSelect: () => void; + } + | { + id: string; + label: string; + type: "text"; + }; /** 触发器 + 动作列表的下拉菜单,用于容纳卡片上的次要操作。 */ export function ActionMenu({ label, items, disabled }: { @@ -80,18 +87,24 @@ export function ActionMenu({ label, items, disabled }: { } }} > - {items.map((item) => )} + {items.map((item) => item.type === "text" ? ( + + {item.label} + + ) : ( + + ))} , document.body, )} diff --git a/server/plugins/build-in/antigravity-auth/models.ts b/server/plugins/build-in/antigravity-auth/models.ts index 9a148f6..4bd57c2 100644 --- a/server/plugins/build-in/antigravity-auth/models.ts +++ b/server/plugins/build-in/antigravity-auth/models.ts @@ -2,14 +2,14 @@ import type { JsonValue } from "cursor-byok:plugin"; import type { ModelDefinition, ModelSnapshot, ModelSupport } from "cursor-byok:model"; import { accountData } from "./resources.ts"; -export const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com"; -export const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com"; export const ANTIGRAVITY_PROD_ENDPOINT = "https://cloudcode-pa.googleapis.com"; +export const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com"; +export const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com"; export const ANTIGRAVITY_ENDPOINTS = [ - ANTIGRAVITY_SANDBOX_ENDPOINT, - ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_PROD_ENDPOINT, + ANTIGRAVITY_DAILY_ENDPOINT, + ANTIGRAVITY_SANDBOX_ENDPOINT, ]; const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels"; diff --git a/server/plugins/build-in/antigravity-auth/plugin.json b/server/plugins/build-in/antigravity-auth/plugin.json index 802528b..00edea1 100644 --- a/server/plugins/build-in/antigravity-auth/plugin.json +++ b/server/plugins/build-in/antigravity-auth/plugin.json @@ -1,8 +1,8 @@ { "apiVersion": 1, "id": "dev.cursorbyok.plugins.antigravity-auth", - "name": "Antigravity", - "version": "0.1.7", + "name": "Google", + "version": "0.1.8", "author": "@masudranaxpert", "minAppVersion": "0.1.0", "icon": "assets/antigravity.svg", diff --git a/server/plugins/build-in/antigravity-auth/provider.ts b/server/plugins/build-in/antigravity-auth/provider.ts index c211e09..e38704c 100644 --- a/server/plugins/build-in/antigravity-auth/provider.ts +++ b/server/plugins/build-in/antigravity-auth/provider.ts @@ -357,6 +357,7 @@ async function streamCloudCode( const headers: Record = { authorization: `Bearer ${accessToken}`, "content-type": "application/json", + accept: "text/event-stream", "user-agent": ANTIGRAVITY_USER_AGENT, ...ANTIGRAVITY_CLIENT_HEADERS, }; @@ -403,6 +404,7 @@ async function streamCloudCode( inputTokens: number | null; outputTokens: number | null; totalTokens: number | null; + cacheReadTokens: number | null; } | null = null; for await (const line of response.lines) { @@ -428,6 +430,9 @@ async function streamCloudCode( ? usage.candidatesTokenCount : null, totalTokens: typeof usage.totalTokenCount === "number" ? usage.totalTokenCount : null, + cacheReadTokens: typeof usage.cachedContentTokenCount === "number" + ? usage.cachedContentTokenCount + : null, }; } @@ -535,7 +540,7 @@ async function streamCloudCode( inputTokens: finalUsage.inputTokens, outputTokens: finalUsage.outputTokens, totalTokens: finalUsage.totalTokens, - cacheReadTokens: null, + cacheReadTokens: finalUsage.cacheReadTokens, cacheWriteTokens: null, reasoningTokens: null, }, @@ -573,7 +578,7 @@ async function streamCloudCode( inputTokens: finalUsage.inputTokens, outputTokens: finalUsage.outputTokens, totalTokens: finalUsage.totalTokens, - cacheReadTokens: null, + cacheReadTokens: finalUsage.cacheReadTokens, cacheWriteTokens: null, reasoningTokens: null, }, diff --git a/server/plugins/build-in/antigravity-auth/provider_test.ts b/server/plugins/build-in/antigravity-auth/provider_test.ts new file mode 100644 index 0000000..541493b --- /dev/null +++ b/server/plugins/build-in/antigravity-auth/provider_test.ts @@ -0,0 +1,146 @@ +import type { + JsonValue, + NetworkEventStream, + NetworkResponse, + PluginContext, +} from "cursor-byok:plugin"; +import type { LlmRequest, ModelEvent } from "cursor-byok:provider"; +import type { ResourceSnapshot } from "cursor-byok:resource"; +import { antigravityProvider, isQuotaError } from "./provider.ts"; +import { RESOURCE_TYPE } from "./resources.ts"; + +function assert(condition: unknown, message = "assertion failed"): asserts condition { + if (!condition) throw new Error(message); +} + +function assertEquals(actual: unknown, expected: unknown): void { + const left = JSON.stringify(actual); + const right = JSON.stringify(expected); + if (left !== right) throw new Error(`expected ${right}, received ${left}`); +} + +type RequestInit = { body?: string; headers?: Record }; +type FetchHandler = (url: string, init?: RequestInit) => NetworkResponse; +type StreamHandler = (url: string, init?: RequestInit) => NetworkEventStream; + +function context(handlers: { fetch?: FetchHandler; stream?: StreamHandler }): PluginContext { + return { + network: { + fetch: (url, init) => { + if (!handlers.fetch) throw new Error("fetch was not expected"); + return Promise.resolve(handlers.fetch(url, init)); + }, + stream: (url, init) => { + if (!handlers.stream) throw new Error("stream was not expected"); + return Promise.resolve(handlers.stream(url, init)); + }, + }, + signal: new AbortController().signal, + }; +} + +function snapshot(privateData: JsonValue): ResourceSnapshot { + return { + id: "resource-1", + type: RESOURCE_TYPE, + key: "antigravity:user-1", + privateData, + state: { status: "ready" }, + }; +} + +async function* sse(lines: string[]): AsyncGenerator { + for (const line of lines) yield line; +} + +function request(): LlmRequest { + return { + instructions: "You are an AI coding assistant.", + messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + tools: [], + reasoning: { enabled: true, effort: "medium" }, + latency: "standard", + maxOutputTokens: 65536, + cacheKey: "conv-1", + }; +} + +Deno.test("provider parses usage metadata including cachedContentTokenCount as cacheReadTokens", async () => { + let requestBody = ""; + let requestHeaders: Record = {}; + const events: ModelEvent[] = []; + + const result = await antigravityProvider.invoke( + { + model: { + id: "gemini-3.7-flash-medium", + displayName: "Gemini 3.7 Flash Medium", + privateData: {}, + }, + resource: snapshot({ + accessToken: "mock-access-token", + refreshToken: "mock-refresh-token", + expiresAt: Date.now() + 3600_000, + projectId: "test-project-123", + }), + request: request(), + }, + { emit: (event) => events.push(event) }, + context({ + stream: (url, init) => { + assert(url.includes("/v1internal:streamGenerateContent?alt=sse")); + requestBody = init?.body ?? ""; + requestHeaders = init?.headers ?? {}; + return { + status: 200, + headers: {}, + lines: sse([ + 'data: {"response":{"candidates":[{"content":{"parts":[{"thought":true,"text":"Thinking..."}]}}]}}', + 'data: {"response":{"candidates":[{"content":{"parts":[{"text":"Hello world"}]}}]}}', + 'data: {"response":{"candidates":[{"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":120,"candidatesTokenCount":15,"totalTokenCount":135,"cachedContentTokenCount":80}}}', + ]), + }; + }, + }), + ); + + assertEquals(result, { status: "completed" }); + assertEquals(requestHeaders["authorization"], "Bearer mock-access-token"); + assertEquals(requestHeaders.accept, "text/event-stream"); + assert(!("session-id" in requestHeaders), "Codex-only session-id header was sent"); + assert(!("thread-id" in requestHeaders), "Codex-only thread-id header was sent"); + assert(!("x-client-request-id" in requestHeaders), "request-id header was reused as cache key"); + const body = JSON.parse(requestBody) as Record; + assertEquals(body.project, "test-project-123"); + assertEquals(body.model, "gemini-3.7-flash-medium"); + assert(!("sessionId" in body), "unsupported sessionId field was sent"); + assert(!("conversationId" in body), "unsupported conversationId field was sent"); + + assertEquals(events, [ + { type: "thinking-start" }, + { type: "thinking-delta", text: "Thinking..." }, + { type: "thinking-end" }, + { type: "text-start" }, + { type: "text-delta", text: "Hello world" }, + { type: "text-end" }, + { + type: "usage", + usage: { + inputTokens: 120, + outputTokens: 15, + totalTokens: 135, + cacheReadTokens: 80, + cacheWriteTokens: null, + reasoningTokens: null, + }, + }, + { type: "done", reason: "stop" }, + ]); +}); + +Deno.test("isQuotaError identifies rate limits and quota exhaustion", () => { + assert(isQuotaError("RESOURCE_EXHAUSTED: quota exceeded")); + assert(isQuotaError("Rate limit exceeded for model")); + assert(isQuotaError("HTTP 429 Too Many Requests")); + assert(!isQuotaError("Invalid authorization header")); +}); diff --git a/server/plugins/build-in/codex-auth/codex_test.ts b/server/plugins/build-in/codex-auth/codex_test.ts index 1149537..8ae3ae0 100644 --- a/server/plugins/build-in/codex-auth/codex_test.ts +++ b/server/plugins/build-in/codex-auth/codex_test.ts @@ -13,6 +13,8 @@ import { codexProvider, isQuotaError } from "./provider.ts"; import { accountIdentity, credentialDraft, + consumeResetCardAction, + listResetCardsAction, parseCodexUsage, parseCredentialFiles, presentAccount, @@ -134,6 +136,7 @@ Deno.test("usage maps secondary to weekly and primary to five-hour quota", () => primary_window: { used_percent: 80, reset_at: 1_800_000_000 }, secondary_window: { used_percent: 25, reset_at: 1_900_000_000 }, }, + rate_limit_reset_credits: { available_count: 2 }, }, 1_700_000_000_000); assertEquals(quota.planLabel, "ChatGPT Plus"); assertEquals(quota.weekly?.remainingPercent, 75); diff --git a/server/plugins/build-in/codex-auth/main.ts b/server/plugins/build-in/codex-auth/main.ts index beb077e..109332a 100644 --- a/server/plugins/build-in/codex-auth/main.ts +++ b/server/plugins/build-in/codex-auth/main.ts @@ -1,7 +1,14 @@ import { defineProviderPlugin } from "cursor-byok:plugin"; import { codexDeviceOAuth } from "./oauth.ts"; import { codexProvider } from "./provider.ts"; -import { credentialImport, presentAccount, refreshAccount, RESOURCE_TYPE } from "./resources.ts"; +import { + credentialImport, + consumeResetCardAction, + listResetCardsAction, + presentAccount, + refreshAccount, + RESOURCE_TYPE, +} from "./resources.ts"; export default defineProviderPlugin({ providers: [codexProvider], @@ -11,6 +18,7 @@ export default defineProviderPlugin({ add: [codexDeviceOAuth], import: credentialImport, present: presentAccount, + actions: [listResetCardsAction, consumeResetCardAction], refresh: refreshAccount, }], }); diff --git a/server/plugins/build-in/codex-auth/resources.ts b/server/plugins/build-in/codex-auth/resources.ts index 2d49b7c..eac0824 100644 --- a/server/plugins/build-in/codex-auth/resources.ts +++ b/server/plugins/build-in/codex-auth/resources.ts @@ -1,5 +1,8 @@ import type { JsonValue, PluginContext } from "cursor-byok:plugin"; import type { + ResourceAction, + ResourceActionCard, + ResourceActionResult, ResourceDraft, ResourceImportFile, ResourceImportResult, @@ -14,8 +17,13 @@ import type { export const RESOURCE_TYPE = "chatgpt-account"; const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; +const RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"; +const RESET_CREDITS_CONSUME_URL = `${RESET_CREDITS_URL}/consume`; const FIVE_HOURS_MS = 5 * 60 * 60 * 1000; +export const LIST_RESET_CARDS_ACTION_ID = "list-reset-cards"; +export const CONSUME_RESET_CARD_ACTION_ID = "consume-reset-card"; + export type QuotaWindow = { usedPercent: number | null; remainingPercent: number | null; @@ -26,6 +34,7 @@ export type AccountQuota = { planLabel: string | null; weekly: QuotaWindow | null; fiveHour: QuotaWindow | null; + resetCreditsAvailable: number | null; limitReached: boolean; updatedAtMs: number; }; @@ -34,6 +43,7 @@ export type AccountQuota = { export type AccountData = { accessToken: string; refreshToken: string | null; + accountId: string | null; displayName: string; quota: AccountQuota | null; }; @@ -41,6 +51,7 @@ export type AccountData = { export type CredentialCandidate = { accessToken: string; refreshToken: string | null; + accountId?: string | null; displayName: string | null; }; @@ -102,9 +113,10 @@ function profileEmail(payload: Record | null): string | null { export async function accountIdentity( accessToken: string, + accountId?: string | null, ): Promise<{ key: string; displayName: string }> { const payload = decodeJwtPayload(accessToken); - const identity = chatGptAccountId(accessToken) ?? + const identity = accountId ?? chatGptAccountId(accessToken) ?? claim(payload, "sub") ?? claim(payload, "email") ?? await tokenFingerprint(accessToken); @@ -117,10 +129,11 @@ export async function accountIdentity( } export async function credentialDraft(credential: CredentialCandidate): Promise { - const identity = await accountIdentity(credential.accessToken); + const identity = await accountIdentity(credential.accessToken, credential.accountId); const data: AccountData = { accessToken: credential.accessToken, refreshToken: credential.refreshToken, + accountId: credential.accountId ?? chatGptAccountId(credential.accessToken), displayName: credential.displayName ?? identity.displayName, quota: null, }; @@ -134,6 +147,7 @@ export function accountData(resource: ResourceSnapshot): AccountData { return { accessToken, refreshToken: text(data?.refreshToken), + accountId: text(data?.accountId) ?? chatGptAccountId(accessToken), displayName: text(data?.displayName) ?? "ChatGPT account", quota: (data?.quota ?? null) as AccountQuota | null, }; @@ -145,7 +159,7 @@ export function accountHeaders(data: AccountData): Record { originator: "codex_cli_rs", authorization: `Bearer ${data.accessToken}`, }; - const accountId = chatGptAccountId(data.accessToken); + const accountId = data.accountId ?? chatGptAccountId(data.accessToken); if (accountId) headers["ChatGPT-Account-Id"] = accountId; return headers; } @@ -207,10 +221,17 @@ export function parseCodexUsage(body: unknown, nowMs = Date.now()): AccountQuota ? null : quotaWindow(primary, nowMs); const explicitLimit = rateLimit.limit_reached ?? rateLimit.limitReached; + const resetCredits = object(root.rate_limit_reset_credits ?? root.rateLimitResetCredits); + const resetCreditsAvailable = number( + resetCredits?.available_count ?? resetCredits?.availableCount, + ); return { planLabel: planLabel(root.plan_type ?? root.planType), weekly, fiveHour, + resetCreditsAvailable: resetCreditsAvailable === null + ? null + : Math.max(0, Math.floor(resetCreditsAvailable)), limitReached: typeof explicitLimit === "boolean" ? explicitLimit : [weekly, fiveHour].some( (window) => window?.remainingPercent !== null && window?.remainingPercent === 0, ), @@ -271,6 +292,7 @@ export function quotaExhaustedPatch( remainingPercent: 0, resetAtMs: resetFromError(error, nowMs), }, + resetCreditsAvailable: data.quota?.resetCreditsAvailable ?? null, limitReached: true, updatedAtMs: nowMs, }; @@ -280,6 +302,144 @@ export function quotaExhaustedPatch( }; } +async function fetchResetCredits( + data: AccountData, + context: PluginContext, +): Promise<{ cards: ResourceActionCard[]; availableCount: number }> { + const accountId = data.accountId ?? chatGptAccountId(data.accessToken); + if (!accountId) throw new Error("ChatGPT account is missing its account ID"); + const response = await context.network.fetch(RESET_CREDITS_URL, { + method: "GET", + headers: accountHeaders(data), + }); + if (response.status < 200 || response.status >= 300) { + throw new Error( + `Codex reset card lookup failed (HTTP ${response.status}): ${response.body}`, + ); + } + let body: unknown; + try { + body = JSON.parse(response.body); + } catch { + throw new Error("Codex reset card lookup returned invalid JSON"); + } + const root = object(body) ?? {}; + const rawCredits = Array.isArray(root.credits) ? root.credits : []; + const cards = rawCredits.flatMap((value, index): ResourceActionCard[] => { + const credit = object(value); + const id = text(credit?.id); + if (!id) return []; + const resetType = text(credit?.reset_type ?? credit?.resetType); + const grantedAt = timestampMs(credit?.granted_at ?? credit?.grantedAt); + const expiresAt = timestampMs(credit?.expires_at ?? credit?.expiresAt); + return [{ + id, + title: text(credit?.title) ?? resetType ?? `Codex reset card ${index + 1}`, + ...(text(credit?.status) ? { status: text(credit?.status)! } : {}), + ...(grantedAt !== null ? { grantedAtMs: grantedAt } : {}), + ...(expiresAt !== null ? { expiresAtMs: expiresAt } : {}), + fields: resetType + ? [{ + id: "reset-type", + label: { "en-US": "Reset type", "zh-CN": "重置类型" }, + value: resetType, + }] + : [], + }]; + }); + const availableCount = number(root.available_count ?? root.availableCount); + return { + cards, + availableCount: availableCount === null ? cards.filter((card) => card.status === "available").length : Math.max(0, Math.floor(availableCount)), + }; +} + +function timestampMs(value: unknown): number | null { + const numeric = number(value); + if (numeric !== null) return numeric > 10_000_000_000 ? numeric : numeric * 1000; + if (typeof value === "string") { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +function actionDescription(availableCount: number): ResourceActionResult["description"] { + return { + "en-US": `${availableCount} reset card${availableCount === 1 ? "" : "s"} available`, + "zh-CN": `可用重置卡 ${availableCount} 张`, + }; +} + +async function listResetCards( + resource: ResourceSnapshot, + _input: JsonValue, + context: PluginContext, +): Promise { + const result = await fetchResetCredits(accountData(resource), context); + return { + title: { "en-US": "Codex reset cards", "zh-CN": "Codex 重置卡" }, + description: actionDescription(result.availableCount), + cards: result.cards, + }; +} + +async function consumeResetCard( + resource: ResourceSnapshot, + input: JsonValue, + context: PluginContext, +): Promise { + const inputObject = object(input); + const creditId = text(inputObject?.creditId ?? inputObject?.cardId); + if (!creditId) throw new Error("A reset card ID is required"); + + const data = accountData(resource); + const available = await fetchResetCredits(data, context); + const card = available.cards.find((item) => item.id === creditId && item.status === "available"); + if (!card) throw new Error("The selected reset card is not available"); + + const response = await context.network.fetch(RESET_CREDITS_CONSUME_URL, { + method: "POST", + headers: { ...accountHeaders(data), "content-type": "application/json" }, + body: JSON.stringify({ credit_id: creditId, redeem_request_id: crypto.randomUUID() }), + }); + if (response.status < 200 || response.status >= 300) { + throw new Error( + `Codex reset card consumption failed (HTTP ${response.status}): ${response.body}`, + ); + } + + const patch = await refreshAccount(resource, context); + const refreshedResource: ResourceSnapshot = { + ...resource, + ...(patch.privateData ? { privateData: patch.privateData } : {}), + }; + const refreshed = await fetchResetCredits(accountData(refreshedResource), context); + return { + title: { "en-US": "Codex reset card used", "zh-CN": "Codex 重置卡已使用" }, + description: actionDescription(refreshed.availableCount), + cards: refreshed.cards, + patch, + }; +} + +export const listResetCardsAction: ResourceAction = { + id: LIST_RESET_CARDS_ACTION_ID, + displayName: { "en-US": "View reset cards", "zh-CN": "查看重置卡" }, + description: { "en-US": "List available Codex reset cards.", "zh-CN": "查看当前账号的 Codex 重置卡。" }, + target: "resource", + run: listResetCards, +}; + +export const consumeResetCardAction: ResourceAction = { + id: CONSUME_RESET_CARD_ACTION_ID, + displayName: { "en-US": "Use reset card", "zh-CN": "使用重置卡" }, + description: { "en-US": "Redeem one available Codex reset card.", "zh-CN": "消耗一张可用的 Codex 重置卡。" }, + target: "card", + destructive: true, + run: consumeResetCard, +}; + export function presentAccount(resource: ResourceSnapshot): ResourceView { const data = accountData(resource); const metrics: ResourceMetric[] = []; @@ -303,6 +463,15 @@ export function presentAccount(resource: ResourceSnapshot): ResourceView { ...(fiveHour.resetAtMs !== null ? { resetAtMs: fiveHour.resetAtMs } : {}), }); } + const resetCreditsAvailable = data.quota?.resetCreditsAvailable; + if (resetCreditsAvailable !== null && resetCreditsAvailable !== undefined) { + metrics.push({ + id: "reset-credits", + label: { "en-US": "Reset cards", "zh-CN": "重置卡" }, + unit: "count", + value: resetCreditsAvailable, + }); + } return { // 旧记录可能存的是账号 ID;展示时优先从 token 现算邮箱。 displayName: jwtDisplayName(data.accessToken) ?? data.displayName, @@ -375,12 +544,14 @@ function collectCredentials(value: unknown, output: CredentialCandidate[]): void if (!accessToken) return; const refreshToken = firstText(tokens, ["refresh_token", "refreshToken"]) ?? firstText(item, ["refresh_token", "refreshToken"]); + const accountId = firstText(tokens, ["account_id", "accountId", "chatgpt_account_id"]) ?? + firstText(item, ["account_id", "accountId", "chatgpt_account_id"]); const idToken = firstText(tokens, ["id_token", "idToken"]) ?? firstText(item, ["id_token", "idToken"]); const displayName = firstText(item, ["email", "display_name", "displayName", "name"]) ?? firstText(tokens, ["email", "display_name", "displayName", "name"]) ?? jwtDisplayName(idToken); - output.push({ accessToken, refreshToken, displayName }); + output.push({ accessToken, refreshToken, ...(accountId ? { accountId } : {}), displayName }); } export function parseCredentialFiles(files: ResourceImportFile[]): { diff --git a/server/src/control/mod.rs b/server/src/control/mod.rs index a6a686b..4c8aa39 100644 --- a/server/src/control/mod.rs +++ b/server/src/control/mod.rs @@ -172,6 +172,10 @@ pub fn api_router(service: ControlService) -> Router { "/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/{resource_id}", axum::routing::delete(plugins::delete_resource), ) + .route( + "/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/{resource_id}/actions/{action_id}", + post(plugins::action), + ) .route( "/__byok-api__/api/plugins/{plugin_id}/resources/{resource_type}/{resource_id}/refresh", post(plugins::refresh_resource), diff --git a/server/src/control/plugins.rs b/server/src/control/plugins.rs index ffae869..1abbc3c 100644 --- a/server/src/control/plugins.rs +++ b/server/src/control/plugins.rs @@ -87,6 +87,23 @@ pub async fn refresh_resource( Ok(StatusCode::NO_CONTENT) } +pub async fn action( + State(service): State, + Path((plugin_id, resource_type, resource_id, action_id)): Path<( + String, + String, + String, + String, + )>, + Json(input): Json, +) -> Result> { + Ok(Json( + service + .plugin_resource_action(&plugin_id, &resource_type, &resource_id, &action_id, input) + .await?, + )) +} + pub async fn delete_resource( State(service): State, Path((plugin_id, resource_type, resource_id)): Path<(String, String, String)>, diff --git a/server/src/control/service.rs b/server/src/control/service.rs index 9d53581..51e07e6 100644 --- a/server/src/control/service.rs +++ b/server/src/control/service.rs @@ -226,6 +226,19 @@ impl ControlService { .await } + pub async fn plugin_resource_action( + &self, + plugin_id: &str, + resource_type: &str, + resource_id: &str, + action_id: &str, + input: serde_json::Value, + ) -> Result { + self.plugins + .resource_action(plugin_id, resource_type, resource_id, action_id, input) + .await + } + pub async fn plugin_delete_resource( &self, plugin_id: &str, diff --git a/server/src/plugin/descriptor.rs b/server/src/plugin/descriptor.rs index ba92bc3..841b1e9 100644 --- a/server/src/plugin/descriptor.rs +++ b/server/src/plugin/descriptor.rs @@ -38,10 +38,25 @@ pub struct ResourceDefinition { pub add: Vec, #[serde(default)] pub import: Option, + #[serde(default)] + pub actions: Vec, pub can_refresh: bool, pub can_remove: bool, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ResourceActionDefinition { + pub id: String, + pub display_name: LocalizedText, + #[serde(default)] + pub description: LocalizedText, + #[serde(default)] + pub target: String, + #[serde(default)] + pub destructive: bool, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AddMethodDefinition { @@ -132,6 +147,7 @@ pub struct PluginResourceDescriptor { pub display_name: LocalizedText, pub add: Vec, pub import: Option, + pub actions: Vec, pub can_refresh: bool, pub can_remove: bool, pub resources: Vec, @@ -171,6 +187,61 @@ pub struct ResourcePresentation { pub metrics: Vec, } +/// 插件资源操作返回的安全详情;patch 只在核心内部应用,不会回传给桌面端。 +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ResourceActionResult { + pub title: LocalizedText, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub cards: Vec, + #[serde(default, skip_serializing)] + pub patch: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ResourceActionCard { + pub id: String, + pub title: LocalizedText, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub granted_at_ms: Option, + #[serde(default)] + pub expires_at_ms: Option, + #[serde(default)] + pub fields: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ResourceActionField { + pub id: String, + pub label: LocalizedText, + pub value: String, +} + +/// 返回给桌面端的资源操作结果,明确排除插件私有 patch。 +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourceActionResponse { + pub title: LocalizedText, + pub description: Option, + pub cards: Vec, +} + +impl From for ResourceActionResponse { + fn from(result: ResourceActionResult) -> Self { + Self { + title: result.title, + description: result.description, + cards: result.cards, + } + } +} + impl PluginResourceView { pub fn from_record(record: &ResourceRecord, presentation: ResourcePresentation) -> Self { Self { diff --git a/server/src/plugin/registry.rs b/server/src/plugin/registry.rs index 0dc365c..12c5d72 100644 --- a/server/src/plugin/registry.rs +++ b/server/src/plugin/registry.rs @@ -13,8 +13,9 @@ use super::{ data::PluginDataStore, descriptor::{ parse_model_id, PluginDescriptor, PluginModelDescriptor, PluginProviderDescriptor, - PluginResourceDescriptor, PluginResourceView, ProviderDefinition, ResourceDefinition, - ResourcePresentation, OAUTH2_ADD_METHOD, OAUTH2_AUTHORIZATION_CODE_ADD_METHOD, + PluginResourceDescriptor, PluginResourceView, ProviderDefinition, ResourceActionResponse, + ResourceActionResult, ResourceDefinition, ResourcePresentation, OAUTH2_ADD_METHOD, + OAUTH2_AUTHORIZATION_CODE_ADD_METHOD, }, oauth_callback::{self, CallbackHandle, CallbackOutcome, CallbackRequest}, runtime::PluginRuntime, @@ -838,6 +839,58 @@ impl PluginRegistry { .await } + pub async fn resource_action( + &self, + plugin_id: &str, + resource_type: &str, + resource_id: &str, + action_id: &str, + input: serde_json::Value, + ) -> Result { + let executable = self.executable()?; + let entry = self.find_entry(&executable, plugin_id).await?; + let resource = find_resource(&entry, resource_type)?; + let action = resource + .actions + .iter() + .find(|action| action.id == action_id) + .ok_or_else(|| { + Error::Config(format!( + "plugin '{plugin_id}' resource '{resource_type}' does not define action '{action_id}'" + )) + })?; + if !matches!(action.target.as_str(), "resource" | "card") { + return Err(Error::Config(format!( + "plugin '{plugin_id}' resource action '{action_id}' has an invalid target" + ))); + } + let record = self + .find_record(plugin_id, resource_type, resource_id) + .await?; + let value = self + .worker(&entry, &executable) + .await + .invoke( + "resource.action", + serde_json::json!({ + "resourceType": resource_type, + "actionId": action_id, + "resource": record.snapshot(resource_type), + "input": input, + }), + CancellationToken::new(), + ) + .await?; + let result: ResourceActionResult = serde_json::from_value(value)?; + if let Some(patch) = result.patch.clone() { + self.inner + .state + .apply_patch(plugin_id, resource_type, resource_id, patch) + .await?; + } + Ok(serde_json::to_value(ResourceActionResponse::from(result))?) + } + pub async fn delete_resource( &self, plugin_id: &str, @@ -961,6 +1014,7 @@ impl PluginRegistry { display_name: definition.display_name.clone(), add: definition.add.clone(), import: definition.import.clone(), + actions: definition.actions.clone(), can_refresh: definition.can_refresh, can_remove: definition.can_remove, resources: views, diff --git a/server/src/plugin/sdk/plugin.ts b/server/src/plugin/sdk/plugin.ts index 4106784..7fb1322 100644 --- a/server/src/plugin/sdk/plugin.ts +++ b/server/src/plugin/sdk/plugin.ts @@ -99,6 +99,13 @@ export function __descriptor(definition: ProviderPluginDefinition) { multiple: resource.import.multiple ?? false, } : null, + actions: (resource.actions ?? []).map((action) => ({ + id: action.id, + displayName: action.displayName, + description: action.description ?? null, + target: action.target ?? "resource", + destructive: action.destructive ?? false, + })), canRefresh: resource.refresh !== undefined, canRemove: resource.remove !== undefined, })), diff --git a/server/src/plugin/sdk/resource.ts b/server/src/plugin/sdk/resource.ts index 7c15acc..4757bd4 100644 --- a/server/src/plugin/sdk/resource.ts +++ b/server/src/plugin/sdk/resource.ts @@ -44,6 +44,45 @@ export type ResourceMetric = { resetAtMs?: number; }; +export type ResourceActionTarget = "resource" | "card"; + +export type ResourceAction = { + id: string; + displayName: LocalizedText; + description?: LocalizedText; + target?: ResourceActionTarget; + destructive?: boolean; + run( + resource: ResourceSnapshot, + input: JsonValue, + context: PluginContext, + ): Promise; +}; + +export type ResourceActionField = { + id: string; + label: LocalizedText; + value: string; +}; + +/** 资源操作返回的通用详情卡片;不得包含凭证。 */ +export type ResourceActionCard = { + id: string; + title: LocalizedText; + status?: LocalizedText; + grantedAtMs?: number; + expiresAtMs?: number; + fields?: ResourceActionField[]; +}; + +export type ResourceActionResult = { + title: LocalizedText; + description?: LocalizedText; + cards?: ResourceActionCard[]; + /** 消费类操作可用它更新宿主保存的资源状态。 */ + patch?: ResourcePatch; +}; + /** 单条资源的用户可见投影;不得泄露凭证。displayName 是数据(如邮箱),保持纯字符串。 */ export type ResourceView = { displayName: string; @@ -145,6 +184,7 @@ export type ResourceSupport = { add?: ResourceAddMethod[]; import?: ResourceImportSupport; present(resource: ResourceSnapshot): ResourceView; + actions?: ResourceAction[]; /** 用户主动触发时重新读取上游状态(额度、凭证有效性)。 */ refresh?(resource: ResourceSnapshot, context: PluginContext): Promise; /** 可选的上游撤销;宿主随后删除本地记录。 */ diff --git a/server/src/plugin/sdk/worker.ts b/server/src/plugin/sdk/worker.ts index a08849b..1e29c58 100644 --- a/server/src/plugin/sdk/worker.ts +++ b/server/src/plugin/sdk/worker.ts @@ -122,6 +122,17 @@ async function dispatch(message: { id: string; method: string; params?: JsonValu result = await support.refresh(params.resource as never, context); break; } + case "resource.action": { + const support = resourceSupport(params.resourceType); + const action = (support.actions ?? []).find((item) => item.id === params.actionId); + if (!action) throw new Error(`resource ${params.resourceType} has no action ${params.actionId}`); + result = await action.run( + params.resource as never, + params.input ?? null, + context, + ); + break; + } case "resource.remove": { const support = resourceSupport(params.resourceType); await support.remove?.(params.resource as never, context);