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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
7
electron/gateway/random-id.ts
Normal file
7
electron/gateway/random-id.ts
Normal 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)}`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
28
electron/gateway/runtime-context.ts
Normal file
28
electron/gateway/runtime-context.ts
Normal 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'),
|
||||
},
|
||||
];
|
||||
}
|
||||
233
electron/gateway/skill-install-shortcut.ts
Normal file
233
electron/gateway/skill-install-shortcut.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user