mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-04 02:40:50 +08:00
feat(desktop): add plugin resource actions
This commit is contained in:
@@ -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 (
|
||||
<Card className={styles.pluginCard}>
|
||||
<div className={styles.pluginCardTop}>
|
||||
@@ -191,9 +223,9 @@ function PluginCard({ plugin, onOpen }: {
|
||||
models: modelCount,
|
||||
})}
|
||||
</span>
|
||||
<span className={styles.pluginAuthor}>
|
||||
{[`v${plugin.version}`, plugin.author].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
{plugin.author && (
|
||||
<span className={styles.pluginAuthor}>{plugin.author}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.cardActions}>
|
||||
<TruncatedButton
|
||||
@@ -209,39 +241,9 @@ function PluginCard({ plugin, onOpen }: {
|
||||
onClick={() => onOpen(plugin.id, "settings")}
|
||||
/>
|
||||
)}
|
||||
{(importResource || exportResource) && (
|
||||
{moreItems.length > 0 && (
|
||||
<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,
|
||||
),
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
<ActionMenu label={t("更多")} items={moreItems} />
|
||||
</span>
|
||||
)}
|
||||
{importResource && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [modelProviderId, setModelProviderId] = useState<string | null>(null);
|
||||
const [resourceAction, setResourceAction] = useState<{
|
||||
resource: PluginResourceDescriptor;
|
||||
item: PluginResourceView;
|
||||
} | null>(null);
|
||||
const [resourceActionResult, setResourceActionResult] = useState<PluginResourceActionResult | null>(null);
|
||||
const [resourceActionError, setResourceActionError] = useState<string | null>(null);
|
||||
const modelProvider = modelProviderId ? plugin.providers.find((provider) => provider.id === modelProviderId) ?? null : null;
|
||||
|
||||
const run = async (key: string, task: () => Promise<void>) => {
|
||||
@@ -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 <div className={styles.panel}>
|
||||
{plugin.providers.map((provider) => <ProviderRow
|
||||
key={provider.id}
|
||||
@@ -175,6 +217,7 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
|
||||
key={resource.type}
|
||||
resource={resource}
|
||||
busy={busy !== null}
|
||||
onAction={(item, action) => 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 && <ResourceActionModal
|
||||
action={resourceAction.resource.actions.find((item) => 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 })}
|
||||
/>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -278,9 +330,10 @@ function ModelManagementModal({ provider, busy, onClose, onSubmit }: {
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
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) => <ResourceRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
actions={resource.actions.filter((action) => 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 }: {
|
||||
</FormField>;
|
||||
}
|
||||
|
||||
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 }: {
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<StateBadge state={item.state} />
|
||||
{actions.map((action) => <Button key={action.id} size="small" disabled={disabled} onClick={() => onAction(action)}>{pluginText(action.displayName, locale)}</Button>)}
|
||||
{canRefresh && <Button size="small" disabled={disabled} onClick={onRefresh}>{t("刷新")}</Button>}
|
||||
<Button size="small" disabled={disabled} onClick={onDelete}>{t("删除")}</Button>
|
||||
</div>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
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<PluginResourceActionCard | null>(null);
|
||||
const title = result ? pluginText(result.title, locale) : action ? pluginText(action.displayName, locale) : t("资源详情");
|
||||
const cardActions = cardAction ? [cardAction] : [];
|
||||
|
||||
return <>
|
||||
<Modal open title={title} busy={busy} onClose={onClose} submitLabel={t("关闭")} onSubmit={onClose}>
|
||||
{result?.description && <span className={styles.actionDescription}>{pluginText(result.description, locale)}</span>}
|
||||
{busy && <span className={styles.empty}>{t("正在加载…")}</span>}
|
||||
{error && <span className={styles.error} role="alert">{error}</span>}
|
||||
{!busy && !error && result && result.cards.length === 0 && <span className={styles.empty}>{t("没有可用的重置卡。")}</span>}
|
||||
{!busy && !error && result && <div className={styles.actionCardList}>
|
||||
{result.cards.map((card) => <Card key={card.id} className={styles.actionCard}>
|
||||
<div className={styles.actionCardMain}>
|
||||
<strong>{pluginText(card.title, locale)}</strong>
|
||||
{card.status && <span>{formatActionStatus(card.status, locale)}</span>}
|
||||
{card.grantedAtMs !== null && card.grantedAtMs !== undefined && <span>{t("发放时间:{time}", { time: formatActionDate(card.grantedAtMs, locale) })}</span>}
|
||||
{card.expiresAtMs !== null && card.expiresAtMs !== undefined && <span>{t("到期时间:{time}", { time: formatActionDate(card.expiresAtMs, locale) })}</span>}
|
||||
{card.fields.map((field) => <span key={field.id}>{pluginText(field.label, locale)}: {field.value}</span>)}
|
||||
</div>
|
||||
{cardActions.length > 0 && <div className={styles.actions}>
|
||||
{cardActions.map((cardActionItem) => <Button
|
||||
key={cardActionItem.id}
|
||||
size="small"
|
||||
disabled={busy || card.status !== "available"}
|
||||
onClick={() => cardActionItem.destructive ? setPendingCard(card) : onCardAction(cardActionItem, card)}
|
||||
>{pluginText(cardActionItem.displayName, locale)}</Button>)}
|
||||
</div>}
|
||||
</Card>)}
|
||||
</div>}
|
||||
</Modal>
|
||||
{pendingCard && cardAction && <ConfirmDialog
|
||||
open
|
||||
title={t("使用重置卡")}
|
||||
busy={busy}
|
||||
confirmLabel={t("确认使用")}
|
||||
onCancel={() => setPendingCard(null)}
|
||||
onConfirm={() => {
|
||||
const card = pendingCard;
|
||||
setPendingCard(null);
|
||||
onCardAction(cardAction, card);
|
||||
}}
|
||||
>
|
||||
<p>{t("使用后会立即消耗这张重置卡,且无法恢复。确定继续吗?")}</p>
|
||||
<strong>{pluginText(pendingCard.title, locale)}</strong>
|
||||
</ConfirmDialog>}
|
||||
</>;
|
||||
}
|
||||
|
||||
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 <span className={styles.cooling} title={state.message ?? undefined}>{t("冷却中")}</span>;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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": "启用",
|
||||
|
||||
@@ -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<PluginOAuthPoll>(`/plugins/oauth/${encodeURIComponent(sessionId)}/poll`, { method: "POST", signal }),
|
||||
importPluginResources: (pluginId: string, resourceType: string, files: PluginImportFile[]) => request<PluginImportResult>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/import`, { method: "POST", body: JSON.stringify(files) }),
|
||||
refreshPluginResource: (pluginId: string, resourceType: string, resourceId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/refresh`, { method: "POST" }),
|
||||
pluginResourceAction: (pluginId: string, resourceType: string, resourceId: string, actionId: string, input: unknown = {}) => request<PluginResourceActionResult>(`/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<void>(`/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<void>(`/plugins/${encodeURIComponent(pluginId)}/providers/${encodeURIComponent(providerId)}/models/enabled`, { method: "PUT", body: JSON.stringify({ modelId, enabled }) }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => <button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>)}
|
||||
{items.map((item) => item.type === "text" ? (
|
||||
<span key={item.id} className={styles.textItem}>
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -357,6 +357,7 @@ async function streamCloudCode(
|
||||
const headers: Record<string, string> = {
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -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<string, string> };
|
||||
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<string> {
|
||||
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<string, string> = {};
|
||||
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<string, unknown>;
|
||||
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"));
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}],
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> | 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<ResourceDraft> {
|
||||
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<string, string> {
|
||||
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<ResourceActionResult> {
|
||||
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<ResourceActionResult> {
|
||||
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[]): {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -87,6 +87,23 @@ pub async fn refresh_resource(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn action(
|
||||
State(service): State<ControlService>,
|
||||
Path((plugin_id, resource_type, resource_id, action_id)): Path<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
)>,
|
||||
Json(input): Json<serde_json::Value>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
Ok(Json(
|
||||
service
|
||||
.plugin_resource_action(&plugin_id, &resource_type, &resource_id, &action_id, input)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_resource(
|
||||
State(service): State<ControlService>,
|
||||
Path((plugin_id, resource_type, resource_id)): Path<(String, String, String)>,
|
||||
|
||||
@@ -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<serde_json::Value> {
|
||||
self.plugins
|
||||
.resource_action(plugin_id, resource_type, resource_id, action_id, input)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn plugin_delete_resource(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
|
||||
@@ -38,10 +38,25 @@ pub struct ResourceDefinition {
|
||||
pub add: Vec<AddMethodDefinition>,
|
||||
#[serde(default)]
|
||||
pub import: Option<ImportDefinition>,
|
||||
#[serde(default)]
|
||||
pub actions: Vec<ResourceActionDefinition>,
|
||||
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<AddMethodDefinition>,
|
||||
pub import: Option<ImportDefinition>,
|
||||
pub actions: Vec<ResourceActionDefinition>,
|
||||
pub can_refresh: bool,
|
||||
pub can_remove: bool,
|
||||
pub resources: Vec<PluginResourceView>,
|
||||
@@ -171,6 +187,61 @@ pub struct ResourcePresentation {
|
||||
pub metrics: Vec<ResourceMetric>,
|
||||
}
|
||||
|
||||
/// 插件资源操作返回的安全详情;patch 只在核心内部应用,不会回传给桌面端。
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ResourceActionResult {
|
||||
pub title: LocalizedText,
|
||||
#[serde(default)]
|
||||
pub description: Option<LocalizedText>,
|
||||
#[serde(default)]
|
||||
pub cards: Vec<ResourceActionCard>,
|
||||
#[serde(default, skip_serializing)]
|
||||
pub patch: Option<super::state::ResourcePatch>,
|
||||
}
|
||||
|
||||
#[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<LocalizedText>,
|
||||
#[serde(default)]
|
||||
pub granted_at_ms: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub expires_at_ms: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub fields: Vec<ResourceActionField>,
|
||||
}
|
||||
|
||||
#[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<LocalizedText>,
|
||||
pub cards: Vec<ResourceActionCard>,
|
||||
}
|
||||
|
||||
impl From<ResourceActionResult> 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 {
|
||||
|
||||
@@ -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<serde_json::Value> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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<ResourceActionResult>;
|
||||
};
|
||||
|
||||
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<ResourcePatch>;
|
||||
/** 可选的上游撤销;宿主随后删除本地记录。 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user