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
+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);