feat(desktop): add plugin resource actions

This commit is contained in:
leokun
2026-09-05 00:12:14 +08:00
parent 8fbbcd5ae9
commit 2cb15b541b
24 changed files with 1110 additions and 254 deletions
@@ -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);
+9 -1
View File
@@ -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,
}],
});
+175 -4
View File
@@ -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[]): {
+4
View File
@@ -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),
+17
View File
@@ -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)>,
+13
View File
@@ -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,
+71
View File
@@ -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 {
+56 -2
View File
@@ -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,
+7
View File
@@ -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,
})),
+40
View File
@@ -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>;
/** 可选的上游撤销;宿主随后删除本地记录。 */
+11
View File
@@ -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);