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

@@ -978,6 +978,9 @@
.de-skill-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.de-skill-name { font-size: 15px; font-weight: 600; color: var(--de-text-primary); }
.de-skill-desc { font-size: 13px; color: var(--de-text-body); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; flex: 1; }
.de-skill-meta-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-height: 24px; }
.de-skill-meta-chip { display: inline-flex; align-items: center; max-width: 160px; min-height: 22px; padding: 0 8px; border-radius: 999px; border: 1px solid rgba(79,172,254,0.14); background: rgba(79,172,254,0.08); color: #41627f; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.de-skill-recent { margin: 0; font-size: 12px; color: var(--de-text-muted); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.de-skill-card-foot { display: flex; align-items: center; justify-content: space-between; }
.de-skill-version { font-size: 11px; color: var(--de-text-muted); }
.de-skill-status { font-size: 11px; font-weight: 600; }

View File

@@ -70,6 +70,24 @@ export interface SkillSummary {
category: string;
capabilityLevel: 'core' | 'standard' | 'specialized' | 'experimental';
source: 'skill-api';
serverId?: string | null;
transport?: string | null;
running?: boolean | null;
toolCount?: number | null;
tools?: string[];
allowTools?: string[];
denyTools?: string[];
governancePolicy?: 'allow' | 'deny' | 'open' | 'disabled' | null;
recentCalls?: Array<{
eventId: string;
message: string;
status?: string | null;
occurredAt: string;
toolName?: string | null;
projectId?: string | null;
}>;
callCount?: number;
lastCalledAt?: string | null;
}
export interface DataSourceStatus {
@@ -87,6 +105,10 @@ function arr(v: unknown): Obj[] {
return Array.isArray(v) ? (v as Obj[]) : [];
}
function obj(v: unknown): Obj | null {
return v && typeof v === 'object' && !Array.isArray(v) ? v as Obj : null;
}
function str(v: unknown, fallback = ''): string {
if (typeof v === 'string') return v;
if (typeof v === 'number') return String(v);
@@ -456,6 +478,7 @@ export function adaptSkillSummaries(skillsData: Obj[] | null): SkillSummary[] {
if (!skillsData) return [];
return skillsData.map((s: Obj) => {
const sid = str(s.skill_id, str(s.id));
const governance = obj(s.governance);
return {
id: sid,
name: zhName(sid, str(s.name, sid)),
@@ -465,10 +488,51 @@ export function adaptSkillSummaries(skillsData: Obj[] | null): SkillSummary[] {
category: s.category ? str(s.category, categoryFor(sid)) : categoryFor(sid),
capabilityLevel: capabilityLevelFor(s.capability_level ?? s.capabilityLevel),
source: 'skill-api' as const,
serverId: s.server_id ? str(s.server_id) : null,
transport: s.transport ? str(s.transport) : null,
running: typeof s.running === 'boolean' ? s.running : null,
toolCount: num(s.tool_count),
tools: stringList(s.tools),
allowTools: stringList(s.allow_tools ?? governance?.server_allow_tools ?? governance?.global_allow_tools),
denyTools: stringList(s.deny_tools ?? governance?.server_deny_tools ?? governance?.global_deny_tools),
governancePolicy: governancePolicy(s.governance_policy ?? governance?.effective_policy),
recentCalls: arr(s.recent_calls).map((call) => {
const c = obj(call) ?? {};
return {
eventId: str(c.event_id, str(c.id)),
message: str(c.message),
status: c.status ? str(c.status) : null,
occurredAt: str(c.occurred_at, str(c.occurredAt)),
toolName: c.tool_name ? str(c.tool_name) : null,
projectId: c.project_id ? str(c.project_id) : null,
};
}).filter((call) => call.eventId || call.message),
callCount: num(s.call_count) ?? 0,
lastCalledAt: s.last_called_at ? str(s.last_called_at) : null,
};
});
}
function stringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0).map((item) => item.trim());
}
function num(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function governancePolicy(value: unknown): SkillSummary['governancePolicy'] {
return value === 'allow' || value === 'deny' || value === 'open' || value === 'disabled'
? value
: null;
}
export function adaptJournalEntries(storeData: Obj | null): JournalEntry[] {
if (!storeData) return [];
const tables = (storeData.tables ?? {}) as Obj;

View File

@@ -30,6 +30,13 @@ const cadenceLabels: Record<TaskCadence, string> = {
adhoc: '临时',
};
const policyLabels: Record<NonNullable<SkillSummary['governancePolicy']>, string> = {
allow: '白名单',
deny: '黑名单',
open: '开放',
disabled: '停用',
};
function getCadence(skill: SkillSummary): TaskCadence {
const text = `${skill.id} ${skill.name} ${skill.description} ${skill.category}`.toLowerCase();
if (/weekly|week|周/.test(text)) return 'weekly';
@@ -71,6 +78,26 @@ function learningStatus(skill: SkillSummary, recommended: boolean, advanced: boo
return '可学习';
}
function skillMetaBadges(skill: SkillSummary): string[] {
const badges: string[] = [];
if (skill.serverId) badges.push(skill.serverId);
if (skill.running !== null && skill.running !== undefined) badges.push(skill.running ? '运行中' : '未运行');
if (skill.governancePolicy) badges.push(policyLabels[skill.governancePolicy]);
if (typeof skill.toolCount === 'number') badges.push(`${skill.toolCount} 工具`);
if (skill.allowTools?.length) badges.push(`allow ${skill.allowTools.length}`);
if (skill.denyTools?.length) badges.push(`deny ${skill.denyTools.length}`);
return badges.slice(0, 5);
}
function recentCallText(skill: SkillSummary): string | null {
const call = skill.recentCalls?.[0];
if (!call) return typeof skill.callCount === 'number' && skill.callCount > 0
? `${skill.callCount} 次调用`
: null;
const tool = call.toolName ? `${call.toolName} · ` : '';
return `${tool}${call.message || call.status || '最近调用'}`;
}
function SkillCard({
skill,
recommended = false,
@@ -90,6 +117,8 @@ function SkillCard({
}) {
const cadence = getCadence(skill);
const status = learningStatus(skill, recommended, advanced);
const metaBadges = skillMetaBadges(skill);
const recentCall = recentCallText(skill);
return (
<div
@@ -112,6 +141,12 @@ function SkillCard({
</span>
</div>
<p className="de-skill-desc">{getOutcome(skill)}</p>
{metaBadges.length > 0 && (
<div className="de-skill-meta-row">
{metaBadges.map((badge) => <span key={badge} className="de-skill-meta-chip">{badge}</span>)}
</div>
)}
{recentCall && <p className="de-skill-recent">{recentCall}</p>}
<div className="de-skill-card-foot">
<span className="de-skill-version">
{cadenceLabels[cadence]}
@@ -205,7 +240,19 @@ export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string |
const filtered = skills
.filter(s => cadenceFilter === 'all' || getCadence(s) === cadenceFilter)
.filter(s => !search || s.name.includes(search) || s.description.includes(search) || s.category.includes(search));
.filter(s => {
if (!search) return true;
const haystack = [
s.name,
s.description,
s.category,
s.serverId,
...(s.tools ?? []),
...(s.allowTools ?? []),
...(s.denyTools ?? []),
].filter(Boolean).join(' ');
return haystack.includes(search);
});
const advancedSkills = filtered.filter(isAdvancedSkill);
const advancedIds = new Set(advancedSkills.map(s => s.id));

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -15,8 +15,8 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-BVS02Noc.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-ReBtI0Fy.css">
<script type="module" crossorigin src="./assets/index-DN59eLPD.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DeYFPN7i.css">
</head>
<body>
<div id="root"></div>

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)}`;
}

View File

@@ -854,11 +854,31 @@ file server tools
数字员工“技能库”展示 qimingclaw Skill Catalog。
当前客户端实现由 `digitalEmployee:getSkillCapabilities` IPC 输出本地 Skill Catalog
embedded digital 页面通过 `window.QimingClawBridge.digital.getSkillCapabilities()`
合并到 `/api/skill`。目录先覆盖 MCP server / tool后续再继续扩展 ACP command、
GUI agent tool 和 file server tool。
MCP 技能字段已包含:
```text
server_id
transport
running
tools / tool_count
allow_tools / deny_tools
governance.effective_policy
recent_calls / call_count / last_called_at
project_ids
```
用户可以:
- 查看技能
- 启用 / 禁用
- 查看所属 MCP server
- 查看 MCP allow / deny 策略
- 查看 MCP proxy 运行状态
- 查看输入输出说明
- 查看最近调用记录
@@ -866,10 +886,12 @@ file server tools
- 工具从“技术配置”变成“数字员工能力”。
- PlanTemplate 可以绑定 Skill。
- MCP server 和工具能按治理策略投射成可扫描的技能卡。
### 验收
- MCP tool 能出现在技能库。
- MCP server 能显示 allow / deny、运行状态和最近调用。
- 禁用技能后PlanTemplate 或任务执行不会再选择它。
## Phase 7Approval / Human-in-the-loop