feat: add tool status management and localization for skill installation

- Updated chat message types to include tool statuses.
- Enhanced localization files for English, Thai, and Chinese to support new tool status messages.
- Modified HomePage and SkillsPage components to handle tool statuses in chat messages.
- Implemented tool status merging and updating logic in the chat store.
- Added handling for tool status events in the gateway event processing.
- Created tests for chat message rendering with tool statuses and skill installation shortcuts.
- Improved gateway event dispatching for tool lifecycle events.
This commit is contained in:
duanshuwen
2026-04-23 20:27:54 +08:00
parent 979fb0a0f6
commit df600272d6
29 changed files with 2041 additions and 384 deletions

View File

@@ -1,7 +1,7 @@
import logManager from '@electron/service/logger';
import { extractBrowserOpenIntent, openUrlInBrowser } from '@electron/service/browser-open-service';
import { appendTranscriptLine } from '@electron/utils/token-usage-writer';
import type { RawMessage } from '@runtime/shared/chat-model';
import type { RawMessage, ToolStatus } from '@runtime/shared/chat-model';
import { sessionStore } from './session-store';
import type { GatewayEvent } from './types';
@@ -22,6 +22,21 @@ async function processBrowserOpen(
broadcast: (event: GatewayEvent) => void,
) {
let assistantText = '';
const toolCallId = `browser.open_url:${runId}`;
const startedAt = Date.now();
let finalToolStatus: ToolStatus | null = null;
broadcast({
type: 'tool:status',
sessionKey,
runId,
toolCallId,
toolName: 'browser.open_url',
status: 'running',
updatedAt: startedAt,
summary: `Opening ${url}`,
input: { url },
});
try {
const result = await openUrlInBrowser(url, { signal });
@@ -29,11 +44,61 @@ async function processBrowserOpen(
return;
}
assistantText = buildBrowserOpenResponseText(result);
finalToolStatus = {
id: toolCallId,
toolCallId,
name: 'browser.open_url',
status: 'completed',
updatedAt: Date.now(),
durationMs: Date.now() - startedAt,
summary: assistantText,
input: { url },
result,
};
broadcast({
type: 'tool:status',
sessionKey,
runId,
toolCallId,
toolName: 'browser.open_url',
status: finalToolStatus.status,
updatedAt: finalToolStatus.updatedAt,
durationMs: finalToolStatus.durationMs,
summary: finalToolStatus.summary,
input: finalToolStatus.input,
result: finalToolStatus.result,
});
} catch (error) {
if (signal.aborted) {
return;
}
assistantText = buildBrowserOpenErrorText(error);
finalToolStatus = {
id: toolCallId,
toolCallId,
name: 'browser.open_url',
status: 'error',
updatedAt: Date.now(),
durationMs: Date.now() - startedAt,
summary: assistantText,
input: { url },
result: {
error: error instanceof Error ? error.message : String(error),
},
};
broadcast({
type: 'tool:status',
sessionKey,
runId,
toolCallId,
toolName: 'browser.open_url',
status: finalToolStatus.status,
updatedAt: finalToolStatus.updatedAt,
durationMs: finalToolStatus.durationMs,
summary: finalToolStatus.summary,
input: finalToolStatus.input,
result: finalToolStatus.result,
});
}
sessionStore.clearActiveRun(sessionKey);
@@ -42,6 +107,7 @@ async function processBrowserOpen(
role: 'assistant',
content: assistantText,
timestamp: Date.now(),
_toolStatuses: finalToolStatus ? [finalToolStatus] : undefined,
};
sessionStore.appendMessage(sessionKey, finalMessage);

View File

@@ -27,6 +27,12 @@ export function dispatchProtocolEvent(
case 'ready':
emitter.emit('gateway:ready', payload);
break;
case GatewayEventType.TOOL_CALL_STARTED:
emitter.emit('tool:status', { status: 'running', payload });
break;
case GatewayEventType.TOOL_CALL_COMPLETED:
emitter.emit('tool:status', { status: 'completed', payload });
break;
default:
emitter.emit('notification', { method: event, params: payload });
}
@@ -44,6 +50,12 @@ export function dispatchJsonRpcNotification(
case GatewayEventType.MESSAGE_RECEIVED:
emitter.emit('chat:message', notification.params as { message: unknown });
break;
case GatewayEventType.TOOL_CALL_STARTED:
emitter.emit('tool:status', { status: 'running', payload: notification.params });
break;
case GatewayEventType.TOOL_CALL_COMPLETED:
emitter.emit('tool:status', { status: 'completed', payload: notification.params });
break;
case GatewayEventType.ERROR: {
const errorData = notification.params as { message?: string };
emitter.emit('error', new Error(errorData.message || 'Gateway error'));

View File

@@ -1,4 +1,3 @@
import { randomUUID } from 'node:crypto';
import { createProvider } from '@electron/providers';
import type { BaseProvider } from '@electron/providers/BaseProvider';
import { providerApiService } from '@electron/service/provider-api-service';
@@ -9,21 +8,57 @@ import { sessionStore } from '../session-store';
import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types';
import { appendTranscriptLine } from '@electron/utils/token-usage-writer';
import { maybeHandleBrowserOpenMessage } from '../browser-shortcut';
import { maybeHandleSkillInstallMessage } from '../skill-install-shortcut';
import { buildRuntimeContextMessages } from '../runtime-context';
import { createRandomId } from '../random-id';
export interface GatewayChatMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
}
function flattenMessageContent(content: RawMessage['content']): string {
if (typeof content === 'string') {
return content;
}
return content
.map((block) => {
if (!block || typeof block !== 'object') {
return '';
}
if (block.type === 'text' && typeof block.text === 'string') {
return block.text;
}
if ((block.type === 'tool_result' || block.type === 'toolResult') && typeof block.content === 'string') {
return block.content;
}
if ((block.type === 'tool_result' || block.type === 'toolResult') && Array.isArray(block.content)) {
return flattenMessageContent(block.content as RawMessage['content']);
}
return '';
})
.filter(Boolean)
.join('\n');
}
function buildChatMessages(sessionMessages: RawMessage[]): GatewayChatMessage[] {
return sessionMessages
.map((msg): GatewayChatMessage | null => {
if (!msg.role || !msg.content) return null;
const role = msg.role;
const content = flattenMessageContent(msg.content).trim();
if (!content) {
return null;
}
if (role === 'user' || role === 'assistant' || role === 'system') {
return {
role,
content: typeof msg.content === 'string' ? msg.content : '',
content,
};
}
// Skip toolresult and unsupported roles for now
@@ -114,7 +149,7 @@ export function handleChatSend(
): GatewayRpcReturns['chat.send'] {
const sessionKey = normalizeAgentSessionKey(params.sessionKey);
const { message, options } = params;
const runId = randomUUID();
const runId = createRandomId();
// 1. Append user message
const userMessage: RawMessage = {
@@ -136,6 +171,10 @@ export function handleChatSend(
return { runId };
}
if (maybeHandleSkillInstallMessage(sessionKey, runId, userMessage, broadcast)) {
return { runId };
}
// 2. Resolve provider account
const accountId = options?.providerAccountId || providerApiService.getDefault().accountId;
if (!accountId) {
@@ -154,7 +193,10 @@ export function handleChatSend(
// 3. Build messages array from session history
const session = sessionStore.getOrCreate(sessionKey);
const messages = buildChatMessages(session.messages);
const messages = [
...buildRuntimeContextMessages(sessionKey),
...buildChatMessages(session.messages),
];
// 4. Start streaming
const abortController = new AbortController();

View File

@@ -1,5 +1,22 @@
import { ClawHubService } from '@electron/gateway/clawhub';
import {
SkillInstallService,
SkillInstallServiceError,
type SkillInstallRequest,
} from '@electron/service/skill-install-service';
import { getAllSkillConfigs, updateSkillConfig } from '@electron/utils/skill-config';
import type { GatewayRpcReturns } from '../types';
import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types';
type GatewayBroadcast = (event: GatewayEvent) => void;
function broadcastSkillsRuntimeChanged(broadcast?: GatewayBroadcast, reason = 'skills:changed'): void {
broadcast?.({
type: 'runtime:changed',
topics: ['skills'],
reason,
syncedAt: new Date().toISOString(),
});
}
export async function handleSkillsStatus(): Promise<GatewayRpcReturns['skills.status']> {
const configs = await getAllSkillConfigs();
@@ -43,3 +60,28 @@ export async function handleSkillsUpdate(
return { success: true };
}
export async function handleSkillsInstall(
params: GatewayRpcParams['skills.install'],
broadcast?: GatewayBroadcast,
): Promise<GatewayRpcReturns['skills.install']> {
const request = params as SkillInstallRequest;
const installService = new SkillInstallService({
clawHubService: new ClawHubService(),
});
try {
const result = await installService.install(request);
broadcastSkillsRuntimeChanged(
broadcast,
`skills:install:${result.source}:${result.slug}`,
);
return result;
} catch (error) {
if (error instanceof SkillInstallServiceError) {
throw new Error(error.message);
}
throw error instanceof Error ? error : new Error(String(error));
}
}

View File

@@ -1,4 +1,3 @@
import { randomUUID } from 'node:crypto';
import { EventEmitter } from 'node:events';
import { createServer } from 'node:net';
import { join } from 'node:path';
@@ -63,6 +62,7 @@ import {
import { GatewayStateController, type GatewayRuntimeStatus } from './state';
import { connectGatewaySocket, waitForGatewayReady } from './ws-client';
import { dispatchGatewayRpcMethod } from './rpc-dispatch';
import { createRandomId } from './random-id';
type RuntimeChangeBroadcast = {
topics: RuntimeRefreshTopic[];
@@ -95,6 +95,11 @@ type GatewayNotificationEvent =
params?: unknown;
};
type GatewayToolStatusNotification = {
status: 'running' | 'completed' | 'error';
payload?: unknown;
};
export interface GatewayManagerEvents {
status: (status: GatewayRuntimeStatus) => void;
message: (message: unknown) => void;
@@ -104,6 +109,7 @@ export interface GatewayManagerEvents {
'channel:status': (data: { channelId: string; status: string }) => void;
'chat:message': (data: { message: unknown }) => void;
'gateway:ready': (payload: unknown) => void;
'tool:status': (payload: GatewayToolStatusNotification) => void;
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -243,6 +249,78 @@ function extractTextFromGatewayPayload(payload: Record<string, unknown>): string
return '';
}
function getRecordStringField(record: Record<string, unknown>, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === 'string' && value.trim()) {
return value;
}
}
return undefined;
}
function getRecordNumberField(record: Record<string, unknown>, ...keys: string[]): number | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
}
return undefined;
}
function normalizeGatewayToolStatus(value: unknown): 'running' | 'completed' | 'error' {
if (typeof value !== 'string') {
return 'completed';
}
switch (value.trim().toLowerCase()) {
case 'running':
case 'completed':
case 'error':
return value.trim().toLowerCase() as 'running' | 'completed' | 'error';
case 'failed':
return 'error';
default:
return 'completed';
}
}
function normalizeToolStatusEvent(value: GatewayToolStatusNotification): GatewayEvent | null {
const payload = isRecord(value.payload) ? value.payload : {};
const sessionKey = getRecordStringField(payload, 'sessionKey', 'session_key');
const runId = getRecordStringField(payload, 'runId', 'run_id');
const toolName = getRecordStringField(payload, 'toolName', 'tool_name', 'name');
if (!sessionKey || !runId || !toolName) {
return null;
}
const toolCallId = getRecordStringField(payload, 'toolCallId', 'tool_call_id', 'id');
const summary = getRecordStringField(payload, 'summary', 'message');
const durationMs = getRecordNumberField(payload, 'durationMs', 'duration_ms');
const errorMessage = getRecordStringField(payload, 'error');
const status = errorMessage
? 'error'
: normalizeGatewayToolStatus(payload.status ?? value.status);
return {
type: 'tool:status',
sessionKey: normalizeAgentSessionKey(sessionKey),
runId,
toolCallId,
toolName,
status,
updatedAt: normalizeTimestamp(payload.timestamp) ?? Date.now(),
summary: errorMessage ?? summary,
durationMs,
input: payload.input ?? payload.arguments ?? payload.args,
result: payload.result,
};
}
function buildGatewayRpcError(error: unknown, fallback: string): Error {
if (typeof error === 'string' && error.trim()) {
return new Error(error);
@@ -292,7 +370,7 @@ export class GatewayManager extends EventEmitter {
private readonly processOwner = new OpenClawProcessOwner();
private readonly pendingRequests = new Map<string, PendingGatewayRequest>();
private readonly deltaSnapshots = new Map<string, string>();
private gatewayToken = randomUUID();
private gatewayToken = createRandomId();
private socket: WebSocket | null = null;
private child: GatewayProcessHandle | null = null;
private port: number | null = null;
@@ -343,6 +421,7 @@ export class GatewayManager extends EventEmitter {
override on(eventName: 'channel:status', listener: GatewayManagerEvents['channel:status']): this;
override on(eventName: 'chat:message', listener: GatewayManagerEvents['chat:message']): this;
override on(eventName: 'gateway:ready', listener: GatewayManagerEvents['gateway:ready']): this;
override on(eventName: 'tool:status', listener: GatewayManagerEvents['tool:status']): this;
override on(eventName: string | symbol, listener: (...args: any[]) => void): this;
override on(eventName: string | symbol, listener: (...args: any[]) => void): this {
return super.on(eventName, listener);
@@ -356,6 +435,7 @@ export class GatewayManager extends EventEmitter {
override once(eventName: 'channel:status', listener: GatewayManagerEvents['channel:status']): this;
override once(eventName: 'chat:message', listener: GatewayManagerEvents['chat:message']): this;
override once(eventName: 'gateway:ready', listener: GatewayManagerEvents['gateway:ready']): this;
override once(eventName: 'tool:status', listener: GatewayManagerEvents['tool:status']): this;
override once(eventName: string | symbol, listener: (...args: any[]) => void): this;
override once(eventName: string | symbol, listener: (...args: any[]) => void): this {
return super.once(eventName, listener);
@@ -369,6 +449,7 @@ export class GatewayManager extends EventEmitter {
override off(eventName: 'channel:status', listener: GatewayManagerEvents['channel:status']): this;
override off(eventName: 'chat:message', listener: GatewayManagerEvents['chat:message']): this;
override off(eventName: 'gateway:ready', listener: GatewayManagerEvents['gateway:ready']): this;
override off(eventName: 'tool:status', listener: GatewayManagerEvents['tool:status']): this;
override off(eventName: string | symbol, listener: (...args: any[]) => void): this;
override off(eventName: string | symbol, listener: (...args: any[]) => void): this {
return super.off(eventName, listener);
@@ -414,6 +495,13 @@ export class GatewayManager extends EventEmitter {
}
});
this.on('tool:status', (payload) => {
const event = normalizeToolStatusEvent(payload);
if (event) {
this.broadcast(event);
}
});
this.on('error', (error) => {
logManager.debug('GatewayManager emitted error event', error);
});

View File

@@ -1,4 +1,4 @@
import { randomUUID } from 'node:crypto';
import { createRandomId } from './random-id';
export interface JsonRpcRequest {
jsonrpc: '2.0';
@@ -67,7 +67,7 @@ export function createRequest(
): JsonRpcRequest {
return {
jsonrpc: '2.0',
id: id ?? randomUUID(),
id: id ?? createRandomId(),
method,
params,
};

View File

@@ -0,0 +1,7 @@
export function createRandomId(): string {
if (typeof globalThis.crypto?.randomUUID === 'function') {
return globalThis.crypto.randomUUID();
}
return `id-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}

View File

@@ -74,6 +74,14 @@ export function dispatchGatewayRpcMethod(
handled: true,
result: skillHandlers.handleSkillsUpdate(params),
};
case 'skills.install':
return {
handled: true,
result: skillHandlers.handleSkillsInstall(
params as GatewayRpcParams['skills.install'],
broadcast,
),
};
default:
return { handled: false };
}

View File

@@ -0,0 +1,28 @@
import type { GatewayChatMessage } from '@electron/providers/BaseProvider';
const AGENT_RUNTIME_CONTEXT = [
'You are zn-ai, a desktop AI assistant running through a local OpenClaw-style gateway.',
'Treat host tools as explicit capabilities with real side effects.',
'Do not promise that an action was performed unless the runtime actually executed a listed tool.',
].join(' ');
const TOOL_RUNTIME_CONTEXT = [
'Available host tools in this build:',
'- browser.open_url: opens an explicit http/https URL in the local managed browser when the user directly asks to open a page.',
'- skills.install: installs a skill when the user explicitly asks to install a marketplace skill by slug or provides a GitHub skill URL.',
'Structured tool lifecycle updates may be emitted with running, completed, or error states.',
'Only claim a skill was installed after the runtime reports the tool completed successfully.',
].join('\n');
export function buildRuntimeContextMessages(sessionKey: string): GatewayChatMessage[] {
return [
{
role: 'system',
content: [
AGENT_RUNTIME_CONTEXT,
TOOL_RUNTIME_CONTEXT,
`Current session key: ${sessionKey}`,
].join('\n\n'),
},
];
}

View File

@@ -0,0 +1,233 @@
import logManager from '@electron/service/logger';
import type { SkillInstallRequest, SkillInstallResult } from '@electron/service/skill-install-service';
import { parseGitHubSkillUrl } from '@electron/service/skill-install-service';
import { appendTranscriptLine } from '@electron/utils/token-usage-writer';
import type { RawMessage, ToolStatus } from '@runtime/shared/chat-model';
import { handleSkillsInstall } from './handlers/skills';
import { sessionStore } from './session-store';
import type { GatewayEvent } from './types';
const GITHUB_URL_PATTERN = /https:\/\/github\.com\/[^\s)"']+/giu;
const INSTALL_VERB_PATTERN = /(||||||\binstall\b)/iu;
const MARKETPLACE_INSTALL_PATTERNS = [
/^\s*(?:)?(?:)?(?:||)\s+(?:\s*)?(?:skill|)\s*[:]?\s*["']?([a-z0-9][a-z0-9._-]*)["']?\s*$/iu,
/^\s*(?:)?(?:)?(?:||)\s+["']?([a-z0-9][a-z0-9._-]*)["']?\s*(?:)?\s*(?:skill|)\s*$/iu,
/^\s*install\s+(?:the\s+)?(?:skill\s+)?["']?([a-z0-9][a-z0-9._-]*)["']?\s*$/iu,
] as const;
export interface SkillInstallIntent {
request: SkillInstallRequest;
description: string;
}
function stripTrailingPunctuation(value: string): string {
return value.trim().replace(/[)\]}>.,!?;:'"]+$/u, '');
}
function extractGitHubSkillUrl(text: string): string | null {
const matches = text.match(GITHUB_URL_PATTERN) ?? [];
for (const match of matches) {
const candidate = stripTrailingPunctuation(match);
try {
const parsed = parseGitHubSkillUrl(candidate);
return parsed.originalUrl;
} catch {
continue;
}
}
return null;
}
function describeInstallResult(result: SkillInstallResult): string {
return `已安装并启用 skill ${result.slug}${result.source})。位置:${result.baseDir}`;
}
function describeInstallFailure(error: unknown): string {
return `安装失败:${error instanceof Error ? error.message : String(error)}`;
}
export function extractSkillInstallIntent(text: string): SkillInstallIntent | null {
const trimmed = String(text || '').trim();
if (!trimmed || !INSTALL_VERB_PATTERN.test(trimmed)) {
return null;
}
const githubUrl = extractGitHubSkillUrl(trimmed);
if (githubUrl) {
const parsed = parseGitHubSkillUrl(githubUrl);
return {
request: {
kind: 'github-url',
url: parsed.originalUrl,
},
description: parsed.defaultSlug,
};
}
for (const pattern of MARKETPLACE_INSTALL_PATTERNS) {
const match = pattern.exec(trimmed);
if (!match?.[1]) {
continue;
}
return {
request: {
kind: 'marketplace',
slug: match[1],
},
description: match[1],
};
}
return null;
}
async function processSkillInstall(
sessionKey: string,
runId: string,
intent: SkillInstallIntent,
signal: AbortSignal,
broadcast: (event: GatewayEvent) => void,
) {
const toolCallId = `skills.install:${runId}`;
const startedAt = Date.now();
let finalToolStatus: ToolStatus | null = null;
broadcast({
type: 'tool:status',
sessionKey,
runId,
toolCallId,
toolName: 'skills.install',
status: 'running',
updatedAt: startedAt,
summary: `Installing ${intent.description}`,
input: intent.request,
});
let assistantText = '';
try {
const result = await handleSkillsInstall(intent.request, broadcast);
if (signal.aborted) {
return;
}
assistantText = describeInstallResult(result);
finalToolStatus = {
id: toolCallId,
toolCallId,
name: 'skills.install',
status: 'completed',
updatedAt: Date.now(),
durationMs: Date.now() - startedAt,
summary: assistantText,
input: intent.request,
result,
};
broadcast({
type: 'tool:status',
sessionKey,
runId,
toolCallId,
toolName: 'skills.install',
status: finalToolStatus.status,
updatedAt: finalToolStatus.updatedAt,
durationMs: finalToolStatus.durationMs,
summary: finalToolStatus.summary,
input: finalToolStatus.input,
result: finalToolStatus.result,
});
} catch (error) {
if (signal.aborted) {
return;
}
assistantText = describeInstallFailure(error);
finalToolStatus = {
id: toolCallId,
toolCallId,
name: 'skills.install',
status: 'error',
updatedAt: Date.now(),
durationMs: Date.now() - startedAt,
summary: assistantText,
input: intent.request,
result: {
error: error instanceof Error ? error.message : String(error),
},
};
broadcast({
type: 'tool:status',
sessionKey,
runId,
toolCallId,
toolName: 'skills.install',
status: finalToolStatus.status,
updatedAt: finalToolStatus.updatedAt,
durationMs: finalToolStatus.durationMs,
summary: finalToolStatus.summary,
input: finalToolStatus.input,
result: finalToolStatus.result,
});
}
sessionStore.clearActiveRun(sessionKey);
const finalMessage: RawMessage = {
role: 'assistant',
content: assistantText,
timestamp: Date.now(),
_toolStatuses: finalToolStatus ? [finalToolStatus] : undefined,
};
sessionStore.appendMessage(sessionKey, finalMessage);
appendTranscriptLine(sessionKey, {
type: 'message',
timestamp: new Date().toISOString(),
message: {
role: 'assistant',
content: assistantText,
tool: 'skills.install',
},
});
broadcast({
type: 'chat:final',
sessionKey,
runId,
message: finalMessage,
});
}
export function maybeHandleSkillInstallMessage(
sessionKey: string,
runId: string,
message: RawMessage,
broadcast: (event: GatewayEvent) => void,
): boolean {
const intent = typeof message.content === 'string'
? extractSkillInstallIntent(message.content)
: null;
if (!intent) {
return false;
}
const abortController = new AbortController();
sessionStore.setActiveRun(sessionKey, runId, abortController);
processSkillInstall(sessionKey, runId, intent, abortController.signal, broadcast).catch((error) => {
logManager.error('Unexpected error in processSkillInstall:', error);
sessionStore.clearActiveRun(sessionKey);
broadcast({
type: 'chat:error',
sessionKey,
runId,
error: error instanceof Error ? error.message : String(error),
});
});
return true;
}

View File

@@ -5,7 +5,10 @@ export type RuntimeRefreshTopic =
| 'models'
| 'agents'
| 'channels'
| 'channel-targets';
| 'channel-targets'
| 'skills';
export type GatewayToolStatus = 'running' | 'completed' | 'error';
/// Gateway 向 Renderer 推送的事件类型
export type GatewayEvent =
@@ -32,6 +35,19 @@ export type GatewayEvent =
sessionKey: string;
runId: string;
}
| {
type: 'tool:status';
sessionKey: string;
runId: string;
toolName: string;
status: GatewayToolStatus;
updatedAt: number;
toolCallId?: string;
summary?: string;
durationMs?: number;
input?: unknown;
result?: unknown;
}
| {
type: 'gateway:status';
status: 'connected' | 'disconnected' | 'reconnecting';
@@ -73,6 +89,16 @@ export interface GatewayRpcParams {
skillKey: string;
enabled?: boolean;
};
'skills.install': {
kind: 'marketplace';
slug: string;
version?: string;
force?: boolean;
} | {
kind: 'github-url';
url: string;
force?: boolean;
};
}
/// Gateway RPC 方法返回值映射
@@ -103,4 +129,11 @@ export interface GatewayRpcReturns {
}>;
};
'skills.update': { success: boolean };
'skills.install': {
success: true;
slug: string;
baseDir: string;
source: 'marketplace' | 'github-url';
enabled: true;
};
}