feat(client): enrich digital skill catalog

This commit is contained in:
baiyanyun
2026-06-07 20:04:38 +08:00
parent 0f0bffe695
commit ee51fcdbeb
12 changed files with 643 additions and 263 deletions

View File

@@ -3,7 +3,6 @@ import type { HandlerContext } from "@shared/types/ipc";
import { FEATURES } from "@shared/featureFlags";
import { agentService } from "../services/engines/unifiedAgent";
import { mcpProxyManager } from "../services/packages/mcp";
import type { McpServerEntry } from "../services/packages/mcp";
import { getComputerServerStatus } from "../services/computerServer";
import { getGuiAgentServerStatus } from "../services/packages/guiAgentServer";
import { getWindowsMcpStatus } from "../services/packages/windowsMcp";
@@ -25,6 +24,7 @@ import {
getDigitalEmployeeSyncStatus,
} from "../services/digitalEmployee/syncService";
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
import { listDigitalEmployeeSkillCapabilities } from "../services/digitalEmployee/skillCatalogService";
export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
ipcMain.handle("digitalEmployee:getSnapshot", async () => {
@@ -52,7 +52,7 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
});
ipcMain.handle("digitalEmployee:getSkillCapabilities", async () => {
return buildSkillCapabilities();
return listDigitalEmployeeSkillCapabilities();
});
ipcMain.handle("digitalEmployee:getPlanTemplates", async () => {
@@ -82,135 +82,6 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
);
}
interface DigitalEmployeeSkillCapability {
skill_id: string;
name: string;
version: string;
enabled: boolean;
description: string;
category: string;
capability_level: "core" | "standard" | "specialized" | "experimental";
source: "qimingclaw-mcp";
server_id: string;
transport: "stdio" | "remote";
running: boolean;
tools?: string[];
tool_count?: number;
allow_tools?: string[];
deny_tools?: string[];
}
type McpServerEntryWithRuntimeFields = McpServerEntry & {
enabled?: boolean;
discoveredTools?: string[];
};
function buildSkillCapabilities(): {
generatedAt: string;
skills: DigitalEmployeeSkillCapability[];
} {
const status = mcpProxyManager.getStatus();
const config = mcpProxyManager.getConfig();
const skills: DigitalEmployeeSkillCapability[] = [];
for (const [serverId, rawEntry] of Object.entries(config.mcpServers || {})) {
const entry = rawEntry as McpServerEntryWithRuntimeFields;
const enabled = entry.enabled !== false;
const transport = "url" in entry ? "remote" : "stdio";
const discoveredTools = normalizeToolList(entry.discoveredTools);
const allowedTools = normalizeToolList(entry.allowTools);
const deniedTools = normalizeToolList(entry.denyTools);
const effectiveTools = chooseEffectiveTools(
discoveredTools,
allowedTools,
deniedTools,
);
skills.push({
skill_id: `qimingclaw-mcp-server-${slugifySkillPart(serverId)}`,
name: `MCP 服务:${serverId}`,
version: "1.0.0",
enabled,
description: effectiveTools.length > 0
? `来自 qimingclaw MCP 配置,当前可用于数字员工编排的工具数:${effectiveTools.length}`
: "来自 qimingclaw MCP 配置,可作为数字员工的外部工具服务。",
category: "MCP",
capability_level: "specialized",
source: "qimingclaw-mcp",
server_id: serverId,
transport,
running: status.running,
tools: effectiveTools,
tool_count: effectiveTools.length,
...(allowedTools.length > 0 ? { allow_tools: allowedTools } : {}),
...(deniedTools.length > 0 ? { deny_tools: deniedTools } : {}),
});
for (const toolName of effectiveTools.slice(0, 120)) {
skills.push({
skill_id: `qimingclaw-mcp-tool-${slugifySkillPart(serverId)}-${slugifySkillPart(toolName)}`,
name: `MCP 工具:${toolName}`,
version: "1.0.0",
enabled,
description: `${serverId} 提供,可被 qimingclaw Agent 调用并纳入数字员工任务编排。`,
category: "MCP 工具",
capability_level: "standard",
source: "qimingclaw-mcp",
server_id: serverId,
transport,
running: status.running,
tools: [toolName],
tool_count: 1,
});
}
}
return {
generatedAt: new Date().toISOString(),
skills,
};
}
function normalizeToolList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value.filter((item): item is string => (
typeof item === "string" && item.trim().length > 0
)).map((item) => item.trim()))];
}
function chooseEffectiveTools(
discoveredTools: string[],
allowedTools: string[],
deniedTools: string[],
): string[] {
const baseTools = discoveredTools.length > 0 ? discoveredTools : allowedTools;
if (baseTools.length === 0) return [];
if (allowedTools.length > 0) {
const allowed = new Set(allowedTools);
return baseTools.filter((tool) => allowed.has(tool));
}
if (deniedTools.length > 0) {
const denied = new Set(deniedTools);
return baseTools.filter((tool) => !denied.has(tool));
}
return baseTools;
}
function slugifySkillPart(value: string): string {
const slug = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (slug) return slug;
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return `capability-${hash.toString(36)}`;
}
function buildSnapshot(ctx: HandlerContext): DigitalEmployeeSnapshot {
const sessions = agentService.listAllSessionsDetailed();
return {

View File

@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMcp = vi.hoisted(() => ({
status: {
running: true,
serverCount: 1,
serverNames: ["crm"],
},
config: {
mcpServers: {
crm: {
command: "node",
args: ["crm-mcp.js"],
discoveredTools: ["crm.search", "crm.export", "crm.delete"],
allowTools: ["crm.search", "crm.export"],
denyTools: ["crm.delete"],
},
},
},
}));
const mockRuntime = vi.hoisted(() => ({
records: {
generatedAt: "2026-06-07T08:00:00.000Z",
plans: [],
tasks: [],
runs: [],
artifacts: [],
approvals: [],
events: [
{
id: "event-1",
kind: "computer_tool_call",
message: "工具调用开始crm.search",
payload: {
toolName: "crm.search",
projectId: "project-a",
sessionId: "session-a",
status: "running",
},
occurredAt: "2026-06-07T07:59:00.000Z",
},
],
},
}));
vi.mock("../packages/mcp", () => ({
mcpProxyManager: {
getStatus: () => mockMcp.status,
getConfig: () => mockMcp.config,
},
}));
vi.mock("./stateService", () => ({
readDigitalEmployeeRuntimeRecords: () => mockRuntime.records,
}));
describe("digital employee skill catalog service", () => {
beforeEach(() => {
vi.resetModules();
});
it("projects MCP servers and tools with governance and recent calls", async () => {
const { listDigitalEmployeeSkillCapabilities } = await import("./skillCatalogService");
const result = listDigitalEmployeeSkillCapabilities();
expect(result.skills).toEqual([
expect.objectContaining({
skill_id: "qimingclaw-mcp-server-crm",
server_id: "crm",
running: true,
tool_count: 2,
tools: ["crm.search", "crm.export"],
allow_tools: ["crm.search", "crm.export"],
deny_tools: ["crm.delete"],
governance: expect.objectContaining({
effective_policy: "allow",
server_allow_tools: ["crm.search", "crm.export"],
server_deny_tools: ["crm.delete"],
}),
recent_calls: [expect.objectContaining({ tool_name: "crm.search" })],
project_ids: ["project-a"],
}),
expect.objectContaining({
skill_id: "qimingclaw-mcp-tool-crm-crm-search",
tools: ["crm.search"],
recent_calls: [expect.objectContaining({ event_id: "event-1" })],
}),
expect.objectContaining({
skill_id: "qimingclaw-mcp-tool-crm-crm-export",
tools: ["crm.export"],
recent_calls: [],
}),
]);
});
});

View File

@@ -0,0 +1,276 @@
import { mcpProxyManager } from "../packages/mcp";
import type { McpServerEntry } from "../packages/mcp";
import { readDigitalEmployeeRuntimeRecords } from "./stateService";
export interface DigitalEmployeeSkillRecentCall {
event_id: string;
kind: string;
message: string;
status?: string | null;
occurred_at: string;
tool_name?: string | null;
project_id?: string | null;
session_id?: string | null;
}
export interface DigitalEmployeeSkillGovernance {
effective_policy: "allow" | "deny" | "open" | "disabled";
server_enabled: boolean;
global_allow_tools: string[];
global_deny_tools: string[];
server_allow_tools: string[];
server_deny_tools: string[];
}
export interface DigitalEmployeeSkillCapability {
skill_id: string;
name: string;
version: string;
enabled: boolean;
description: string;
category: string;
capability_level: "core" | "standard" | "specialized" | "experimental";
source: "qimingclaw-mcp";
server_id: string;
transport: "stdio" | "remote";
running: boolean;
governance: DigitalEmployeeSkillGovernance;
recent_calls: DigitalEmployeeSkillRecentCall[];
call_count: number;
last_called_at?: string | null;
tools?: string[];
tool_count?: number;
allow_tools?: string[];
deny_tools?: string[];
project_ids?: string[];
}
type McpServerEntryWithRuntimeFields = McpServerEntry & {
enabled?: boolean;
discoveredTools?: string[];
};
export function listDigitalEmployeeSkillCapabilities(): {
generatedAt: string;
skills: DigitalEmployeeSkillCapability[];
} {
const status = mcpProxyManager.getStatus();
const config = mcpProxyManager.getConfig();
const runtime = readDigitalEmployeeRuntimeRecords(100);
const calls = runtime.events
.map(toRecentCall)
.filter((call): call is DigitalEmployeeSkillRecentCall => Boolean(call));
const skills: DigitalEmployeeSkillCapability[] = [];
const globalAllowTools = normalizeToolList(config.allowTools);
const globalDenyTools = normalizeToolList(config.denyTools);
for (const [serverId, rawEntry] of Object.entries(config.mcpServers || {})) {
const entry = rawEntry as McpServerEntryWithRuntimeFields;
const enabled = entry.enabled !== false;
const transport = "url" in entry ? "remote" : "stdio";
const discoveredTools = normalizeToolList(entry.discoveredTools);
const serverAllowTools = normalizeToolList(entry.allowTools);
const serverDenyTools = normalizeToolList(entry.denyTools);
const effectiveAllowedTools = serverAllowTools.length > 0 ? serverAllowTools : globalAllowTools;
const effectiveDeniedTools = serverDenyTools.length > 0 ? serverDenyTools : globalDenyTools;
const effectiveTools = chooseEffectiveTools(
discoveredTools,
effectiveAllowedTools,
effectiveDeniedTools,
);
const governance = buildGovernance(
enabled,
globalAllowTools,
globalDenyTools,
serverAllowTools,
serverDenyTools,
);
const serverCalls = recentCallsForServer(calls, serverId, effectiveTools);
skills.push({
skill_id: `qimingclaw-mcp-server-${slugifySkillPart(serverId)}`,
name: `MCP 服务:${serverId}`,
version: "1.0.0",
enabled,
description: effectiveTools.length > 0
? `来自 qimingclaw MCP 配置,当前可用于数字员工编排的工具数:${effectiveTools.length}`
: "来自 qimingclaw MCP 配置,可作为数字员工的外部工具服务。",
category: "MCP",
capability_level: "specialized",
source: "qimingclaw-mcp",
server_id: serverId,
transport,
running: status.running,
governance,
recent_calls: serverCalls.slice(0, 5),
call_count: serverCalls.length,
last_called_at: serverCalls[0]?.occurred_at ?? null,
tools: effectiveTools,
tool_count: effectiveTools.length,
project_ids: projectIds(serverCalls),
...(effectiveAllowedTools.length > 0 ? { allow_tools: effectiveAllowedTools } : {}),
...(effectiveDeniedTools.length > 0 ? { deny_tools: effectiveDeniedTools } : {}),
});
for (const toolName of effectiveTools.slice(0, 120)) {
const toolCalls = recentCallsForTool(calls, toolName);
skills.push({
skill_id: `qimingclaw-mcp-tool-${slugifySkillPart(serverId)}-${slugifySkillPart(toolName)}`,
name: `MCP 工具:${toolName}`,
version: "1.0.0",
enabled,
description: `${serverId} 提供,可被 qimingclaw Agent 调用并纳入数字员工任务编排。`,
category: "MCP 工具",
capability_level: "standard",
source: "qimingclaw-mcp",
server_id: serverId,
transport,
running: status.running,
governance,
recent_calls: toolCalls.slice(0, 5),
call_count: toolCalls.length,
last_called_at: toolCalls[0]?.occurred_at ?? null,
tools: [toolName],
tool_count: 1,
project_ids: projectIds(toolCalls),
...(effectiveAllowedTools.length > 0 ? { allow_tools: effectiveAllowedTools } : {}),
...(effectiveDeniedTools.length > 0 ? { deny_tools: effectiveDeniedTools } : {}),
});
}
}
return {
generatedAt: new Date().toISOString(),
skills,
};
}
function buildGovernance(
enabled: boolean,
globalAllowTools: string[],
globalDenyTools: string[],
serverAllowTools: string[],
serverDenyTools: string[],
): DigitalEmployeeSkillGovernance {
return {
effective_policy: !enabled
? "disabled"
: serverAllowTools.length > 0 || globalAllowTools.length > 0
? "allow"
: serverDenyTools.length > 0 || globalDenyTools.length > 0
? "deny"
: "open",
server_enabled: enabled,
global_allow_tools: globalAllowTools,
global_deny_tools: globalDenyTools,
server_allow_tools: serverAllowTools,
server_deny_tools: serverDenyTools,
};
}
function normalizeToolList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value.filter((item): item is string => (
typeof item === "string" && item.trim().length > 0
)).map((item) => item.trim()))];
}
function chooseEffectiveTools(
discoveredTools: string[],
allowedTools: string[],
deniedTools: string[],
): string[] {
const baseTools = discoveredTools.length > 0 ? discoveredTools : allowedTools;
if (baseTools.length === 0) return [];
if (allowedTools.length > 0) {
const allowed = new Set(allowedTools);
return baseTools.filter((tool) => allowed.has(tool));
}
if (deniedTools.length > 0) {
const denied = new Set(deniedTools);
return baseTools.filter((tool) => !denied.has(tool));
}
return baseTools;
}
function toRecentCall(event: {
id: string;
kind: string;
message: string;
payload: unknown;
occurredAt: string;
}): DigitalEmployeeSkillRecentCall | null {
const payload = objectRecord(event.payload);
const toolName = stringValue(payload?.toolName)
|| stringValue(payload?.tool_name)
|| toolNameFromMessage(event.message);
if (!toolName) return null;
return {
event_id: event.id,
kind: event.kind,
message: event.message,
status: stringValue(payload?.status) || null,
occurred_at: event.occurredAt,
tool_name: toolName,
project_id: stringValue(payload?.projectId ?? payload?.project_id) || null,
session_id: stringValue(payload?.sessionId ?? payload?.session_id) || null,
};
}
function recentCallsForServer(
calls: DigitalEmployeeSkillRecentCall[],
serverId: string,
tools: string[],
): DigitalEmployeeSkillRecentCall[] {
const toolSet = new Set(tools.map((tool) => tool.toLowerCase()));
const serverNeedle = serverId.toLowerCase();
return calls.filter((call) => {
const toolName = call.tool_name?.toLowerCase() ?? "";
if (toolSet.size > 0 && toolSet.has(toolName)) return true;
return Boolean(toolName && (toolName.includes(serverNeedle) || serverNeedle.includes(toolName)));
});
}
function recentCallsForTool(
calls: DigitalEmployeeSkillRecentCall[],
toolName: string,
): DigitalEmployeeSkillRecentCall[] {
const needle = toolName.toLowerCase();
return calls.filter((call) => call.tool_name?.toLowerCase() === needle);
}
function projectIds(calls: DigitalEmployeeSkillRecentCall[]): string[] {
return [...new Set(calls
.map((call) => call.project_id)
.filter((projectId): projectId is string => Boolean(projectId)))];
}
function toolNameFromMessage(message: string): string | null {
const match = message.match(/工具调用(?:开始|更新)(.+)$/);
return match?.[1]?.trim() || null;
}
function stringValue(value: unknown): string {
return typeof value === "string" && value.trim() ? value.trim() : "";
}
function objectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function slugifySkillPart(value: string): string {
const slug = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (slug) return slug;
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return `capability-${hash.toString(36)}`;
}