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

skill链路打通

parent 9ae4391b
......@@ -7,13 +7,16 @@ import type { RuntimeModePreference, SystemSummary } from "@qjclaw/shared-types"
import { createMainWindow } from "./create-window.js";
import { registerDesktopIpc } from "./ipc.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 { DiagnosticsService } from "./services/diagnostics.js";
import { loadLocalOpenClawGatewayConfig, resolveEffectiveGatewayUrl } from "./services/openclaw-local-config.js";
import { SecretManager } from "./services/secrets.js";
import { startSmokeCloudApiServer } from "./services/smoke-cloud-api.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 {
usingMockApi: boolean;
......@@ -262,7 +265,8 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
result.initialState = initialState;
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");
const sendResult = await window.webContents.executeJavaScript(`(async () => {
const api = window.qjcDesktop;
......@@ -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 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 preferredSkillId = ${JSON.stringify(process.env.QJCLAW_SMOKE_SKILL_ID?.trim() ?? "")};
if (smokeBaseUrl) {
const current = await api.config.load();
await api.config.save({
......@@ -332,7 +337,12 @@ async function runSmokeTest(window: BrowserWindow, outputPath: string): Promise<
const credits = session.state === "authenticated" ? await api.credits.getSummary() : null;
const skills = session.state === "authenticated" ? await api.skills.list() : [];
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 sessionId = state?.activeSessionId || sessions[0]?.id || "desktop-main";
const system = await api.system.getSummary();
......@@ -473,6 +483,10 @@ async function bootstrap(): Promise<void> {
await deviceIdentityService.load();
const localOpenClawConfig = await loadLocalOpenClawGatewayConfig();
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({
vendorRuntimeDir: resolveVendorRuntimeDir(systemSummary),
......@@ -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 profileClient = new ProfileClient(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 runtimeCloudSupervisor = new RuntimeCloudSupervisor({
appVersion: app.getVersion(),
......@@ -530,9 +547,11 @@ async function bootstrap(): Promise<void> {
profileClient,
creditClient,
skillClient,
skillStore,
modelConfigClient,
runtimeCloudClient,
runtimeCloudSupervisor,
runtimeSkillBridge,
systemSummary,
localOpenClawConfig
});
......@@ -548,6 +567,7 @@ async function bootstrap(): Promise<void> {
void (async () => {
await runtimeCloudSupervisor.stop("app-before-quit");
await runtimeManager.stop();
await runtimeSkillBridge.clearManagedSkills().catch(() => undefined);
if (stopSmokeCloudApiServer) {
await stopSmokeCloudApiServer();
stopSmokeCloudApiServer = undefined;
......
......@@ -17,11 +17,14 @@ import {
import type { GatewayClient } from "@qjclaw/gateway-client";
import type { RuntimeManager } from "@qjclaw/runtime-manager";
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 { 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 type { SecretManager } from "./services/secrets.js";
import type { RuntimeCloudSupervisor } from "./services/runtime-cloud-supervisor.js";
import type { RuntimeSkillBridgeService } from "./services/runtime-skill-bridge.js";
interface MainServices {
configService: AppConfigService;
......@@ -33,9 +36,11 @@ interface MainServices {
profileClient: ProfileClient;
creditClient: CreditClient;
skillClient: SkillClient;
skillStore: SkillStoreService;
modelConfigClient: ModelConfigClient;
runtimeCloudClient: OpenClawConfigClient;
runtimeCloudSupervisor: RuntimeCloudSupervisor;
runtimeSkillBridge: RuntimeSkillBridgeService;
appVersion: string;
systemSummary: SystemSummary;
localOpenClawConfig?: LocalOpenClawGatewayConfig | null;
......@@ -190,9 +195,11 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
profileClient,
secretManager,
skillClient,
skillStore,
modelConfigClient,
runtimeCloudClient,
runtimeCloudSupervisor,
runtimeSkillBridge,
systemSummary,
localOpenClawConfig
} = services;
......@@ -258,7 +265,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
? await gatewayClient.status().catch(() => null)
: null;
const chatSummary = buildChatSummary(runtimeStatus, runtimeCloudStatus, gatewayStatus);
const skills = await runtimeCloudClient.getWorkspaceSkills();
const skills = await skillStore.listWorkspaceSkills();
return {
apiKeyConfigured: runtimeCloudStatus.apiKeyConfigured,
......@@ -341,7 +348,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
const config = await getEffectiveConfig();
const [runtimeCloudStatus, skills] = await Promise.all([
runtimeCloudClient.getStatus(),
runtimeCloudClient.getWorkspaceSkills()
skillStore.listWorkspaceSkills()
]);
const selectedSkill = skillId ? skills.find((skill) => skill.id === skillId) : undefined;
const configuredModelId = runtimeCloudStatus.config?.modelId;
......@@ -378,6 +385,21 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
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.gatewayStatus, async () => gatewayClient.status());
ipcMain.handle(IPC_CHANNELS.gatewayConnect, async () => gatewayClient.connect());
......@@ -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.chatSendPrompt, async (_event, sessionId: string, prompt: string, skillId?: string) => {
const executionPolicy = await resolveExecutionPolicy(skillId);
const gatewayPrompt = await prepareGatewayPrompt(prompt, skillId);
runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, skillId);
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);
return { ...result, executionPolicy };
} catch (error) {
......@@ -478,6 +501,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
});
ipcMain.handle(IPC_CHANNELS.chatStreamPrompt, async (event, sessionId: string, prompt: string, skillId?: string) => {
const executionPolicy = await resolveExecutionPolicy(skillId);
const gatewayPrompt = await prepareGatewayPrompt(prompt, skillId);
const requestId = randomUUID();
let settled = false;
let ready = false;
......@@ -497,7 +521,7 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, skillId);
try {
const stream = await gatewayClient.streamPrompt(sessionId, prompt, {
const stream = await gatewayClient.streamPrompt(sessionId, gatewayPrompt, {
onStarted: ({ sessionId: nextSessionId, runId }) => {
queueOrSend({
type: "started",
......@@ -675,9 +699,10 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
listMessages: (sessionId: string) => gatewayClient.listMessages(sessionId),
sendPrompt: async (sessionId: string, prompt: string, skillId?: string) => {
const executionPolicy = await resolveExecutionPolicy(skillId);
const gatewayPrompt = await prepareGatewayPrompt(prompt, skillId);
runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, skillId);
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);
return { ...result, executionPolicy };
} catch (error) {
......@@ -691,7 +716,8 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
},
streamPrompt: async (sessionId: string, prompt: string, skillId?: string) => {
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 {
requestId: randomUUID(),
sessionId: stream.sessionId,
......
......@@ -13,10 +13,10 @@ import type {
RuntimeCloudStatus,
SkillModelBindingMode,
SkillSummary,
UserProfileSummary,
WorkspaceSkillSummary
UserProfileSummary
} from "@qjclaw/shared-types";
import type { AppConfigService } from "./app-config.js";
import type { RemoteSkillAsset } from "./skill-store.js";
import type { SecretManager } from "./secrets.js";
interface SessionPayload {
......@@ -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) => ({
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}`,
description: binding.skill?.description ?? 'Enabled for this employee and ready to use in chat.',
category: binding.skill?.category ?? 'general',
enabled: true
description: binding.skill?.description ?? "Enabled for this employee and ready to use in chat.",
category: binding.skill?.category ?? "general",
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 {
request(url: URL, options: { method: "GET" | "POST"; headers?: Record<string, string>; body?: unknown }): Promise<string> {
const client = url.protocol === "https:" ? https : http;
......@@ -371,6 +380,8 @@ class ProductCloudApiClient {
description: item.description ?? "No description provided.",
category: item.category ?? "general",
enabled: item.enabled ?? true,
ready: item.enabled ?? true,
downloadState: "ready",
requiresCredits: item.requiresCredits
}));
}
......@@ -451,6 +462,7 @@ export class OpenClawConfigClient {
private readonly configService: AppConfigService;
private readonly secretManager: SecretManager;
private readonly httpClient = new HttpJsonClient();
private readonly payloadListeners = new Set<RuntimeCloudPayloadListener>();
private payloadCache: OpenClawEmployeeConfigPayload | null = null;
private statusCache: RuntimeCloudStatus = {
state: "unconfigured",
......@@ -483,8 +495,15 @@ export class OpenClawConfigClient {
return this.mergeConfig(defaultConfig, payload);
}
async getWorkspaceSkills(): Promise<WorkspaceSkillSummary[]> {
return toWorkspaceSkillSummaries(this.payloadCache);
getRemoteSkillAssets(): RemoteSkillAsset[] {
return toRemoteSkillAssets(this.payloadCache);
}
onPayloadUpdated(listener: RuntimeCloudPayloadListener): () => void {
this.payloadListeners.add(listener);
return () => {
this.payloadListeners.delete(listener);
};
}
private async fetchPayload(action: RuntimeCloudFetchAction): Promise<OpenClawEmployeeConfigPayload> {
......@@ -553,6 +572,7 @@ export class OpenClawConfigClient {
config: summary,
lastError: undefined
};
await this.notifyPayloadUpdated(action, summary);
return payload;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
......@@ -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 {
this.statusCache = {
...this.statusCache,
......@@ -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 {
private readonly api: ProductCloudApiClient;
......@@ -780,3 +791,4 @@ export class ModelConfigClient {
}
......@@ -50,7 +50,7 @@ interface RuntimeCloudSupervisorOptions {
}
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_BATCH_SIZE = 20;
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 = {
name: "默认对话",
description: "通用对话技能",
category: "通用",
enabled: true
enabled: true,
ready: true,
downloadState: "ready" as const
};
const ui = {
......@@ -190,8 +192,8 @@ const mockDesktopApi = {
runtimeMessage: "mock",
skillCount: 2,
skills: [
{ id: "sheet", name: "表格工具", description: "处理电子表格和数据统计。", category: "办公", enabled: true },
{ id: "doc", 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: "Document Tools", description: "Process documents and content organization.", category: "office", enabled: true, ready: true, downloadState: "ready", fileName: "doc.md" }
],
plugins: [
{ id: "spreadsheet-tools", name: "表格工具", description: "读取、统计和处理 Excel、CSV 等常见表格文件。", status: "included", includedByDefault: true },
......@@ -320,6 +322,23 @@ function getPluginCopy(plugin: WorkspaceSummary["plugins"][number]) {
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) {
if (!runtimeStatus || gatewayStatus?.state !== "connected") {
return false;
......@@ -351,7 +370,9 @@ export default function App() {
const activeStreamRef = useRef<ActiveStreamState | 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 chatLaunchState: ChatLaunchState = workspace?.chatLaunchState ?? (workspace?.apiKeyConfigured ? "starting" : "unbound");
const chatStatusMessage = workspace?.chatStatusMessage ?? (chatLaunchState === "starting" ? ui.startingHint : chatLaunchState === "error" ? ui.chatNotReadyError : "");
......@@ -426,7 +447,8 @@ export default function App() {
setWorkspacePathDraft((current) => current || nextConfig.workspacePath);
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)) {
setSelectedSkillId(nextSkills[0].id);
}
......@@ -1032,7 +1054,7 @@ export default function App() {
</div>
</section>
) : 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 === "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>
......
This diff is collapsed.
This diff is collapsed.
......@@ -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 PluginStatus = "included" | "extension" | "unavailable";
export type ChatLaunchState = "unbound" | "starting" | "ready" | "error";
export type SkillDownloadState = "pending" | "downloading" | "ready" | "failed" | "removed";
export interface GatewayStatus {
state: GatewayState;
......@@ -200,6 +201,12 @@ export interface WorkspaceSkillSummary {
description: string;
category: string;
enabled: boolean;
ready: boolean;
downloadState: SkillDownloadState;
fileName?: string;
fileSize?: number;
lastSyncedAt?: string;
lastError?: string;
}
export interface PluginSummary {
......@@ -388,6 +395,12 @@ export interface SkillSummary {
description: string;
category: string;
enabled: boolean;
ready: boolean;
downloadState: SkillDownloadState;
fileName?: string;
fileSize?: number;
lastSyncedAt?: string;
lastError?: string;
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