Files
zn-ai/electron/gateway/skill-capability-parser.ts
DEV_DSW 4c61e93c3e Add unit tests for skill capabilities, skill planner, and UV setup
- Implement tests for random ID generation, ensuring preference for crypto.randomUUID.
- Create tests for runtime context capabilities, validating the injection of enabled skill capabilities.
- Add tests for skill capability parsing, including classification and command example extraction.
- Introduce tests for the skill planner, verifying tool call planning based on user requests and attachment requirements.
- Establish tests for UV setup, ensuring proper handling of Python installation scenarios and environment checks.
2026-04-24 17:02:59 +08:00

499 lines
14 KiB
TypeScript

import type { SkillConfigRecord } from '@electron/utils/skill-config';
import type { ToolRenderHints } from '@runtime/shared/chat-model';
const FRONTMATTER_PATTERN = /^---\s*\r?\n([\s\S]*?)\r?\n---/;
const FILE_EXTENSION_PATTERN = /\.[a-z0-9]{2,8}\b/gi;
const ENV_VAR_PATTERN = /\$\{([A-Z][A-Z0-9_]+)\}/g;
const QUOTED_TEXT_PATTERN = /"([^"\n]{2,80})"|'([^'\n]{2,80})'/g;
const CODE_FENCE_PATTERN = /```([^\n`]*)\r?\n([\s\S]*?)```/g;
const SPREADSHEET_EXTENSIONS = ['.xlsx', '.xls', '.xlsm', '.csv', '.tsv', '.ods'];
const DOCUMENT_EXTENSIONS = ['.pdf', '.docx', '.doc', '.pptx', '.ppt', '.odt', '.odp', '.rtf'];
const COMMON_FILE_EXTENSIONS = [
...SPREADSHEET_EXTENSIONS,
...DOCUMENT_EXTENSIONS,
'.txt',
'.md',
'.json',
'.xml',
'.html',
'.htm',
'.png',
'.jpg',
'.jpeg',
'.gif',
'.bmp',
'.webp',
'.svg',
];
const OPERATION_KEYWORDS: Array<[string, string[]]> = [
['search', ['search', 'look up', 'lookup', 'find', 'discover']],
['research', ['research', 'investigate']],
['extract', ['extract', 'scrape']],
['crawl', ['crawl', 'map']],
['read', ['read', 'open', 'inspect', 'review']],
['analyze', ['analyze', 'analysis', 'summarize', 'statistics']],
['create', ['create', 'build', 'generate', 'produce']],
['edit', ['edit', 'modify', 'update', 'fill cells', 'add formulas']],
['convert', ['convert', 'transform', 'export']],
['validate', ['validate', 'check formulas', 'verify']],
['repair', ['repair', 'fix', 'recover']],
['format', ['format', 'styling', 'style']],
];
export interface SkillCapability {
skillKey: string;
slug: string;
name: string;
description: string;
enabled: boolean;
category: string;
version?: string;
source?: string;
baseDir?: string;
manifestPath?: string;
allowedTools: string[];
operationHints: string[];
triggerHints: string[];
inputExtensions: string[];
requiredEnvVars: string[];
requiresAuth: boolean;
plannerSummary: string;
commandExamples?: string[];
renderHints?: ToolRenderHints;
}
export interface SkillCapabilityParserInput {
skillKey: string;
config?: Partial<SkillConfigRecord> & {
enabled?: boolean;
apiKey?: string;
env?: Record<string, string>;
};
manifestContent?: string | null;
}
type ParsedFrontmatter = {
name?: string;
description?: string;
version?: string;
category?: string;
allowedTools: string[];
};
function uniq(values: Array<string | undefined | null>): string[] {
return Array.from(new Set(values.map((value) => value?.trim()).filter(Boolean) as string[]));
}
function extractFrontmatter(markdown?: string | null): { frontmatter: string; body: string } {
if (!markdown) {
return { frontmatter: '', body: '' };
}
const match = markdown.match(FRONTMATTER_PATTERN);
if (!match) {
return { frontmatter: '', body: markdown };
}
return {
frontmatter: match[1] || '',
body: markdown.slice(match[0].length).trim(),
};
}
function parseScalarFrontmatterValue(frontmatter: string, key: string): string | undefined {
const pattern = new RegExp(`^${key}:\\s*(.+)$`, 'im');
const match = frontmatter.match(pattern);
if (!match?.[1]) {
return undefined;
}
const value = match[1].trim().replace(/^["']|["']$/g, '');
if (value === '|' || value === '>') {
return undefined;
}
return value;
}
function parseNestedFrontmatterValue(frontmatter: string, key: string): string | undefined {
const pattern = new RegExp(`^\\s+${key}:\\s*(.+)$`, 'im');
const match = frontmatter.match(pattern);
if (!match?.[1]) {
return undefined;
}
return match[1].trim().replace(/^["']|["']$/g, '');
}
function parseDescription(frontmatter: string, markdown?: string | null): string | undefined {
const blockMatch = frontmatter.match(/^description:\s*\|\s*\r?\n([\s\S]*?)(?:\r?\n(?:[A-Za-z0-9_-]+:|[A-Za-z0-9_-]+:\s)|$)/im);
if (blockMatch?.[1]) {
return blockMatch[1]
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.join(' ')
.trim();
}
const scalar = parseScalarFrontmatterValue(frontmatter, 'description');
if (scalar) {
return scalar;
}
if (!markdown) {
return undefined;
}
const lines = markdown
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
if (line.startsWith('#') || line === '---') {
continue;
}
return line.replace(/\s+/g, ' ').trim();
}
return undefined;
}
function parseAllowedTools(frontmatter: string): string[] {
const scalar = parseScalarFrontmatterValue(frontmatter, 'allowed-tools');
if (!scalar) {
return [];
}
return uniq(scalar.split(','));
}
function parseCategory(frontmatter: string): string | undefined {
const scalar = parseScalarFrontmatterValue(frontmatter, 'category');
if (scalar) {
return scalar;
}
const nested = frontmatter.match(/^\s+category:\s*(.+)$/im);
if (!nested?.[1]) {
return undefined;
}
return nested[1].trim().replace(/^["']|["']$/g, '');
}
function parseFrontmatter(markdown?: string | null): ParsedFrontmatter {
const { frontmatter } = extractFrontmatter(markdown);
return {
name: parseScalarFrontmatterValue(frontmatter, 'name'),
description: parseDescription(frontmatter, markdown),
version: parseScalarFrontmatterValue(frontmatter, 'version')
|| parseNestedFrontmatterValue(frontmatter, 'version'),
category: parseCategory(frontmatter),
allowedTools: parseAllowedTools(frontmatter),
};
}
function collectMatches(pattern: RegExp, text: string): string[] {
const matches: string[] = [];
const regex = new RegExp(pattern.source, pattern.flags);
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
const value = match.slice(1).find(Boolean) || match[0];
if (value) {
matches.push(value);
}
}
return uniq(matches);
}
function detectOperations(text: string): string[] {
const lowerText = text.toLowerCase();
const operations = OPERATION_KEYWORDS
.filter(([, keywords]) => keywords.some((keyword) => lowerText.includes(keyword)))
.map(([operation]) => operation);
return operations.length > 0 ? operations : ['assist'];
}
function detectInputExtensions(text: string): string[] {
return uniq(
collectMatches(FILE_EXTENSION_PATTERN, text)
.map((value) => value.toLowerCase())
.filter((value) => COMMON_FILE_EXTENSIONS.includes(value)),
);
}
function detectTriggerHints(text: string, inputExtensions: string[]): string[] {
const quotedHints = collectMatches(QUOTED_TEXT_PATTERN, text)
.filter((value) => value.length <= 64);
const sentenceHints = text
.split(/(?<=[.!?])\s+/)
.map((sentence) => sentence.trim())
.filter((sentence) => {
const lowerSentence = sentence.toLowerCase();
return lowerSentence.includes('use this skill')
|| lowerSentence.includes('use when')
|| lowerSentence.includes('trigger')
|| lowerSentence.includes('when to use');
})
.slice(0, 4)
.map((sentence) => sentence.replace(/\s+/g, ' ').trim());
return uniq([...quotedHints, ...sentenceHints, ...inputExtensions]).slice(0, 8);
}
function detectRequiredEnvVars(
manifestContent: string,
config?: SkillCapabilityParserInput['config'],
): string[] {
const fromManifest = collectMatches(ENV_VAR_PATTERN, manifestContent);
const fromConfig = config?.env ? Object.keys(config.env) : [];
return uniq([...fromManifest, ...fromConfig]);
}
function isCommandFenceLanguage(value: string): boolean {
const normalized = value.trim().toLowerCase();
return normalized === ''
|| ['bash', 'sh', 'shell', 'zsh', 'powershell', 'pwsh', 'ps1', 'cmd', 'bat'].includes(normalized);
}
function normalizeCommandBlockLines(block: string): string[] {
const lines = block
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.filter((line) => !line.startsWith('#'));
const combined: string[] = [];
for (const line of lines) {
if (combined.length > 0 && combined[combined.length - 1].endsWith('\\')) {
combined[combined.length - 1] = `${combined[combined.length - 1].slice(0, -1).trim()} ${line}`;
continue;
}
combined.push(line);
}
return combined;
}
function isSafeCommandExample(line: string): boolean {
const normalized = line.trim();
if (!normalized) {
return false;
}
if (normalized.startsWith('$ ')) {
return isSafeCommandExample(normalized.slice(2));
}
if (
normalized.includes('|')
|| normalized.includes('&&')
|| normalized.includes('||')
|| normalized.includes(';')
|| normalized.includes('>$')
|| normalized.includes('$(')
|| normalized.includes('`')
|| normalized.includes('<<')
) {
return false;
}
return !/^(curl|wget|brew|apt|apt-get|yum|dnf|pip|pip3|pnpm|npm|yarn)\s+(install|add)\b/i.test(normalized)
&& !/\blogin\b/i.test(normalized)
&& !/\bsetup\b/i.test(normalized);
}
function extractCommandExamples(markdown?: string | null): string[] {
if (!markdown) {
return [];
}
const examples: string[] = [];
for (const match of markdown.matchAll(CODE_FENCE_PATTERN)) {
const language = match[1] || '';
const block = match[2] || '';
if (!isCommandFenceLanguage(language)) {
continue;
}
for (const line of normalizeCommandBlockLines(block)) {
const candidate = line.startsWith('$ ') ? line.slice(2).trim() : line;
if (!isSafeCommandExample(candidate)) {
continue;
}
examples.push(candidate);
}
}
return uniq(examples).slice(0, 12);
}
function inferCategory(
parsedCategory: string | undefined,
operationHints: string[],
inputExtensions: string[],
): string {
if (parsedCategory) {
return parsedCategory;
}
if (inputExtensions.some((extension) => SPREADSHEET_EXTENSIONS.includes(extension))) {
return 'document';
}
if (inputExtensions.some((extension) => DOCUMENT_EXTENSIONS.includes(extension))) {
return 'document';
}
if (operationHints.some((operation) => ['search', 'research', 'crawl', 'extract'].includes(operation))) {
return 'search';
}
return 'general';
}
function inferRenderHints(
category: string,
operationHints: string[],
inputExtensions: string[],
skillKey: string,
): ToolRenderHints {
if (inputExtensions.some((extension) => SPREADSHEET_EXTENSIONS.includes(extension))) {
return {
card: 'document-analysis',
preferredView: 'table',
skillType: 'spreadsheet',
metadata: {
skillKey,
operations: operationHints,
},
};
}
if (inputExtensions.some((extension) => DOCUMENT_EXTENSIONS.includes(extension)) || category === 'document') {
return {
card: 'document-analysis',
preferredView: 'summary',
skillType: 'document',
metadata: {
skillKey,
operations: operationHints,
inputExtensions,
},
};
}
if (category === 'search' || operationHints.some((operation) => ['search', 'research', 'crawl'].includes(operation))) {
return {
card: 'search-results',
preferredView: 'summary',
skillType: 'search',
metadata: {
skillKey,
operations: operationHints,
},
};
}
return {
card: 'generic',
preferredView: 'summary',
skillType: category,
metadata: {
skillKey,
operations: operationHints,
},
};
}
function truncateDescription(value: string, maxLength = 180): string {
const normalized = value.replace(/\s+/g, ' ').trim();
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 3).trim()}...`;
}
function buildPlannerSummary(
category: string,
operationHints: string[],
inputExtensions: string[],
description: string,
requiresAuth: boolean,
): string {
const segments: string[] = [];
segments.push(`${category} skill`);
if (operationHints.length > 0) {
segments.push(`operations: ${operationHints.join(', ')}`);
}
if (inputExtensions.length > 0) {
segments.push(`inputs: ${inputExtensions.join(', ')}`);
}
if (requiresAuth) {
segments.push('auth/environment required');
}
segments.push(truncateDescription(description));
return segments.join('; ');
}
export function parseSkillCapability(input: SkillCapabilityParserInput): SkillCapability {
const manifestContent = input.manifestContent?.trim() || '';
const parsed = parseFrontmatter(manifestContent);
const config = input.config || {};
const name = parsed.name || config.name || config.slug || input.skillKey;
const description = parsed.description || config.description || `Enabled skill ${name}`;
const combinedText = [description, manifestContent].filter(Boolean).join('\n');
const operationHints = detectOperations(combinedText);
const inputExtensions = detectInputExtensions(combinedText);
const requiredEnvVars = detectRequiredEnvVars(manifestContent, config);
const commandExamples = extractCommandExamples(manifestContent);
const requiresAuth = Boolean(config.apiKey)
|| requiredEnvVars.length > 0
|| /requires api key|api key required|authentication required|authorization required|authorize|authorization|login\b|sign in|oauth|access token|bearer token/i.test(combinedText);
const category = inferCategory(parsed.category, operationHints, inputExtensions);
const renderHints = inferRenderHints(category, operationHints, inputExtensions, input.skillKey);
return {
skillKey: input.skillKey,
slug: config.slug || input.skillKey,
name,
description,
enabled: config.enabled !== false,
category,
version: config.version || parsed.version,
source: config.source,
baseDir: config.baseDir,
manifestPath: config.filePath,
allowedTools: uniq(parsed.allowedTools),
operationHints,
triggerHints: detectTriggerHints(combinedText, inputExtensions),
inputExtensions,
requiredEnvVars,
requiresAuth,
commandExamples,
plannerSummary: buildPlannerSummary(
category,
operationHints,
inputExtensions,
description,
requiresAuth,
),
renderHints,
};
}