Commit 0ab5ca4c authored by AI-甘富林's avatar AI-甘富林

skill链路打通

parent 9ae4391b
...@@ -7,13 +7,16 @@ import type { RuntimeModePreference, SystemSummary } from "@qjclaw/shared-types" ...@@ -7,13 +7,16 @@ import type { RuntimeModePreference, SystemSummary } from "@qjclaw/shared-types"
import { createMainWindow } from "./create-window.js"; import { createMainWindow } from "./create-window.js";
import { registerDesktopIpc } from "./ipc.js"; import { registerDesktopIpc } from "./ipc.js";
import { AppConfigService } from "./services/app-config.js"; import { AppConfigService } from "./services/app-config.js";
import { AuthClient, CreditClient, ModelConfigClient, OpenClawConfigClient, ProfileClient, SkillClient } from "./services/cloud-api.js"; import { AuthClient, CreditClient, ModelConfigClient, OpenClawConfigClient, ProfileClient } from "./services/cloud-api.js";
import { DeviceIdentityService } from "./services/device-identity.js"; import { DeviceIdentityService } from "./services/device-identity.js";
import { DiagnosticsService } from "./services/diagnostics.js"; import { DiagnosticsService } from "./services/diagnostics.js";
import { loadLocalOpenClawGatewayConfig, resolveEffectiveGatewayUrl } from "./services/openclaw-local-config.js"; import { loadLocalOpenClawGatewayConfig, resolveEffectiveGatewayUrl } from "./services/openclaw-local-config.js";
import { SecretManager } from "./services/secrets.js"; import { SecretManager } from "./services/secrets.js";
import { startSmokeCloudApiServer } from "./services/smoke-cloud-api.js"; import { startSmokeCloudApiServer } from "./services/smoke-cloud-api.js";
import { RuntimeCloudSupervisor } from "./services/runtime-cloud-supervisor.js"; import { RuntimeCloudSupervisor } from "./services/runtime-cloud-supervisor.js";
import { RuntimeSkillBridgeService } from "./services/runtime-skill-bridge.js";
import { SkillClient } from "./services/skill-client.js";
import { SkillStoreService } from "./services/skill-store.js";
interface RendererSmokeState { interface RendererSmokeState {
usingMockApi: boolean; usingMockApi: boolean;
...@@ -262,7 +265,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -262,7 +265,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
result.initialState = initialState; result.initialState = initialState;
await trace("runSmokeTest:initial-state-ready"); await trace("runSmokeTest:initial-state-ready");
const prompt = `qjc smoke stream ${new Date().toISOString()}`; const prompt = process.env.QJCLAW_SMOKE_PROMPT?.trim() || `qjc smoke stream ${new Date().toISOString()}`;
const preferredSkillId = process.env.QJCLAW_SMOKE_SKILL_ID?.trim();
await trace("runSmokeTest:before-send-script"); await trace("runSmokeTest:before-send-script");
const sendResult = await window.webContents.executeJavaScript(`(async () => { const sendResult = await window.webContents.executeJavaScript(`(async () => {
const api = window.qjcDesktop; const api = window.qjcDesktop;
...@@ -277,6 +281,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -277,6 +281,7 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const smokeBaseUrl = ${JSON.stringify(process.env.QJCLAW_SMOKE_CLOUD_API_BASE_URL ?? "")}; const smokeBaseUrl = ${JSON.stringify(process.env.QJCLAW_SMOKE_CLOUD_API_BASE_URL ?? "")};
const smokeToken = ${JSON.stringify(process.env.QJCLAW_SMOKE_AUTH_TOKEN ?? "")}; const smokeToken = ${JSON.stringify(process.env.QJCLAW_SMOKE_AUTH_TOKEN ?? "")};
const smokeRuntimeApiKey = ${JSON.stringify(process.env.QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY ?? "smoke-runtime-api-key")}; const smokeRuntimeApiKey = ${JSON.stringify(process.env.QJCLAW_SMOKE_RUNTIME_CLOUD_API_KEY ?? "smoke-runtime-api-key")};
const preferredSkillId = ${JSON.stringify(process.env.QJCLAW_SMOKE_SKILL_ID?.trim() ?? "")};
if (smokeBaseUrl) { if (smokeBaseUrl) {
const current = await api.config.load(); const current = await api.config.load();
await api.config.save({ await api.config.save({
...@@ -332,7 +337,12 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise< ...@@ -332,7 +337,12 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const credits = session.state === "authenticated" ? await api.credits.getSummary() : null; const credits = session.state === "authenticated" ? await api.credits.getSummary() : null;
const skills = session.state === "authenticated" ? await api.skills.list() : []; const skills = session.state === "authenticated" ? await api.skills.list() : [];
const workspace = await api.workspace.getSummary(); const workspace = await api.workspace.getSummary();
const selectedSkillId = workspace.skills[0]?.id ?? skills[0]?.id; const selectedSkillId = preferredSkillId
? (workspace.skills.find((skill) => skill.id === preferredSkillId)?.id
?? skills.find((skill) => skill.id === preferredSkillId)?.id
?? workspace.skills[0]?.id
?? skills[0]?.id)
: (workspace.skills[0]?.id ?? skills[0]?.id);
const sessions = await api.chat.listSessions(); const sessions = await api.chat.listSessions();
const sessionId = state?.activeSessionId || sessions[0]?.id || "desktop-main"; const sessionId = state?.activeSessionId || sessions[0]?.id || "desktop-main";
const system = await api.system.getSummary(); const system = await api.system.getSummary();
...@@ -473,6 +483,10 @@ async function bootstrap(): Promise<void> { ...@@ -473,6 +483,10 @@ async function bootstrap(): Promise<void> {
await deviceIdentityService.load(); await deviceIdentityService.load();
const localOpenClawConfig = await loadLocalOpenClawGatewayConfig(); const localOpenClawConfig = await loadLocalOpenClawGatewayConfig();
const runtimeCloudClient = new OpenClawConfigClient(configService, secretManager); const runtimeCloudClient = new OpenClawConfigClient(configService, secretManager);
const skillStore = new SkillStoreService(systemSummary.userDataPath);
runtimeCloudClient.onPayloadUpdated(async ({ config: payloadConfig, skills }) => {
await skillStore.reconcile(skills, payloadConfig.configVersion);
});
const runtimeManager = new RuntimeManager({ const runtimeManager = new RuntimeManager({
vendorRuntimeDir: resolveVendorRuntimeDir(systemSummary), vendorRuntimeDir: resolveVendorRuntimeDir(systemSummary),
...@@ -493,10 +507,13 @@ async function bootstrap(): Promise<void> { ...@@ -493,10 +507,13 @@ async function bootstrap(): Promise<void> {
} }
}); });
const runtimeSkillBridge = new RuntimeSkillBridgeService(skillStore, runtimeManager);
await runtimeSkillBridge.clearManagedSkills().catch(() => undefined);
const authClient = new AuthClient(configService, secretManager); const authClient = new AuthClient(configService, secretManager);
const profileClient = new ProfileClient(configService, secretManager); const profileClient = new ProfileClient(configService, secretManager);
const creditClient = new CreditClient(configService, secretManager); const creditClient = new CreditClient(configService, secretManager);
const skillClient = new SkillClient(runtimeCloudClient); const skillClient = new SkillClient(skillStore);
const modelConfigClient = new ModelConfigClient(configService, secretManager); const modelConfigClient = new ModelConfigClient(configService, secretManager);
const runtimeCloudSupervisor = new RuntimeCloudSupervisor({ const runtimeCloudSupervisor = new RuntimeCloudSupervisor({
appVersion: app.getVersion(), appVersion: app.getVersion(),
...@@ -530,9 +547,11 @@ async function bootstrap(): Promise<void> { ...@@ -530,9 +547,11 @@ async function bootstrap(): Promise<void> {
profileClient, profileClient,
creditClient, creditClient,
skillClient, skillClient,
skillStore,
modelConfigClient, modelConfigClient,
runtimeCloudClient, runtimeCloudClient,
runtimeCloudSupervisor, runtimeCloudSupervisor,
runtimeSkillBridge,
systemSummary, systemSummary,
localOpenClawConfig localOpenClawConfig
}); });
...@@ -548,6 +567,7 @@ async function bootstrap(): Promise<void> { ...@@ -548,6 +567,7 @@ async function bootstrap(): Promise<void> {
void (async () => { void (async () => {
await runtimeCloudSupervisor.stop("app-before-quit"); await runtimeCloudSupervisor.stop("app-before-quit");
await runtimeManager.stop(); await runtimeManager.stop();
await runtimeSkillBridge.clearManagedSkills().catch(() => undefined);
if (stopSmokeCloudApiServer) { if (stopSmokeCloudApiServer) {
await stopSmokeCloudApiServer(); await stopSmokeCloudApiServer();
stopSmokeCloudApiServer = undefined; stopSmokeCloudApiServer = undefined;
......
...@@ -17,11 +17,14 @@ import { ...@@ -17,11 +17,14 @@ import {
import type { GatewayClient } from "@qjclaw/gateway-client"; import type { GatewayClient } from "@qjclaw/gateway-client";
import type { RuntimeManager } from "@qjclaw/runtime-manager"; import type { RuntimeManager } from "@qjclaw/runtime-manager";
import type { AppConfigService } from "./services/app-config.js"; import type { AppConfigService } from "./services/app-config.js";
import type { AuthClient, CreditClient, ModelConfigClient, OpenClawConfigClient, ProfileClient, SkillClient } from "./services/cloud-api.js"; import type { AuthClient, CreditClient, ModelConfigClient, OpenClawConfigClient, ProfileClient } from "./services/cloud-api.js";
import type { DiagnosticsService } from "./services/diagnostics.js"; import type { DiagnosticsService } from "./services/diagnostics.js";
import type { SkillClient } from "./services/skill-client.js";
import type { SkillStoreService } from "./services/skill-store.js";
import { resolveEffectiveGatewayUrl, type LocalOpenClawGatewayConfig } from "./services/openclaw-local-config.js"; import { resolveEffectiveGatewayUrl, type LocalOpenClawGatewayConfig } from "./services/openclaw-local-config.js";
import type { SecretManager } from "./services/secrets.js"; import type { SecretManager } from "./services/secrets.js";
import type { RuntimeCloudSupervisor } from "./services/runtime-cloud-supervisor.js"; import type { RuntimeCloudSupervisor } from "./services/runtime-cloud-supervisor.js";
import type { RuntimeSkillBridgeService } from "./services/runtime-skill-bridge.js";
interface MainServices { interface MainServices {
configService: AppConfigService; configService: AppConfigService;
...@@ -33,9 +36,11 @@ interface MainServices { ...@@ -33,9 +36,11 @@ interface MainServices {
profileClient: ProfileClient; profileClient: ProfileClient;
creditClient: CreditClient; creditClient: CreditClient;
skillClient: SkillClient; skillClient: SkillClient;
skillStore: SkillStoreService;
modelConfigClient: ModelConfigClient; modelConfigClient: ModelConfigClient;
runtimeCloudClient: OpenClawConfigClient; runtimeCloudClient: OpenClawConfigClient;
runtimeCloudSupervisor: RuntimeCloudSupervisor; runtimeCloudSupervisor: RuntimeCloudSupervisor;
runtimeSkillBridge: RuntimeSkillBridgeService;
appVersion: string; appVersion: string;
systemSummary: SystemSummary; systemSummary: SystemSummary;
localOpenClawConfig?: LocalOpenClawGatewayConfig | null; localOpenClawConfig?: LocalOpenClawGatewayConfig | null;
...@@ -190,9 +195,11 @@ export function registerDesktopIpc(services: MainServices): DesktopApi { ...@@ -190,9 +195,11 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
profileClient, profileClient,
secretManager, secretManager,
skillClient, skillClient,
skillStore,
modelConfigClient, modelConfigClient,
runtimeCloudClient, runtimeCloudClient,
runtimeCloudSupervisor, runtimeCloudSupervisor,
runtimeSkillBridge,
systemSummary, systemSummary,
localOpenClawConfig localOpenClawConfig
} = services; } = services;
...@@ -258,7 +265,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi { ...@@ -258,7 +265,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
? await gatewayClient.status().catch(() => null) ? await gatewayClient.status().catch(() => null)
: null; : null;
const chatSummary = buildChatSummary(runtimeStatus, runtimeCloudStatus, gatewayStatus); const chatSummary = buildChatSummary(runtimeStatus, runtimeCloudStatus, gatewayStatus);
const skills = await runtimeCloudClient.getWorkspaceSkills(); const skills = await skillStore.listWorkspaceSkills();
return { return {
apiKeyConfigured: runtimeCloudStatus.apiKeyConfigured, apiKeyConfigured: runtimeCloudStatus.apiKeyConfigured,
...@@ -341,7 +348,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi { ...@@ -341,7 +348,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
const config = await getEffectiveConfig(); const config = await getEffectiveConfig();
const [runtimeCloudStatus, skills] = await Promise.all([ const [runtimeCloudStatus, skills] = await Promise.all([
runtimeCloudClient.getStatus(), runtimeCloudClient.getStatus(),
runtimeCloudClient.getWorkspaceSkills() skillStore.listWorkspaceSkills()
]); ]);
const selectedSkill = skillId ? skills.find((skill) => skill.id === skillId) : undefined; const selectedSkill = skillId ? skills.find((skill) => skill.id === skillId) : undefined;
const configuredModelId = runtimeCloudStatus.config?.modelId; const configuredModelId = runtimeCloudStatus.config?.modelId;
...@@ -378,6 +385,21 @@ export function registerDesktopIpc(services: MainServices): DesktopApi { ...@@ -378,6 +385,21 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
sender.send(IPC_CHANNELS.chatStreamEvent, payload); sender.send(IPC_CHANNELS.chatStreamEvent, payload);
}; };
const prepareGatewayPrompt = async (prompt: string, skillId?: string): Promise<string> => {
if (!skillId) {
await runtimeSkillBridge.clearManagedSkills();
return prompt;
}
const runtimeStatus = await runtimeManager.status();
if (runtimeStatus.activeMode !== "bundled-runtime") {
throw new Error("Selected skills currently require bundled runtime. Switch from external gateway mode and try again.");
}
const prepared = await runtimeSkillBridge.preparePrompt(prompt, skillId);
return prepared.prompt;
};
ipcMain.handle(IPC_CHANNELS.workspaceGetSummary, async () => buildWorkspaceSummary()); ipcMain.handle(IPC_CHANNELS.workspaceGetSummary, async () => buildWorkspaceSummary());
ipcMain.handle(IPC_CHANNELS.gatewayStatus, async () => gatewayClient.status()); ipcMain.handle(IPC_CHANNELS.gatewayStatus, async () => gatewayClient.status());
ipcMain.handle(IPC_CHANNELS.gatewayConnect, async () => gatewayClient.connect()); ipcMain.handle(IPC_CHANNELS.gatewayConnect, async () => gatewayClient.connect());
...@@ -462,9 +484,10 @@ export function registerDesktopIpc(services: MainServices): DesktopApi { ...@@ -462,9 +484,10 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
ipcMain.handle(IPC_CHANNELS.chatListMessages, async (_event, sessionId: string) => gatewayClient.listMessages(sessionId)); ipcMain.handle(IPC_CHANNELS.chatListMessages, async (_event, sessionId: string) => gatewayClient.listMessages(sessionId));
ipcMain.handle(IPC_CHANNELS.chatSendPrompt, async (_event, sessionId: string, prompt: string, skillId?: string) => { ipcMain.handle(IPC_CHANNELS.chatSendPrompt, async (_event, sessionId: string, prompt: string, skillId?: string) => {
const executionPolicy = await resolveExecutionPolicy(skillId); const executionPolicy = await resolveExecutionPolicy(skillId);
const gatewayPrompt = await prepareGatewayPrompt(prompt, skillId);
runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, skillId); runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, skillId);
try { try {
const result = await gatewayClient.sendPrompt(sessionId, prompt); const result = await gatewayClient.sendPrompt(sessionId, gatewayPrompt);
runtimeCloudSupervisor.noteMessageSent(result.sessionId, result.reply.content, executionPolicy.modelId, skillId); runtimeCloudSupervisor.noteMessageSent(result.sessionId, result.reply.content, executionPolicy.modelId, skillId);
return { ...result, executionPolicy }; return { ...result, executionPolicy };
} catch (error) { } catch (error) {
...@@ -478,6 +501,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi { ...@@ -478,6 +501,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
}); });
ipcMain.handle(IPC_CHANNELS.chatStreamPrompt, async (event, sessionId: string, prompt: string, skillId?: string) => { ipcMain.handle(IPC_CHANNELS.chatStreamPrompt, async (event, sessionId: string, prompt: string, skillId?: string) => {
const executionPolicy = await resolveExecutionPolicy(skillId); const executionPolicy = await resolveExecutionPolicy(skillId);
const gatewayPrompt = await prepareGatewayPrompt(prompt, skillId);
const requestId = randomUUID(); const requestId = randomUUID();
let settled = false; let settled = false;
let ready = false; let ready = false;
...@@ -497,7 +521,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi { ...@@ -497,7 +521,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, skillId); runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, skillId);
try { try {
const stream = await gatewayClient.streamPrompt(sessionId, prompt, { const stream = await gatewayClient.streamPrompt(sessionId, gatewayPrompt, {
onStarted: ({ sessionId: nextSessionId, runId }) => { onStarted: ({ sessionId: nextSessionId, runId }) => {
queueOrSend({ queueOrSend({
type: "started", type: "started",
...@@ -675,9 +699,10 @@ export function registerDesktopIpc(services: MainServices): DesktopApi { ...@@ -675,9 +699,10 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
listMessages: (sessionId: string) => gatewayClient.listMessages(sessionId), listMessages: (sessionId: string) => gatewayClient.listMessages(sessionId),
sendPrompt: async (sessionId: string, prompt: string, skillId?: string) => { sendPrompt: async (sessionId: string, prompt: string, skillId?: string) => {
const executionPolicy = await resolveExecutionPolicy(skillId); const executionPolicy = await resolveExecutionPolicy(skillId);
const gatewayPrompt = await prepareGatewayPrompt(prompt, skillId);
runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, skillId); runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, skillId);
try { try {
const result = await gatewayClient.sendPrompt(sessionId, prompt); const result = await gatewayClient.sendPrompt(sessionId, gatewayPrompt);
runtimeCloudSupervisor.noteMessageSent(result.sessionId, result.reply.content, executionPolicy.modelId, skillId); runtimeCloudSupervisor.noteMessageSent(result.sessionId, result.reply.content, executionPolicy.modelId, skillId);
return { ...result, executionPolicy }; return { ...result, executionPolicy };
} catch (error) { } catch (error) {
...@@ -691,7 +716,8 @@ export function registerDesktopIpc(services: MainServices): DesktopApi { ...@@ -691,7 +716,8 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
}, },
streamPrompt: async (sessionId: string, prompt: string, skillId?: string) => { streamPrompt: async (sessionId: string, prompt: string, skillId?: string) => {
const executionPolicy = await resolveExecutionPolicy(skillId); const executionPolicy = await resolveExecutionPolicy(skillId);
const stream = await gatewayClient.streamPrompt(sessionId, prompt); const gatewayPrompt = await prepareGatewayPrompt(prompt, skillId);
const stream = await gatewayClient.streamPrompt(sessionId, gatewayPrompt);
return { return {
requestId: randomUUID(), requestId: randomUUID(),
sessionId: stream.sessionId, sessionId: stream.sessionId,
......
...@@ -13,10 +13,10 @@ import type { ...@@ -13,10 +13,10 @@ import type {
RuntimeCloudStatus, RuntimeCloudStatus,
SkillModelBindingMode, SkillModelBindingMode,
SkillSummary, SkillSummary,
UserProfileSummary, UserProfileSummary
WorkspaceSkillSummary
} from "@qjclaw/shared-types"; } from "@qjclaw/shared-types";
import type { AppConfigService } from "./app-config.js"; import type { AppConfigService } from "./app-config.js";
import type { RemoteSkillAsset } from "./skill-store.js";
import type { SecretManager } from "./secrets.js"; import type { SecretManager } from "./secrets.js";
interface SessionPayload { interface SessionPayload {
...@@ -206,16 +206,25 @@ function asRecord(value: unknown): Record<string, unknown> { ...@@ -206,16 +206,25 @@ function asRecord(value: unknown): Record<string, unknown> {
} }
function toWorkspaceSkillSummaries(payload?: OpenClawEmployeeConfigPayload | null): WorkspaceSkillSummary[] { function toRemoteSkillAssets(payload?: OpenClawEmployeeConfigPayload | null): RemoteSkillAsset[] {
return (payload?.skills ?? []).map((binding, index) => ({ return (payload?.skills ?? []).map((binding, index) => ({
id: binding.skill_id ?? binding.binding_id ?? `skill-${index + 1}`, bindingId: binding.binding_id ?? `binding-${index + 1}`,
skillId: binding.skill_id ?? binding.binding_id ?? `skill-${index + 1}`,
name: binding.skill?.title ?? binding.skill_id ?? `Skill ${index + 1}`, name: binding.skill?.title ?? binding.skill_id ?? `Skill ${index + 1}`,
description: binding.skill?.description ?? 'Enabled for this employee and ready to use in chat.', description: binding.skill?.description ?? "Enabled for this employee and ready to use in chat.",
category: binding.skill?.category ?? 'general', category: binding.skill?.category ?? "general",
enabled: true fileName: binding.skill?.file_name ?? undefined,
fileSize: typeof binding.skill?.file_size === "number" ? binding.skill.file_size : undefined,
downloadUrl: binding.skill?.download_url ?? undefined
})); }));
} }
type RuntimeCloudPayloadListener = (payload: {
action: RuntimeCloudFetchAction;
config: RuntimeCloudConfigSummary;
skills: RemoteSkillAsset[];
}) => Promise<void> | void;
class HttpJsonClient { class HttpJsonClient {
request(url: URL, options: { method: "GET" | "POST"; headers?: Record<string, string>; body?: unknown }): Promise<string> { request(url: URL, options: { method: "GET" | "POST"; headers?: Record<string, string>; body?: unknown }): Promise<string> {
const client = url.protocol === "https:" ? https : http; const client = url.protocol === "https:" ? https : http;
...@@ -371,6 +380,8 @@ class ProductCloudApiClient { ...@@ -371,6 +380,8 @@ class ProductCloudApiClient {
description: item.description ?? "No description provided.", description: item.description ?? "No description provided.",
category: item.category ?? "general", category: item.category ?? "general",
enabled: item.enabled ?? true, enabled: item.enabled ?? true,
ready: item.enabled ?? true,
downloadState: "ready",
requiresCredits: item.requiresCredits requiresCredits: item.requiresCredits
})); }));
} }
...@@ -451,6 +462,7 @@ export class OpenClawConfigClient { ...@@ -451,6 +462,7 @@ export class OpenClawConfigClient {
private readonly configService: AppConfigService; private readonly configService: AppConfigService;
private readonly secretManager: SecretManager; private readonly secretManager: SecretManager;
private readonly httpClient = new HttpJsonClient(); private readonly httpClient = new HttpJsonClient();
private readonly payloadListeners = new Set<RuntimeCloudPayloadListener>();
private payloadCache: OpenClawEmployeeConfigPayload | null = null; private payloadCache: OpenClawEmployeeConfigPayload | null = null;
private statusCache: RuntimeCloudStatus = { private statusCache: RuntimeCloudStatus = {
state: "unconfigured", state: "unconfigured",
...@@ -483,8 +495,15 @@ export class OpenClawConfigClient { ...@@ -483,8 +495,15 @@ export class OpenClawConfigClient {
return this.mergeConfig(defaultConfig, payload); return this.mergeConfig(defaultConfig, payload);
} }
async getWorkspaceSkills(): Promise<WorkspaceSkillSummary[]> { getRemoteSkillAssets(): RemoteSkillAsset[] {
return toWorkspaceSkillSummaries(this.payloadCache); return toRemoteSkillAssets(this.payloadCache);
}
onPayloadUpdated(listener: RuntimeCloudPayloadListener): () => void {
this.payloadListeners.add(listener);
return () => {
this.payloadListeners.delete(listener);
};
} }
private async fetchPayload(action: RuntimeCloudFetchAction): Promise<OpenClawEmployeeConfigPayload> { private async fetchPayload(action: RuntimeCloudFetchAction): Promise<OpenClawEmployeeConfigPayload> {
...@@ -553,6 +572,7 @@ export class OpenClawConfigClient { ...@@ -553,6 +572,7 @@ export class OpenClawConfigClient {
config: summary, config: summary,
lastError: undefined lastError: undefined
}; };
await this.notifyPayloadUpdated(action, summary);
return payload; return payload;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
...@@ -560,6 +580,17 @@ export class OpenClawConfigClient { ...@@ -560,6 +580,17 @@ export class OpenClawConfigClient {
} }
} }
private async notifyPayloadUpdated(action: RuntimeCloudFetchAction, config: RuntimeCloudConfigSummary): Promise<void> {
const skills = this.getRemoteSkillAssets();
for (const listener of this.payloadListeners) {
try {
await listener({ action, config, skills });
} catch {
// Keep runtime cloud config usable even if local skill sync fails.
}
}
}
private fail(baseUrl: string, apiKeyConfigured: boolean, message: string): never { private fail(baseUrl: string, apiKeyConfigured: boolean, message: string): never {
this.statusCache = { this.statusCache = {
...this.statusCache, ...this.statusCache,
...@@ -747,26 +778,6 @@ export class CreditClient { ...@@ -747,26 +778,6 @@ export class CreditClient {
} }
} }
export class SkillClient {
private readonly runtimeCloudClient: OpenClawConfigClient;
constructor(runtimeCloudClient: OpenClawConfigClient) {
this.runtimeCloudClient = runtimeCloudClient;
}
async list(): Promise<SkillSummary[]> {
const skills = await this.runtimeCloudClient.getWorkspaceSkills();
return skills.map((skill) => ({
id: skill.id,
name: skill.name,
description: skill.description,
category: skill.category,
enabled: skill.enabled,
requiresCredits: 0
}));
}
}
export class ModelConfigClient { export class ModelConfigClient {
private readonly api: ProductCloudApiClient; private readonly api: ProductCloudApiClient;
...@@ -780,3 +791,4 @@ export class ModelConfigClient { ...@@ -780,3 +791,4 @@ export class ModelConfigClient {
} }
...@@ -50,7 +50,7 @@ interface RuntimeCloudSupervisorOptions { ...@@ -50,7 +50,7 @@ interface RuntimeCloudSupervisorOptions {
} }
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000; const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
const DEFAULT_CONFIG_SYNC_INTERVAL_MS = 60000; const DEFAULT_CONFIG_SYNC_INTERVAL_MS = 4 * 60 * 60 * 1000;
const DEFAULT_EVENT_FLUSH_INTERVAL_MS = 10000; const DEFAULT_EVENT_FLUSH_INTERVAL_MS = 10000;
const DEFAULT_EVENT_BATCH_SIZE = 20; const DEFAULT_EVENT_BATCH_SIZE = 20;
const MAX_EVENT_BATCH_SIZE = 100; const MAX_EVENT_BATCH_SIZE = 100;
......
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import type { RuntimeManager } from "@qjclaw/runtime-manager";
import type { SkillExecutionTarget, SkillStoreService } from "./skill-store.js";
interface PreparedSkillExecution {
prompt: string;
skillName: string;
runtimeSkillName: string;
runtimeSkillDir?: string;
localPath: string;
}
const MANAGED_SKILL_PREFIX = "qjclaw-cloud-";
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48) || "skill";
}
function stripBom(content: string): string {
return content.replace(/^\uFEFF/, "");
}
function extractFrontmatterName(content: string): string | undefined {
const trimmed = stripBom(content);
if (!trimmed.startsWith("---")) {
return undefined;
}
const endIndex = trimmed.indexOf("\n---", 3);
if (endIndex < 0) {
return undefined;
}
const header = trimmed.slice(3, endIndex);
const match = header.match(/^name:\s*(.+)$/m);
return match?.[1]?.trim().replace(/^[\'\"]|[\'\"]$/g, "") || undefined;
}
function applyRuntimeSkillName(content: string, runtimeSkillName: string): string {
const normalized = stripBom(content);
if (normalized.startsWith("---")) {
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex >= 0) {
const header = normalized.slice(3, endIndex);
const body = normalized.slice(endIndex + 4);
const nextHeader = /^name:\s*.+$/m.test(header)
? header.replace(/^name:\s*.+$/m, `name: ${runtimeSkillName}`)
: `name: ${runtimeSkillName}\n${header.trimStart()}`;
return `---\n${nextHeader}\n---${body}`;
}
}
return `---\nname: ${runtimeSkillName}\n---\n\n${normalized}`;
}
export class RuntimeSkillBridgeService {
private readonly skillStore: SkillStoreService;
private readonly runtimeManager: RuntimeManager;
constructor(skillStore: SkillStoreService, runtimeManager: RuntimeManager) {
this.skillStore = skillStore;
this.runtimeManager = runtimeManager;
}
async preparePrompt(prompt: string, skillId: string): Promise<PreparedSkillExecution> {
const target = await this.skillStore.getExecutionTarget(skillId);
if (!target) {
throw new Error("The selected skill is not ready locally yet.");
}
const runtimeStatus = await this.runtimeManager.status();
const activation = await this.activateRuntimeSkill(target, runtimeStatus.runtimeDataDir);
return {
prompt: this.buildPrompt(prompt, target.name, activation.runtimeSkillName),
skillName: target.name,
runtimeSkillName: activation.runtimeSkillName,
runtimeSkillDir: activation.runtimeSkillDir,
localPath: target.localPath
};
}
async clearManagedSkills(): Promise<void> {
const runtimeStatus = await this.runtimeManager.status();
const skillsRoot = await this.resolveManagedSkillsRoot(runtimeStatus.runtimeDataDir);
await this.removeManagedSkillDirs(skillsRoot);
}
private async activateRuntimeSkill(target: SkillExecutionTarget, runtimeDataDir: string): Promise<{ runtimeSkillName: string; runtimeSkillDir: string }> {
const content = await readFile(target.localPath, "utf8");
const sourceName = extractFrontmatterName(content)
?? (target.fileName ? path.parse(target.fileName).name : undefined)
?? target.name
?? target.skillId;
const runtimeSkillName = `${MANAGED_SKILL_PREFIX}${slugify(sourceName)}`;
const skillsRoot = await this.resolveManagedSkillsRoot(runtimeDataDir);
const runtimeSkillDir = path.join(skillsRoot, runtimeSkillName);
const materializedContent = applyRuntimeSkillName(content, runtimeSkillName);
await mkdir(skillsRoot, { recursive: true });
await this.removeManagedSkillDirs(skillsRoot);
await mkdir(runtimeSkillDir, { recursive: true });
await writeFile(path.join(runtimeSkillDir, "SKILL.md"), materializedContent, "utf8");
await writeFile(path.join(runtimeSkillDir, ".qjclaw-skill.json"), JSON.stringify({
source: "cloud",
selectedSkillName: target.name,
runtimeSkillName,
localPath: target.localPath,
materializedAt: new Date().toISOString()
}, null, 2), "utf8");
return {
runtimeSkillName,
runtimeSkillDir
};
}
private async resolveManagedSkillsRoot(runtimeDataDir: string): Promise<string> {
try {
const runtimePaths = this.runtimeManager.resolveBundledPaths();
const manifestPath = path.join(runtimePaths.runtimeDir, "runtime-manifest.json");
const manifestRaw = await readFile(manifestPath, "utf8");
const manifest = JSON.parse(stripBom(manifestRaw)) as { sourceOpenClawEntry?: string };
const sourceEntry = typeof manifest.sourceOpenClawEntry === "string" ? manifest.sourceOpenClawEntry.trim() : "";
if (sourceEntry) {
return path.join(path.dirname(sourceEntry), "skills");
}
} catch {
// Fall back to a runtime-local skills directory when the bundled manifest is unavailable.
}
return path.join(runtimeDataDir, "skills");
}
private buildPrompt(originalPrompt: string, displayName: string, runtimeSkillName: string): string {
return [
`You must use the installed OpenClaw skill \"${runtimeSkillName}\" for this request.`,
`The user explicitly selected the desktop skill \"${displayName}\".`,
"Do not answer with a generic chat response before trying to invoke that local skill.",
"",
"User request:",
originalPrompt
].join("\n");
}
private async removeManagedSkillDirs(skillsRoot: string): Promise<void> {
let entries: Array<{ name: string; isDirectory: () => boolean }> = [];
try {
entries = await readdir(skillsRoot, { withFileTypes: true });
} catch {
return;
}
await Promise.all(
entries
.filter((entry) => entry.isDirectory() && entry.name.startsWith(MANAGED_SKILL_PREFIX))
.map((entry) => rm(path.join(skillsRoot, entry.name), { recursive: true, force: true }))
);
}
}
import type { SkillSummary } from "@qjclaw/shared-types";
import type { SkillStoreService } from "./skill-store.js";
export class SkillClient {
private readonly skillStore: SkillStoreService;
constructor(skillStore: SkillStoreService) {
this.skillStore = skillStore;
}
list(): Promise<SkillSummary[]> {
return this.skillStore.listSkills();
}
}
import http from "node:http";
import https from "node:https";
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import type {
SkillDownloadState,
SkillSummary,
WorkspaceSkillSummary
} from "@qjclaw/shared-types";
export interface RemoteSkillAsset {
bindingId: string;
skillId: string;
name: string;
description: string;
category: string;
fileName?: string;
fileSize?: number;
downloadUrl?: string;
}
export interface SkillExecutionTarget {
skillId: string;
name: string;
fileName?: string;
localPath: string;
}
interface SkillManifestEntry extends RemoteSkillAsset {
localPath?: string;
downloadState: SkillDownloadState;
lastSyncedAt?: string;
lastDownloadedAt?: string;
lastError?: string;
remoteConfigVersion?: string;
}
const SKILLS_DIR = "skills";
const MANIFEST_FILE = "manifest.json";
const REDIRECT_STATUS_CODES = new Set([301, 302, 307, 308]);
const MAX_REDIRECTS = 5;
function toSummary(entry: SkillManifestEntry): WorkspaceSkillSummary {
return {
id: entry.skillId,
name: entry.name,
description: entry.description,
category: entry.category,
enabled: entry.downloadState !== "removed",
ready: entry.downloadState === "ready",
downloadState: entry.downloadState,
fileName: entry.fileName,
fileSize: entry.fileSize,
lastSyncedAt: entry.lastSyncedAt,
lastError: entry.lastError
};
}
function compareEntries(left: SkillManifestEntry, right: SkillManifestEntry): number {
return left.name.localeCompare(right.name, "zh-CN");
}
export class SkillStoreService {
private readonly skillsRoot: string;
private readonly manifestPath: string;
constructor(userDataPath: string) {
this.skillsRoot = path.join(userDataPath, SKILLS_DIR);
this.manifestPath = path.join(this.skillsRoot, MANIFEST_FILE);
}
async reconcile(remoteSkills: RemoteSkillAsset[], configVersion?: string): Promise<void> {
await mkdir(this.skillsRoot, { recursive: true });
const now = new Date().toISOString();
const currentEntries = await this.readManifest();
const currentById = new Map(currentEntries.map((entry) => [entry.skillId, entry]));
const nextEntries: SkillManifestEntry[] = [];
for (const remoteSkill of remoteSkills) {
const current = currentById.get(remoteSkill.skillId);
const nextEntry: SkillManifestEntry = {
...current,
...remoteSkill,
localPath: remoteSkill.fileName
? path.join(this.skillsRoot, remoteSkill.skillId, remoteSkill.fileName)
: current?.localPath,
downloadState: current?.downloadState ?? "pending",
lastSyncedAt: now,
remoteConfigVersion: configVersion ?? current?.remoteConfigVersion
};
if (!remoteSkill.downloadUrl || !remoteSkill.fileName) {
nextEntries.push({
...nextEntry,
downloadState: "failed",
lastError: "技能下载地址或文件名缺失。"
});
continue;
}
if (!this.needsDownload(current, nextEntry)) {
nextEntries.push({
...nextEntry,
downloadState: "ready",
lastError: undefined
});
continue;
}
try {
const downloadingEntry: SkillManifestEntry = {
...nextEntry,
downloadState: "downloading",
lastError: undefined
};
const downloadedEntry = await this.downloadSkill(downloadingEntry);
nextEntries.push(downloadedEntry);
} catch (error) {
nextEntries.push({
...nextEntry,
downloadState: "failed",
lastError: error instanceof Error ? error.message : String(error)
});
}
}
const remoteSkillIds = new Set(remoteSkills.map((skill) => skill.skillId));
for (const current of currentEntries) {
if (!remoteSkillIds.has(current.skillId)) {
nextEntries.push({
...current,
downloadState: "removed",
lastSyncedAt: now,
remoteConfigVersion: configVersion ?? current.remoteConfigVersion
});
}
}
await this.writeManifest(nextEntries);
}
async listWorkspaceSkills(): Promise<WorkspaceSkillSummary[]> {
const entries = await this.readManifest();
return entries
.filter((entry) => entry.downloadState !== "removed")
.sort(compareEntries)
.map(toSummary);
}
async listSkills(): Promise<SkillSummary[]> {
const entries = await this.readManifest();
return entries
.filter((entry) => entry.downloadState !== "removed")
.sort(compareEntries)
.map((entry) => ({
...toSummary(entry),
requiresCredits: 0
}));
}
async getExecutionTarget(skillId: string): Promise<SkillExecutionTarget | undefined> {
const entries = await this.readManifest();
const entry = entries.find((item) => item.skillId === skillId && item.downloadState === "ready" && typeof item.localPath === "string");
if (!entry?.localPath) {
return undefined;
}
return {
skillId: entry.skillId,
name: entry.name,
fileName: entry.fileName,
localPath: entry.localPath
};
}
private needsDownload(current: SkillManifestEntry | undefined, next: SkillManifestEntry): boolean {
if (!current) {
return true;
}
if (current.downloadState !== "ready") {
return true;
}
if (!current.localPath) {
return true;
}
return current.downloadUrl !== next.downloadUrl
|| current.fileName !== next.fileName
|| current.fileSize !== next.fileSize;
}
private async downloadSkill(entry: SkillManifestEntry): Promise<SkillManifestEntry> {
if (!entry.downloadUrl || !entry.fileName || !entry.localPath) {
throw new Error("技能下载元数据不完整,无法落盘。");
}
const targetDir = path.dirname(entry.localPath);
const tempPath = `${entry.localPath}.tmp-${Date.now()}`;
const downloadedAt = new Date().toISOString();
await mkdir(targetDir, { recursive: true });
try {
const payload = await this.downloadToBuffer(new URL(entry.downloadUrl));
await writeFile(tempPath, payload);
await unlink(entry.localPath).catch(() => undefined);
await rename(tempPath, entry.localPath);
} catch (error) {
await unlink(tempPath).catch(() => undefined);
throw error;
}
return {
...entry,
downloadState: "ready",
lastDownloadedAt: downloadedAt,
lastError: undefined
};
}
private async downloadToBuffer(url: URL, redirectCount = 0): Promise<Buffer> {
const client = url.protocol === "https:" ? https : http;
return new Promise<Buffer>((resolve, reject) => {
const request = client.get(url, (response) => {
const status = response.statusCode ?? 500;
const location = response.headers.location;
if (location && REDIRECT_STATUS_CODES.has(status)) {
if (redirectCount >= MAX_REDIRECTS) {
reject(new Error("技能下载重定向次数过多。"));
response.resume();
return;
}
const redirectUrl = new URL(location, url);
response.resume();
void this.downloadToBuffer(redirectUrl, redirectCount + 1).then(resolve, reject);
return;
}
if (status < 200 || status >= 300) {
reject(new Error(`技能下载失败,HTTP 状态码 ${status}。`));
response.resume();
return;
}
const chunks: Buffer[] = [];
response.on("data", (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
response.on("end", () => {
resolve(Buffer.concat(chunks));
});
});
request.on("error", (error) => {
reject(new Error(`技能下载失败:${error.message}`));
});
});
}
private async readManifest(): Promise<SkillManifestEntry[]> {
await mkdir(this.skillsRoot, { recursive: true });
try {
const raw = await readFile(this.manifestPath, "utf8");
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((entry): entry is SkillManifestEntry => typeof entry?.skillId === "string");
} catch {
return [];
}
}
private async writeManifest(entries: SkillManifestEntry[]): Promise<void> {
const nextEntries = [...entries].sort(compareEntries);
const tempPath = `${this.manifestPath}.tmp-${Date.now()}`;
await mkdir(this.skillsRoot, { recursive: true });
await writeFile(tempPath, JSON.stringify(nextEntries, null, 2), "utf8");
await unlink(this.manifestPath).catch(() => undefined);
await rename(tempPath, this.manifestPath);
}
}
...@@ -99,7 +99,9 @@ const DEFAULT_SKILL = { ...@@ -99,7 +99,9 @@ const DEFAULT_SKILL = {
name: "默认对话", name: "默认对话",
description: "通用对话技能", description: "通用对话技能",
category: "通用", category: "通用",
enabled: true enabled: true,
ready: true,
downloadState: "ready" as const
}; };
const ui = { const ui = {
...@@ -190,8 +192,8 @@ const mockDesktopApi = { ...@@ -190,8 +192,8 @@ const mockDesktopApi = {
runtimeMessage: "mock", runtimeMessage: "mock",
skillCount: 2, skillCount: 2,
skills: [ skills: [
{ id: "sheet", name: "表格工具", description: "处理电子表格和数据统计。", category: "办公", enabled: true }, { id: "sheet", name: "Spreadsheet Tools", description: "Process spreadsheets and data summaries.", category: "office", enabled: true, ready: true, downloadState: "ready", fileName: "sheet.md" },
{ id: "doc", name: "文档工具", description: "处理文档和内容整理。", category: "办公", enabled: true } { id: "doc", name: "Document Tools", description: "Process documents and content organization.", category: "office", enabled: true, ready: true, downloadState: "ready", fileName: "doc.md" }
], ],
plugins: [ plugins: [
{ id: "spreadsheet-tools", name: "表格工具", description: "读取、统计和处理 Excel、CSV 等常见表格文件。", status: "included", includedByDefault: true }, { id: "spreadsheet-tools", name: "表格工具", description: "读取、统计和处理 Excel、CSV 等常见表格文件。", status: "included", includedByDefault: true },
...@@ -320,6 +322,23 @@ function getPluginCopy(plugin: WorkspaceSummary["plugins"][number]) { ...@@ -320,6 +322,23 @@ function getPluginCopy(plugin: WorkspaceSummary["plugins"][number]) {
return pluginDisplayMap[plugin.id] ?? { name: plugin.name, description: plugin.description }; return pluginDisplayMap[plugin.id] ?? { name: plugin.name, description: plugin.description };
} }
function getSkillStatusText(skill: WorkspaceSummary["skills"][number]) {
switch (skill.downloadState) {
case "ready":
return "Ready";
case "downloading":
return "Syncing";
case "failed":
return skill.lastError ? `Failed: ${skill.lastError}` : "Failed";
case "pending":
return "Pending";
case "removed":
return "Removed";
default:
return "Unknown";
}
}
function canExchangeMessages(runtimeStatus: RuntimeStatus | null, gatewayStatus: GatewayStatus | null) { function canExchangeMessages(runtimeStatus: RuntimeStatus | null, gatewayStatus: GatewayStatus | null) {
if (!runtimeStatus || gatewayStatus?.state !== "connected") { if (!runtimeStatus || gatewayStatus?.state !== "connected") {
return false; return false;
...@@ -351,7 +370,9 @@ export default function App() { ...@@ -351,7 +370,9 @@ export default function App() {
const activeStreamRef = useRef<ActiveStreamState | null>(null); const activeStreamRef = useRef<ActiveStreamState | null>(null);
const [streamSmoke, setStreamSmoke] = useState<SmokeStreamSnapshot | null>(null); const [streamSmoke, setStreamSmoke] = useState<SmokeStreamSnapshot | null>(null);
const effectiveSkills = useMemo(() => (workspace?.skills?.length ? [DEFAULT_SKILL, ...workspace.skills] : [DEFAULT_SKILL]), [workspace]); const catalogSkills = workspace?.skills ?? [];
const readySkills = useMemo(() => catalogSkills.filter((skill) => skill.ready), [catalogSkills]);
const effectiveSkills = useMemo(() => (readySkills.length ? [DEFAULT_SKILL, ...readySkills] : [DEFAULT_SKILL]), [readySkills]);
const selectedSkill = useMemo(() => effectiveSkills.find((skill) => skill.id === selectedSkillId) ?? effectiveSkills[0] ?? DEFAULT_SKILL, [effectiveSkills, selectedSkillId]); const selectedSkill = useMemo(() => effectiveSkills.find((skill) => skill.id === selectedSkillId) ?? effectiveSkills[0] ?? DEFAULT_SKILL, [effectiveSkills, selectedSkillId]);
const chatLaunchState: ChatLaunchState = workspace?.chatLaunchState ?? (workspace?.apiKeyConfigured ? "starting" : "unbound"); const chatLaunchState: ChatLaunchState = workspace?.chatLaunchState ?? (workspace?.apiKeyConfigured ? "starting" : "unbound");
const chatStatusMessage = workspace?.chatStatusMessage ?? (chatLaunchState === "starting" ? ui.startingHint : chatLaunchState === "error" ? ui.chatNotReadyError : ""); const chatStatusMessage = workspace?.chatStatusMessage ?? (chatLaunchState === "starting" ? ui.startingHint : chatLaunchState === "error" ? ui.chatNotReadyError : "");
...@@ -426,7 +447,8 @@ export default function App() { ...@@ -426,7 +447,8 @@ export default function App() {
setWorkspacePathDraft((current) => current || nextConfig.workspacePath); setWorkspacePathDraft((current) => current || nextConfig.workspacePath);
setGatewayStatus(statusResult); setGatewayStatus(statusResult);
const nextSkills = nextWorkspace.skills.length ? [DEFAULT_SKILL, ...nextWorkspace.skills] : [DEFAULT_SKILL]; const nextReadySkills = nextWorkspace.skills.filter((skill) => skill.ready);
const nextSkills = nextReadySkills.length ? [DEFAULT_SKILL, ...nextReadySkills] : [DEFAULT_SKILL];
if (!nextSkills.some((skill) => skill.id === selectedSkillId)) { if (!nextSkills.some((skill) => skill.id === selectedSkillId)) {
setSelectedSkillId(nextSkills[0].id); setSelectedSkillId(nextSkills[0].id);
} }
...@@ -1032,7 +1054,7 @@ export default function App() { ...@@ -1032,7 +1054,7 @@ export default function App() {
</div> </div>
</section> </section>
) : null} ) : null}
{viewMode === "skills" ? <section className="panel catalog-list"><div className="scroll-panel">{effectiveSkills.map((skill) => <button key={skill.id} type="button" className="catalog-item" onClick={() => { setSelectedSkillId(skill.id); setViewMode("chat"); }}><strong>{skill.name}</strong><p>{skill.description}</p></button>)}{!effectiveSkills.length ? <div className="empty-state">{ui.noSkillCards}</div> : null}</div></section> : null} {viewMode === "skills" ? <section className="panel catalog-list"><div className="scroll-panel">{catalogSkills.map((skill) => <button key={skill.id} type="button" className="catalog-item" disabled={!skill.ready} onClick={() => { if (!skill.ready) { return; } setSelectedSkillId(skill.id); setViewMode("chat"); }}><strong>{skill.name}</strong><p>{skill.description}</p><p>{getSkillStatusText(skill)}{skill.fileName ? ` - ${skill.fileName}` : ""}</p></button>)}{!catalogSkills.length ? <div className="empty-state">{ui.noSkillCards}</div> : null}</div></section> : null}
{viewMode === "plugins" ? <section className="panel catalog-list"><div className="section-head compact"><div><h3>{ui.pluginTitle}</h3></div></div><div className="scroll-panel">{workspace?.plugins.map((plugin) => { const copy = getPluginCopy(plugin); return <article key={plugin.id} className="catalog-item static"><strong>{copy.name}</strong><p>{copy.description}</p></article>; })}{!workspace?.plugins.length ? <div className="empty-state">{ui.noPlugins}</div> : null}</div></section> : null} {viewMode === "plugins" ? <section className="panel catalog-list"><div className="section-head compact"><div><h3>{ui.pluginTitle}</h3></div></div><div className="scroll-panel">{workspace?.plugins.map((plugin) => { const copy = getPluginCopy(plugin); return <article key={plugin.id} className="catalog-item static"><strong>{copy.name}</strong><p>{copy.description}</p></article>; })}{!workspace?.plugins.length ? <div className="empty-state">{ui.noPlugins}</div> : null}</div></section> : null}
{viewMode === "settings" ? <div className="page-stack"><section className="panel settings-panel"><div className="section-head compact"><div><h3>{ui.settingsTitle}</h3><p>{ui.settingsDesc}</p></div><StatusChip tone={workspace?.apiKeyConfigured ? "positive" : "warning"}>{workspace?.apiKeyConfigured ? ui.bound : ui.unbound}</StatusChip></div><div className="form-grid single"><label>{ui.apiKey}<input type="password" value={apiKeyDraft} placeholder={workspace?.apiKeyConfigured ? ui.changeApiKey : ui.apiKeyPlaceholder} onChange={(event) => setApiKeyDraft(event.target.value)} /></label><label>{ui.workspacePath}<input value={workspacePathDraft} onChange={(event) => setWorkspacePathDraft(event.target.value)} /></label></div><div className="button-row"><button disabled={saving} onClick={() => void saveConfig(apiKeyDraft)}>{saving ? ui.saving : ui.save}</button></div><div className="mini-info"><span>{ui.currentBinding}</span><strong>{workspace?.apiKeyConfigured ? ui.bound : ui.unbound}</strong></div></section><section className="panel settings-panel"><div className="section-head compact"><div><h3>{ui.diagnostics}</h3><p>{ui.diagnosticsDesc}</p></div></div><div className="mini-info"><span>{ui.workspacePath}</span><strong>{config?.workspacePath || workspacePathDraft || ui.none}</strong></div><div className="button-row"><button className="secondary" onClick={() => void exportDiagnostics()}>{ui.export}</button></div></section></div> : null} {viewMode === "settings" ? <div className="page-stack"><section className="panel settings-panel"><div className="section-head compact"><div><h3>{ui.settingsTitle}</h3><p>{ui.settingsDesc}</p></div><StatusChip tone={workspace?.apiKeyConfigured ? "positive" : "warning"}>{workspace?.apiKeyConfigured ? ui.bound : ui.unbound}</StatusChip></div><div className="form-grid single"><label>{ui.apiKey}<input type="password" value={apiKeyDraft} placeholder={workspace?.apiKeyConfigured ? ui.changeApiKey : ui.apiKeyPlaceholder} onChange={(event) => setApiKeyDraft(event.target.value)} /></label><label>{ui.workspacePath}<input value={workspacePathDraft} onChange={(event) => setWorkspacePathDraft(event.target.value)} /></label></div><div className="button-row"><button disabled={saving} onClick={() => void saveConfig(apiKeyDraft)}>{saving ? ui.saving : ui.save}</button></div><div className="mini-info"><span>{ui.currentBinding}</span><strong>{workspace?.apiKeyConfigured ? ui.bound : ui.unbound}</strong></div></section><section className="panel settings-panel"><div className="section-head compact"><div><h3>{ui.diagnostics}</h3><p>{ui.diagnosticsDesc}</p></div></div><div className="mini-info"><span>{ui.workspacePath}</span><strong>{config?.workspacePath || workspacePathDraft || ui.none}</strong></div><div className="button-row"><button className="secondary" onClick={() => void exportDiagnostics()}>{ui.export}</button></div></section></div> : null}
</main> </main>
......
# Cloud Skill Sync And Execution Flow
Updated: 2026-03-26
## 1. Purpose
This document describes the current real implementation of the cloud skill chain in QianjiangClaw.
It covers:
- how desktop pulls skills from cloud
- where skills are stored locally
- how UI reads skill state
- how chat uses a selected skill
- what runtime mode is required
- what is currently implemented vs. what is still a limitation
This document should be treated as the source of truth for the current code path, not the older planning docs.
## 2. High-Level Summary
Current behavior is:
1. Desktop starts.
2. Electron Main fetches `openclaw-employee-config` from cloud.
3. Returned `skills` are downloaded to local disk under `userData/skills`.
4. Main process writes a local `manifest.json`.
5. UI reads skill availability from that local manifest-derived summary.
6. When the user selects a skill in chat, Main resolves the local file by `skillId`.
7. Main materializes that skill into the OpenClaw runtime's actual skill directory as a temporary `qjclaw-cloud-*` skill.
8. Main rewrites the outgoing prompt so OpenClaw is instructed to use that installed local skill.
9. OpenClaw runtime discovers and reads the materialized local skill and executes it.
Important:
- Cloud skill execution is currently supported only in `bundled-runtime` mode.
- `external-gateway` mode cannot safely assume access to the same local disk, so Main rejects local skill execution in that mode.
## 3. Main Components
### 3.1 Cloud config client
File:
- `apps/desktop/src/main/services/cloud-api.ts`
Responsibilities:
- fetch `openclaw-employee-config`
- validate payload
- keep runtime cloud status cache
- expose remote skill asset metadata
- notify listeners when payload changes
Relevant behavior:
- startup fetch uses `init`
- background sync uses `sync`
- payload updates trigger downstream local skill reconcile
### 3.2 Local skill store
File:
- `apps/desktop/src/main/services/skill-store.ts`
Responsibilities:
- persist downloaded cloud skills under local user data
- maintain `manifest.json`
- expose workspace skill summaries to UI
- expose execution target lookup by `skillId`
Local storage layout:
- `userData/skills/manifest.json`
- `userData/skills/<skillId>/<file_name>`
Manifest tracks:
- `skillId`
- `bindingId`
- `name`
- `description`
- `category`
- `fileName`
- `fileSize`
- `downloadUrl`
- `localPath`
- `downloadState`
- `lastSyncedAt`
- `lastDownloadedAt`
- `lastError`
- `remoteConfigVersion`
Current states:
- `pending`
- `downloading`
- `ready`
- `failed`
- `removed`
### 3.3 Runtime skill bridge
File:
- `apps/desktop/src/main/services/runtime-skill-bridge.ts`
Responsibilities:
- resolve the local downloaded skill file selected by the user
- materialize it as an OpenClaw-readable runtime skill
- assign a runtime skill name like `qjclaw-cloud-*`
- rewrite `SKILL.md` frontmatter `name:` to match that runtime skill name
- clean up old temporary cloud skills
Important implementation detail:
The runtime bridge does not execute directly from `userData/skills/...`.
Instead it:
1. reads the downloaded local skill file from `userData/skills/...`
2. finds the real OpenClaw skill root by reading `vendor/openclaw-runtime/runtime-manifest.json`
3. resolves `sourceOpenClawEntry`
4. writes the temporary runtime skill into the real OpenClaw skill directory next to that entry
This is required because current OpenClaw runtime resolves skills from its own actual installed skill directory, not from `OPENCLAW_HOME/skills`.
### 3.4 IPC chat integration
File:
- `apps/desktop/src/main/ipc.ts`
Responsibilities:
- build workspace summary for renderer
- resolve execution policy
- prepare prompt for gateway
- clear temporary runtime skills when no skill is selected
- reject local skill execution in `external-gateway`
Prompt flow:
- no skill selected:
- clear temporary `qjclaw-cloud-*` skills
- send original prompt
- skill selected:
- ensure runtime mode is `bundled-runtime`
- resolve local downloaded skill
- materialize runtime skill
- rewrite prompt to explicitly instruct OpenClaw to use the installed runtime skill
- send rewritten prompt to gateway
## 4. End-To-End Data Flow
### 4.1 Sync flow
1. App bootstraps Main services in `apps/desktop/src/main/index.ts`.
2. `OpenClawConfigClient.fetchConfig("init")` fetches runtime cloud config.
3. `runtimeCloudClient.onPayloadUpdated(...)` fires.
4. `skillStore.reconcile(skills, configVersion)` compares remote skills and local manifest.
5. New or changed skills are downloaded to `userData/skills/...`.
6. `manifest.json` is rewritten.
7. Renderer reads `workspace.getSummary()` and `skills.list()` from manifest-derived summaries.
### 4.2 Chat execution flow
1. Renderer sends `chat.streamPrompt(sessionId, prompt, skillId?)`.
2. Main checks `skillId`.
3. If present:
- find local skill file via `skillStore.getExecutionTarget(skillId)`
- materialize it into OpenClaw skill root as `qjclaw-cloud-*`
- rewrite prompt to require that installed skill
4. Gateway receives the rewritten prompt.
5. OpenClaw runtime reads the installed local skill file and executes its own skill logic.
## 5. Timing And Sync Policy
Current actual sync policy:
- app startup: one `init` fetch
- runtime running in `bundled-runtime` mode: periodic `sync`
- current default background config sync interval: 4 hours
Implementation:
- `apps/desktop/src/main/services/runtime-cloud-supervisor.ts`
Current default:
- `DEFAULT_CONFIG_SYNC_INTERVAL_MS = 4 * 60 * 60 * 1000`
Notes:
- this is not a daily 6:00 task
- if the app is not running, no sync runs
- the interval can still be overridden by environment variable:
- `QJCLAW_RUNTIME_CLOUD_CONFIG_SYNC_INTERVAL_MS`
## 6. UI Behavior
File:
- `apps/ui/src/App.tsx`
Current renderer behavior:
- skill list comes from workspace summary
- only `ready === true` skills are selectable in chat
- skill page can show non-ready states
- default chat means no cloud skill selected
This means UI is already driven by local readiness, not by raw cloud payload.
## 7. Runtime Modes
### 7.1 Bundled runtime
Supported for cloud skill execution.
Reason:
- Electron Main, local downloaded files, and OpenClaw runtime are all on the same machine
- Main can materialize the runtime skill into the actual OpenClaw skill root
### 7.2 External gateway
Not supported for local cloud skill execution.
Reason:
- external gateway may be remote or otherwise not share the same local disk
- Main cannot guarantee the external gateway can read the local skill file
Current behavior:
- Main throws an explicit error when a selected cloud skill is used under `external-gateway`
## 8. Cleanup Behavior
Current cleanup logic exists to avoid stale temporary runtime skills:
- startup cleanup
- no-skill-selected cleanup
- app before-quit cleanup
What gets cleaned:
- only temporary `qjclaw-cloud-*` skill directories
What does not get cleaned:
- downloaded source files under `userData/skills/...`
- manifest entries
This separation is intentional:
- `userData/skills/...` is the persistent cache
- `qjclaw-cloud-*` runtime skills are ephemeral execution projections
## 9. Real Verified Behavior
Verified with a real employee `api_key`:
- cloud config fetch succeeded
- remote skills were downloaded to local disk
- manifest was updated correctly
- selected cloud skill was materialized into OpenClaw's real skill root
- OpenClaw could discover and read the materialized skill
- renderer streaming worked for at least one real skill end-to-end
Observed limitation from real testing:
- some cloud skills have external prerequisites of their own
- example: `tmux`, `ralphy`, ACP runtime
- example: browser automation and login requirements
- in those cases the chain from desktop to runtime skill is working, but the skill's own runtime workflow may still fail or hang based on environment
This is a skill-content/runtime-environment problem, not a cloud-download-chain problem.
## 10. Current Limitations
### 10.1 Prompt rewrite instead of protocol-level skill execution
Current implementation does not send a dedicated `skillPath` or `skillId` execution contract through gateway protocol.
Instead it relies on:
- materializing a runtime skill locally
- rewriting prompt to instruct OpenClaw to use it
This works today, but it is not the cleanest long-term contract.
### 10.2 OpenClaw skill discovery path is external to QianjiangClaw-managed userData
Current runtime bridge has to project skills into the OpenClaw skill root derived from `runtime-manifest.json`.
That means:
- execution currently depends on how bundled OpenClaw resolves its skill directory
- changes in upstream OpenClaw skill resolution may require bridge updates
### 10.3 Skill prerequisites are not preflight-validated
Current desktop chain does not yet perform a formal preflight for skill-specific dependencies.
Examples:
- `tmux`
- `ralphy`
- ACP runtime backend
- browser automation login state
Recommended future improvement:
- add preflight validation and fast-fail reporting for skills with known dependencies
## 11. Recommended Future Work
### 11.1 Add protocol-level skill execution contract
Possible direction:
- extend gateway/chat protocol with explicit selected skill metadata
- allow runtime to resolve local skill execution through a structured contract instead of prompt rewriting
### 11.2 Add skill preflight checks
For example:
- dependency availability
- browser automation availability
- login-required capabilities
- ACP runtime availability
### 11.3 Add dedicated smoke coverage
Recommended smoke layers:
- cloud config fetch smoke
- local download + manifest smoke
- runtime materialization smoke
- end-to-end execution smoke for a simple non-interactive skill
## 12. Key Files
Core files for future development:
- `apps/desktop/src/main/index.ts`
- `apps/desktop/src/main/ipc.ts`
- `apps/desktop/src/main/services/cloud-api.ts`
- `apps/desktop/src/main/services/skill-store.ts`
- `apps/desktop/src/main/services/skill-client.ts`
- `apps/desktop/src/main/services/runtime-skill-bridge.ts`
- `apps/desktop/src/main/services/runtime-cloud-supervisor.ts`
- `apps/ui/src/App.tsx`
- `packages/shared-types/src/index.ts`
## 13. One-Sentence Mental Model
Cloud returns skill metadata and download URLs, desktop downloads the skill to local cache, chat selection causes Main to project that cached file into OpenClaw's real skill directory, and bundled runtime then executes that local projected skill.
# 云端 Skill 同步与执行链路
更新日期:2026-03-26
## 1. 目的
本文档说明 QianjiangClaw 当前已经落地的云端 skill 链路实现,包括:
- desktop 如何从云端拉取 skill
- skill 下载后存放在本地哪里
- UI 如何读取 skill 状态
- 聊天里选中 skill 后如何执行
- 依赖什么 runtime 模式
- 当前已经实现了什么,哪些仍然是限制
这份文档描述的是当前真实代码路径,应作为现阶段实现的基准,而不是早期规划文档。
## 2. 高层概览
当前实际行为如下:
1. Desktop 启动。
2. Electron Main 从云端拉取 `openclaw-employee-config`
3. 返回的 `skills` 被下载到本地磁盘 `userData/skills`
4. Main 进程写入本地 `manifest.json`
5. UI 从这份本地 manifest 派生出的摘要里读取 skill 可用状态。
6. 用户在聊天里选中某个 skill 后,Main 按 `skillId` 找到本地文件。
7. Main 把这个 skill 投影到 OpenClaw runtime 实际使用的 skill 目录,生成一个临时的 `qjclaw-cloud-*` skill。
8. Main 改写即将发送出去的 prompt,明确要求 OpenClaw 使用这个已经安装到本地的 runtime skill。
9. OpenClaw runtime 发现并读取这个本地投影出来的 skill,然后执行它。
重要说明:
- 当前云端 skill 执行只支持 `bundled-runtime` 模式。
- `external-gateway` 模式无法安全假设能访问同一块本地磁盘,所以 Main 会直接拒绝这种本地 skill 执行。
## 3. 主要组件
### 3.1 云端配置客户端
文件:
- `apps/desktop/src/main/services/cloud-api.ts`
职责:
- 拉取 `openclaw-employee-config`
- 校验返回 payload
- 维护 runtime cloud 状态缓存
- 暴露远端 skill 资源元数据
- 在 payload 变化时通知下游
相关行为:
- 启动拉取使用 `init`
- 后台同步使用 `sync`
- payload 更新后会触发下游本地 skill reconcile
### 3.2 本地 skill 存储层
文件:
- `apps/desktop/src/main/services/skill-store.ts`
职责:
- 将下载后的云端 skill 持久化到本地 user data 目录
- 维护 `manifest.json`
- 给 UI 暴露 workspace skill 摘要
-`skillId` 暴露执行目标查询
本地存储结构:
- `userData/skills/manifest.json`
- `userData/skills/<skillId>/<file_name>`
manifest 当前记录这些字段:
- `skillId`
- `bindingId`
- `name`
- `description`
- `category`
- `fileName`
- `fileSize`
- `downloadUrl`
- `localPath`
- `downloadState`
- `lastSyncedAt`
- `lastDownloadedAt`
- `lastError`
- `remoteConfigVersion`
当前状态值:
- `pending`
- `downloading`
- `ready`
- `failed`
- `removed`
### 3.3 Runtime skill bridge
文件:
- `apps/desktop/src/main/services/runtime-skill-bridge.ts`
职责:
- 解析用户选中的本地已下载 skill 文件
- 把它投影成 OpenClaw 可读取的 runtime skill
- 分配一个类似 `qjclaw-cloud-*` 的 runtime skill 名
- 改写 `SKILL.md` frontmatter 里的 `name:`,保证与 runtime skill 名一致
- 清理旧的临时云端 skill
重要实现细节:
runtime bridge 并不是直接从 `userData/skills/...` 执行。
它实际会:
1.`userData/skills/...` 读取已经下载好的本地 skill 文件。
2. 读取 `vendor/openclaw-runtime/runtime-manifest.json`,找到真正的 OpenClaw skill 根目录。
3. 解析 `sourceOpenClawEntry`
4. 把临时 runtime skill 写入这个真实 OpenClaw skill 目录旁边的 `skills` 目录里。
之所以这样做,是因为当前 OpenClaw runtime 会从它自己实际安装的 skill 目录发现 skill,而不是从 `OPENCLAW_HOME/skills` 读取。
### 3.4 IPC 聊天集成
文件:
- `apps/desktop/src/main/ipc.ts`
职责:
- 为 renderer 构建 workspace 摘要
- 解析执行策略
- 为 gateway 准备 prompt
- 在未选 skill 时清理临时 runtime skills
-`external-gateway` 下拒绝本地 skill 执行
prompt 流程:
- 未选 skill:
- 清理临时 `qjclaw-cloud-*` skills
- 发送原始 prompt
- 选中 skill:
- 确认当前 runtime mode 是 `bundled-runtime`
- 解析本地已下载 skill
- 投影 runtime skill
- 改写 prompt,明确要求 OpenClaw 使用这个已安装的 runtime skill
- 把改写后的 prompt 发给 gateway
## 4. 端到端数据流
### 4.1 同步链路
1. 应用在 `apps/desktop/src/main/index.ts` 中启动 Main 服务。
2. `OpenClawConfigClient.fetchConfig("init")` 拉取 runtime cloud 配置。
3. `runtimeCloudClient.onPayloadUpdated(...)` 被触发。
4. `skillStore.reconcile(skills, configVersion)` 对比远端 skills 和本地 manifest。
5. 新增或变更的 skill 被下载到 `userData/skills/...`
6. `manifest.json` 被重写。
7. Renderer 通过 `workspace.getSummary()``skills.list()` 读取基于 manifest 派生出的摘要。
### 4.2 聊天执行链路
1. Renderer 发送聊天消息和选中的 `skillId`
2. Main 检查 `skillId`
3. 如果存在:
- 通过 `skillStore.getExecutionTarget(skillId)` 找到本地 skill 文件
- 把它投影到 OpenClaw skill 根目录,生成 `qjclaw-cloud-*`
- 改写 prompt,要求必须使用这个已安装 skill
4. Gateway 收到改写后的 prompt。
5. OpenClaw runtime 读取这个已安装到本地的 skill 文件,并执行它自己的 skill 逻辑。
## 5. 时间与同步策略
当前真实同步策略:
- 应用启动时执行一次 `init`
- runtime 在 `bundled-runtime` 模式运行时周期性执行 `sync`
- 当前默认后台配置同步间隔是 4 小时
实现位置:
- `apps/desktop/src/main/services/runtime-cloud-supervisor.ts`
当前默认值:
- `DEFAULT_CONFIG_SYNC_INTERVAL_MS = 4 * 60 * 60 * 1000`
说明:
- 这不是每天早上 6 点的任务
- 如果应用没有运行,就不会发生同步
- 仍然可以通过环境变量覆盖这个间隔:
- `QJCLAW_RUNTIME_CLOUD_CONFIG_SYNC_INTERVAL_MS`
## 6. UI 行为
文件:
- `apps/ui/src/App.tsx`
当前 renderer 行为:
- skill 列表来自 workspace summary
- 只有 `ready === true` 的 skill 能在聊天里被选中
- skill 页面可以展示非 ready 状态
- 默认对话表示没有选中任何云端 skill
这意味着 UI 现在已经由本地可用状态驱动,而不是直接由原始云端 payload 驱动。
## 7. Runtime 模式
### 7.1 Bundled runtime
支持云端 skill 执行。
原因:
- Electron Main、本地下载文件和 OpenClaw runtime 都在同一台机器上
- Main 可以把 runtime skill 投影到 OpenClaw 实际 skill 根目录
### 7.2 External gateway
不支持本地云端 skill 执行。
原因:
- external gateway 可能是远程的,或者与当前机器不共享同一块本地磁盘
- Main 无法保证 external gateway 能读取本地 skill 文件
当前行为:
-`external-gateway` 下,如果用户选中了云端 skill,Main 会直接抛出明确错误
## 8. 清理行为
当前已经有清理逻辑,用来避免临时 runtime skills 残留:
- 启动时清理
- 未选 skill 时清理
- 应用退出前清理
清理目标:
- 仅清理临时的 `qjclaw-cloud-*` skill 目录
不会清理的内容:
- `userData/skills/...` 下的已下载源文件
- manifest 中的记录
这是刻意区分的:
- `userData/skills/...` 是持久化缓存
- `qjclaw-cloud-*` runtime skill 是一次执行期的临时投影
## 9. 真实验证过的行为
已经使用真实员工 `api_key` 做过验证:
- 云端配置拉取成功
- 远端 skills 成功下载到本地磁盘
- manifest 正确更新
- 选中的云端 skill 能被投影到 OpenClaw 真正的 skill 根目录
- OpenClaw 能发现并读取这个投影后的 skill
- 对至少一个真实 skill,renderer 流式链路已经端到端跑通
真实测试中观察到的限制:
- 某些云端 skill 自身还依赖外部前置条件
- 例如:`tmux``ralphy`、ACP runtime
- 例如:浏览器自动化和登录状态
- 这种情况下,desktop 到 runtime skill 的链路本身是通的,但 skill 自己的运行流程仍可能因为环境问题而失败或卡住
这属于 skill 内容或 runtime 环境问题,不属于云端下载链路问题。
## 10. 当前限制
### 10.1 现在是 prompt 改写,不是协议级 skill 执行
当前实现没有在 gateway 协议里显式传一个专门的 `skillPath``skillId` 执行契约。
现在依赖的是:
- 先把 runtime skill 投影到本地
- 再通过 prompt 改写,要求 OpenClaw 去使用它
这在当前版本能工作,但不是长期来看最干净的执行契约。
### 10.2 OpenClaw 的 skill 发现路径不在 QianjiangClaw 自己的 userData 里
当前 runtime bridge 需要根据 `runtime-manifest.json`,把 skill 投影到 OpenClaw 自己真正使用的 skill 根目录里。
这意味着:
- 当前执行链依赖 bundled OpenClaw 如何解析 skill 目录
- 如果上游 OpenClaw 后续修改了 skill 发现逻辑,这里的 bridge 可能也要跟着调整
### 10.3 还没有做 skill 前置依赖预检
当前 desktop 链路还没有正式的 skill 依赖预检。
例如:
- `tmux`
- `ralphy`
- ACP runtime backend
- 浏览器自动化登录状态
建议后续补充:
- 对已知依赖做 preflight 校验
- 缺依赖时快速失败并明确提示
## 11. 建议的后续工作
### 11.1 增加协议级 skill 执行契约
可能方向:
- 在 gateway 或 chat 协议中增加结构化的 selected skill 元数据
- 让 runtime 通过正式结构化契约解析本地 skill 执行,而不是继续依赖 prompt 改写
### 11.2 增加 skill 前置检查
例如:
- 依赖工具可用性
- 浏览器自动化可用性
- 需要登录的能力是否就绪
- ACP runtime 是否可用
### 11.3 增加专门的 smoke 覆盖
建议拆成几层:
- cloud config fetch smoke
- 本地下载与 manifest smoke
- runtime materialization smoke
- 针对简单无交互 skill 的端到端执行 smoke
## 12. 关键文件
后续开发重点关注这些文件:
- `apps/desktop/src/main/index.ts`
- `apps/desktop/src/main/ipc.ts`
- `apps/desktop/src/main/services/cloud-api.ts`
- `apps/desktop/src/main/services/skill-store.ts`
- `apps/desktop/src/main/services/skill-client.ts`
- `apps/desktop/src/main/services/runtime-skill-bridge.ts`
- `apps/desktop/src/main/services/runtime-cloud-supervisor.ts`
- `apps/ui/src/App.tsx`
- `packages/shared-types/src/index.ts`
## 13. 一句话理解
云端返回 skill 元数据和下载地址,desktop 把 skill 下载到本地缓存,聊天选中 skill 后,Main 再把这份本地缓存投影到 OpenClaw 真正的 skill 目录里,最后由 bundled runtime 执行这个本地投影出来的 skill。
...@@ -54,6 +54,7 @@ export type RuntimeTelemetryState = "idle" | "running" | "stopped" | "error"; ...@@ -54,6 +54,7 @@ export type RuntimeTelemetryState = "idle" | "running" | "stopped" | "error";
export type RuntimeCloudEventType = "startup" | "shutdown" | "message_sent" | "message_received" | "error" | "config_updated"; export type RuntimeCloudEventType = "startup" | "shutdown" | "message_sent" | "message_received" | "error" | "config_updated";
export type PluginStatus = "included" | "extension" | "unavailable"; export type PluginStatus = "included" | "extension" | "unavailable";
export type ChatLaunchState = "unbound" | "starting" | "ready" | "error"; export type ChatLaunchState = "unbound" | "starting" | "ready" | "error";
export type SkillDownloadState = "pending" | "downloading" | "ready" | "failed" | "removed";
export interface GatewayStatus { export interface GatewayStatus {
state: GatewayState; state: GatewayState;
...@@ -200,6 +201,12 @@ export interface WorkspaceSkillSummary { ...@@ -200,6 +201,12 @@ export interface WorkspaceSkillSummary {
description: string; description: string;
category: string; category: string;
enabled: boolean; enabled: boolean;
ready: boolean;
downloadState: SkillDownloadState;
fileName?: string;
fileSize?: number;
lastSyncedAt?: string;
lastError?: string;
} }
export interface PluginSummary { export interface PluginSummary {
...@@ -388,6 +395,12 @@ export interface SkillSummary { ...@@ -388,6 +395,12 @@ export interface SkillSummary {
description: string; description: string;
category: string; category: string;
enabled: boolean; enabled: boolean;
ready: boolean;
downloadState: SkillDownloadState;
fileName?: string;
fileSize?: number;
lastSyncedAt?: string;
lastError?: string;
requiresCredits?: number; requiresCredits?: number;
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment