feat(client): enrich digital skill catalog
This commit is contained in:
@@ -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: [],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user