mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Merge afa615a0bd into 53fb175855
This commit is contained in:
commit
ea250069f2
12 changed files with 2430 additions and 34 deletions
|
|
@ -11,6 +11,7 @@ import { parseDesignSystemRenameArgs } from './design-system-rename-args.js';
|
||||||
import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js';
|
import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js';
|
||||||
import { splitResearchSubcommand } from './research/cli-args.js';
|
import { splitResearchSubcommand } from './research/cli-args.js';
|
||||||
import { resolveDaemonUrl } from './daemon-url.js';
|
import { resolveDaemonUrl } from './daemon-url.js';
|
||||||
|
import { buildSkillCatalogTree, parseSkillSummaries } from '@open-design/contracts';
|
||||||
import { requestJsonIpc } from '@open-design/sidecar';
|
import { requestJsonIpc } from '@open-design/sidecar';
|
||||||
import { SIDECAR_ENV, SIDECAR_MESSAGES } from '@open-design/sidecar-proto';
|
import { SIDECAR_ENV, SIDECAR_MESSAGES } from '@open-design/sidecar-proto';
|
||||||
|
|
||||||
|
|
@ -196,6 +197,7 @@ const RECOVERABLE_EXIT_CODES = {
|
||||||
'plugin-requires-daemon': 71,
|
'plugin-requires-daemon': 71,
|
||||||
'snapshot-stale': 72,
|
'snapshot-stale': 72,
|
||||||
'genui-surface-awaiting': 73,
|
'genui-surface-awaiting': 73,
|
||||||
|
'daemon-protocol-error': 74,
|
||||||
'desktop-auth-pending': 74,
|
'desktop-auth-pending': 74,
|
||||||
'desktop-import-token-rejected': 75,
|
'desktop-import-token-rejected': 75,
|
||||||
};
|
};
|
||||||
|
|
@ -5408,7 +5410,60 @@ async function runLibraryList(name, args) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSkills(args) { return runLibraryList('skills', args); }
|
async function runSkills(args) {
|
||||||
|
if (args[0] === 'tree') return runSkillsTree(args.slice(1));
|
||||||
|
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
||||||
|
console.log(`Usage:
|
||||||
|
od skills list List skills.
|
||||||
|
od skills show <id> Print one skill.
|
||||||
|
od skills tree [--json] Print skills grouped by mode and scenario.`);
|
||||||
|
process.exit(args.length === 0 ? 2 : 0);
|
||||||
|
}
|
||||||
|
return runLibraryList('skills', args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSkillsTree(args) {
|
||||||
|
if (args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
|
||||||
|
console.log(`Usage:
|
||||||
|
od skills tree [--json] Print skills grouped by mode and scenario.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
const flags = parseFlags(args, { string: LIBRARY_STRING_FLAGS, boolean: LIBRARY_BOOLEAN_FLAGS });
|
||||||
|
const base = (await libraryDaemonUrl(flags)).replace(/\/$/, '');
|
||||||
|
const resp = await fetch(`${base}/api/skills`);
|
||||||
|
if (!resp.ok) return structuredHttpFailure(resp);
|
||||||
|
const data = await resp.json().catch(() => null);
|
||||||
|
let skills;
|
||||||
|
try {
|
||||||
|
skills = data && Array.isArray(data.skills) ? parseSkillSummaries(data.skills) : null;
|
||||||
|
} catch {
|
||||||
|
skills = null;
|
||||||
|
}
|
||||||
|
if (!skills) {
|
||||||
|
return exitWithStructuredError({
|
||||||
|
code: 'daemon-protocol-error',
|
||||||
|
message: 'Malformed /api/skills response: expected { skills: SkillSummary[] }',
|
||||||
|
data: { endpoint: '/api/skills' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const tree = buildSkillCatalogTree(skills);
|
||||||
|
if (flags.json) return process.stdout.write(JSON.stringify(tree, null, 2) + '\n');
|
||||||
|
console.log(`Skills tree (${tree.total})`);
|
||||||
|
for (const mode of tree.modes) {
|
||||||
|
console.log(`${mode.label} (${mode.count})`);
|
||||||
|
for (const scenario of mode.scenarios) {
|
||||||
|
console.log(` ${scenario.label} (${scenario.count})`);
|
||||||
|
for (const skill of scenario.skills) {
|
||||||
|
const metadata = [
|
||||||
|
skill.platform,
|
||||||
|
skill.previewType,
|
||||||
|
skill.designSystemRequired ? 'design system' : null,
|
||||||
|
].filter(Boolean).join(' · ');
|
||||||
|
console.log(` - ${skill.id}${metadata ? ` [${metadata}]` : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
async function runCraft(args) { return runLibraryList('craft', args); }
|
async function runCraft(args) { return runLibraryList('craft', args); }
|
||||||
|
|
||||||
async function runDesignSystems(args) {
|
async function runDesignSystems(args) {
|
||||||
|
|
|
||||||
210
apps/daemon/tests/skills-cli.test.ts
Normal file
210
apps/daemon/tests/skills-cli.test.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const daemonRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
const cliEntry = fileURLToPath(new URL('../src/cli.ts', import.meta.url));
|
||||||
|
|
||||||
|
async function withSkillsServer<T>(
|
||||||
|
handler: (req: IncomingMessage, res: ServerResponse) => void,
|
||||||
|
run: (baseUrl: string) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const server = createServer(handler);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => resolve());
|
||||||
|
});
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === 'string') {
|
||||||
|
server.close();
|
||||||
|
throw new Error('test server did not bind to a TCP port');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await run(`http://127.0.0.1:${address.port}`);
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('od skills CLI', () => {
|
||||||
|
it('prints the skills tree for the plain command', async () => {
|
||||||
|
await withSkillsServer(
|
||||||
|
(_req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
id: 'dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
description: 'A dashboard skill.',
|
||||||
|
triggers: [],
|
||||||
|
mode: 'prototype',
|
||||||
|
scenario: 'operation',
|
||||||
|
platform: 'desktop',
|
||||||
|
previewType: 'html',
|
||||||
|
designSystemRequired: true,
|
||||||
|
defaultFor: [],
|
||||||
|
upstream: null,
|
||||||
|
hasBody: true,
|
||||||
|
examplePrompt: 'Build a dashboard.',
|
||||||
|
aggregatesExamples: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async (baseUrl) => {
|
||||||
|
const result = await execFileAsync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'--import',
|
||||||
|
'tsx',
|
||||||
|
cliEntry,
|
||||||
|
'skills',
|
||||||
|
'tree',
|
||||||
|
'--daemon-url',
|
||||||
|
baseUrl,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: daemonRoot,
|
||||||
|
env: process.env,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.stdout).toContain('Skills tree (1)');
|
||||||
|
expect(result.stdout).toContain('Prototype (1)');
|
||||||
|
expect(result.stdout).toContain('Operation (1)');
|
||||||
|
expect(result.stdout).toContain('- dashboard [desktop · html · design system]');
|
||||||
|
expect(result.stderr).toBe('');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints skills tree help without contacting the daemon', async () => {
|
||||||
|
const result = await execFileAsync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'--import',
|
||||||
|
'tsx',
|
||||||
|
cliEntry,
|
||||||
|
'skills',
|
||||||
|
'tree',
|
||||||
|
'--help',
|
||||||
|
'--daemon-url',
|
||||||
|
'http://127.0.0.1:1',
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: daemonRoot,
|
||||||
|
env: process.env,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.stdout).toContain('Usage:');
|
||||||
|
expect(result.stdout).toContain('od skills tree [--json]');
|
||||||
|
expect(result.stderr).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed skills tree responses', async () => {
|
||||||
|
await withSkillsServer(
|
||||||
|
(_req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
},
|
||||||
|
async (baseUrl) => {
|
||||||
|
try {
|
||||||
|
await execFileAsync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'--import',
|
||||||
|
'tsx',
|
||||||
|
cliEntry,
|
||||||
|
'skills',
|
||||||
|
'tree',
|
||||||
|
'--json',
|
||||||
|
'--daemon-url',
|
||||||
|
baseUrl,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: daemonRoot,
|
||||||
|
env: process.env,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
throw new Error('skills tree command unexpectedly succeeded');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const failed = error as { code?: number; stderr?: string; stdout?: string };
|
||||||
|
expect(failed.code).toBe(74);
|
||||||
|
expect(failed.stdout ?? '').toBe('');
|
||||||
|
const envelope = JSON.parse(failed.stderr ?? '{}') as {
|
||||||
|
error?: { code?: string; message?: string; data?: { endpoint?: string } };
|
||||||
|
};
|
||||||
|
expect(envelope.error?.code).toBe('daemon-protocol-error');
|
||||||
|
expect(envelope.error?.message).toContain('Malformed /api/skills response');
|
||||||
|
expect(envelope.error?.data?.endpoint).toBe('/api/skills');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed skill elements in skills tree responses', async () => {
|
||||||
|
await withSkillsServer(
|
||||||
|
(_req, res) => {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
id: 'dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
description: 'A dashboard skill.',
|
||||||
|
triggers: [],
|
||||||
|
mode: 'prototype',
|
||||||
|
scenario: 'operation',
|
||||||
|
platform: 'desktop',
|
||||||
|
previewType: 'html',
|
||||||
|
designSystemRequired: true,
|
||||||
|
defaultFor: [],
|
||||||
|
upstream: null,
|
||||||
|
hasBody: true,
|
||||||
|
examplePrompt: 'Build a dashboard.',
|
||||||
|
aggregatesExamples: false,
|
||||||
|
},
|
||||||
|
{ id: 'broken', mode: 'prototype' },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async (baseUrl) => {
|
||||||
|
try {
|
||||||
|
await execFileAsync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'--import',
|
||||||
|
'tsx',
|
||||||
|
cliEntry,
|
||||||
|
'skills',
|
||||||
|
'tree',
|
||||||
|
'--json',
|
||||||
|
'--daemon-url',
|
||||||
|
baseUrl,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: daemonRoot,
|
||||||
|
env: process.env,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
throw new Error('skills tree command unexpectedly succeeded');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const failed = error as { code?: number; stderr?: string; stdout?: string };
|
||||||
|
expect(failed.code).toBe(74);
|
||||||
|
expect(failed.stdout ?? '').toBe('');
|
||||||
|
const envelope = JSON.parse(failed.stderr ?? '{}') as {
|
||||||
|
error?: { code?: string; message?: string; data?: { endpoint?: string } };
|
||||||
|
};
|
||||||
|
expect(envelope.error?.code).toBe('daemon-protocol-error');
|
||||||
|
expect(envelope.error?.message).toContain('Malformed /api/skills response');
|
||||||
|
expect(envelope.error?.data?.endpoint).toBe('/api/skills');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { AppConfig } from '../types';
|
import type { AppConfig, SkillSummary } from '../types';
|
||||||
|
import {
|
||||||
|
buildSkillCatalogTree,
|
||||||
|
type SkillCatalogTree,
|
||||||
|
type SkillCatalogTreeSkill,
|
||||||
|
} from '@open-design/contracts';
|
||||||
import { useAnalytics } from '../analytics/provider';
|
import { useAnalytics } from '../analytics/provider';
|
||||||
import {
|
import {
|
||||||
trackIntegrationsConnectorsTabClick,
|
trackIntegrationsConnectorsTabClick,
|
||||||
trackIntegrationsSkillsTabClick,
|
|
||||||
trackIntegrationsTabClick,
|
trackIntegrationsTabClick,
|
||||||
trackPageView,
|
trackPageView,
|
||||||
trackSettingsConnectorAuthResult,
|
trackSettingsConnectorAuthResult,
|
||||||
|
|
@ -12,7 +16,13 @@ import { ConnectorSection } from './SettingsDialog';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
import { McpClientSection } from './McpClientSection';
|
import { McpClientSection } from './McpClientSection';
|
||||||
import { UseEverywhereGuidePanel } from './UseEverywhereModal';
|
import { UseEverywhereGuidePanel } from './UseEverywhereModal';
|
||||||
import { useT } from '../i18n';
|
import { fetchSkills } from '../providers/registry';
|
||||||
|
import { useI18n, useT } from '../i18n';
|
||||||
|
import {
|
||||||
|
localizeSkillDescription,
|
||||||
|
localizeSkillName,
|
||||||
|
localizeSkillPrompt,
|
||||||
|
} from '../i18n/content';
|
||||||
|
|
||||||
export type IntegrationTab = 'mcp' | 'connectors' | 'skills' | 'use-everywhere';
|
export type IntegrationTab = 'mcp' | 'connectors' | 'skills' | 'use-everywhere';
|
||||||
|
|
||||||
|
|
@ -148,7 +158,7 @@ export function IntegrationsView({
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activeTab === 'skills' ? <SkillsComingSoonPanel /> : null}
|
{activeTab === 'skills' ? <SkillsCatalogTreePanel /> : null}
|
||||||
|
|
||||||
{activeTab === 'use-everywhere' ? (
|
{activeTab === 'use-everywhere' ? (
|
||||||
<div className="integrations-view__use-everywhere">
|
<div className="integrations-view__use-everywhere">
|
||||||
|
|
@ -163,35 +173,783 @@ export function IntegrationsView({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkillsComingSoonPanel() {
|
function SkillsCatalogTreePanel() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const analytics = useAnalytics();
|
const { locale } = useI18n();
|
||||||
|
const [skills, setSkills] = useState<SkillSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [viewMode, setViewMode] = useState<SkillCatalogViewMode>('list');
|
||||||
|
const [filters, setFilters] = useState<SkillCatalogFilters>(DEFAULT_SKILL_CATALOG_FILTERS);
|
||||||
|
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setLoadError(false);
|
||||||
|
void fetchSkills({ throwOnError: true })
|
||||||
|
.then((list) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setSkills(list);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setSkills([]);
|
||||||
|
setLoadError(true);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredSkills = useMemo(() => {
|
||||||
|
const query = search.trim().toLowerCase();
|
||||||
|
return skills.filter((skill) => {
|
||||||
|
if (filters.mode !== 'all' && skill.mode !== filters.mode) return false;
|
||||||
|
if (filters.scenario !== 'all' && skillCatalogScenario(skill) !== filters.scenario) return false;
|
||||||
|
if (filters.category !== 'all' && (skill.category ?? '') !== filters.category) return false;
|
||||||
|
if (filters.platform !== 'all' && (skill.platform ?? '') !== filters.platform) return false;
|
||||||
|
if (filters.previewType !== 'all' && skill.previewType !== filters.previewType) return false;
|
||||||
|
if (filters.designSystem === 'required' && !skill.designSystemRequired) return false;
|
||||||
|
if (filters.designSystem === 'optional' && skill.designSystemRequired) return false;
|
||||||
|
if (!query) return true;
|
||||||
|
const name = localizeSkillName(locale, skill) || skill.name;
|
||||||
|
const description = localizeSkillDescription(locale, skill);
|
||||||
|
const haystack = [
|
||||||
|
skill.id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
skill.mode,
|
||||||
|
skillCatalogScenario(skill),
|
||||||
|
skill.platform ?? '',
|
||||||
|
skill.previewType,
|
||||||
|
skill.category ?? '',
|
||||||
|
...(skill.triggers ?? []),
|
||||||
|
].join('\n');
|
||||||
|
return haystack.toLowerCase().includes(query);
|
||||||
|
});
|
||||||
|
}, [filters, locale, search, skills]);
|
||||||
|
|
||||||
|
const tree = useMemo(
|
||||||
|
() => buildSkillCatalogTree(filteredSkills),
|
||||||
|
[filteredSkills],
|
||||||
|
);
|
||||||
|
const filterOptions = useMemo(
|
||||||
|
() => buildSkillCatalogFilterOptions(skills),
|
||||||
|
[skills],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedSkill = useMemo(() => {
|
||||||
|
if (!selectedSkillId) return null;
|
||||||
|
return filteredSkills.find((skill) => skill.id === selectedSkillId) ?? null;
|
||||||
|
}, [filteredSkills, selectedSkillId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedSkillId && filteredSkills.some((skill) => skill.id === selectedSkillId)) return;
|
||||||
|
if (selectedSkillId) setSelectedSkillId(null);
|
||||||
|
}, [filteredSkills, selectedSkillId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="integrations-view__coming-soon"
|
className="integrations-skills-tree"
|
||||||
aria-labelledby="integration-skills-title"
|
aria-labelledby="integration-skills-title"
|
||||||
onClick={() =>
|
|
||||||
trackIntegrationsSkillsTabClick(analytics.track, {
|
|
||||||
page_name: 'integrations',
|
|
||||||
area: 'skills_tab',
|
|
||||||
element: 'coming_soon',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="integrations-view__coming-icon" aria-hidden="true">
|
<header className="integrations-skills-tree__head">
|
||||||
<Icon name="sparkles" size={22} />
|
<div>
|
||||||
</div>
|
<p className="integrations-view__coming-kicker">{t('integrations.tabLabel.skills')}</p>
|
||||||
<div>
|
<h2 id="integration-skills-title">{t('integrations.skillsTitle')}</h2>
|
||||||
<p className="integrations-view__coming-kicker">{t('tasks.comingSoon')}</p>
|
</div>
|
||||||
<h2 id="integration-skills-title">{t('integrations.skillsTitle')}</h2>
|
<div className="integrations-skills-tree__tools">
|
||||||
|
<div className="integrations-skills-tree__view-toggle" aria-label={t('integrations.skillsViewMode')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={viewMode === 'tree' ? 'is-active' : ''}
|
||||||
|
onClick={() => setViewMode('tree')}
|
||||||
|
>
|
||||||
|
{t('integrations.skillsTreeView')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={viewMode === 'list' ? 'is-active' : ''}
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
>
|
||||||
|
{t('integrations.skillsListView')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label className="integrations-skills-tree__search">
|
||||||
|
<Icon name="search" size={13} />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder={t('integrations.skillsSearch')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="integrations-skills-tree__summary">
|
||||||
<p>
|
<p>
|
||||||
{t('integrations.skillsBody')}
|
{t('integrations.skillsBody')}
|
||||||
</p>
|
</p>
|
||||||
|
<span>{tree.total}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<SkillCatalogFiltersBar
|
||||||
|
filters={filters}
|
||||||
|
options={filterOptions}
|
||||||
|
onChange={setFilters}
|
||||||
|
/>
|
||||||
|
{loading ? (
|
||||||
|
<div className="integrations-skills-tree__empty">
|
||||||
|
{t('integrations.skillsLoading')}
|
||||||
|
</div>
|
||||||
|
) : loadError ? (
|
||||||
|
<div className="integrations-skills-tree__empty">
|
||||||
|
{t('integrations.skillsLoadFailed')}
|
||||||
|
</div>
|
||||||
|
) : tree.total === 0 ? (
|
||||||
|
<div className="integrations-skills-tree__empty">
|
||||||
|
{t('integrations.skillsNoFilterResults')}
|
||||||
|
</div>
|
||||||
|
) : viewMode === 'list' ? (
|
||||||
|
<SkillListView
|
||||||
|
skills={filteredSkills}
|
||||||
|
selectedSkill={selectedSkill}
|
||||||
|
selectedSkillId={selectedSkillId}
|
||||||
|
onSelectSkill={setSelectedSkillId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SkillTreeGraph
|
||||||
|
tree={tree}
|
||||||
|
selectedSkillId={selectedSkillId}
|
||||||
|
selectedSkill={selectedSkill}
|
||||||
|
onSelectSkill={setSelectedSkillId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SkillCatalogViewMode = 'tree' | 'list';
|
||||||
|
type SkillDesignSystemFilter = 'all' | 'required' | 'optional';
|
||||||
|
|
||||||
|
interface SkillCatalogFilters {
|
||||||
|
mode: string;
|
||||||
|
scenario: string;
|
||||||
|
category: string;
|
||||||
|
platform: string;
|
||||||
|
previewType: string;
|
||||||
|
designSystem: SkillDesignSystemFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SKILL_CATALOG_FILTERS: SkillCatalogFilters = {
|
||||||
|
mode: 'all',
|
||||||
|
scenario: 'all',
|
||||||
|
category: 'all',
|
||||||
|
platform: 'all',
|
||||||
|
previewType: 'all',
|
||||||
|
designSystem: 'all',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SkillCatalogFilterOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillCatalogFilterOptions {
|
||||||
|
modes: SkillCatalogFilterOption[];
|
||||||
|
scenarios: SkillCatalogFilterOption[];
|
||||||
|
categories: SkillCatalogFilterOption[];
|
||||||
|
platforms: SkillCatalogFilterOption[];
|
||||||
|
previewTypes: SkillCatalogFilterOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillCatalogFiltersBar({
|
||||||
|
filters,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
filters: SkillCatalogFilters;
|
||||||
|
options: SkillCatalogFilterOptions;
|
||||||
|
onChange: (filters: SkillCatalogFilters) => void;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
|
const update = <K extends keyof SkillCatalogFilters>(key: K, value: SkillCatalogFilters[K]) => {
|
||||||
|
onChange({ ...filters, [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="integrations-skills-tree__filters" aria-label={t('integrations.skillsFilters')}>
|
||||||
|
<SkillCatalogSelect
|
||||||
|
label={t('integrations.skillsFilterMode')}
|
||||||
|
value={filters.mode}
|
||||||
|
options={options.modes}
|
||||||
|
onChange={(value) => update('mode', value)}
|
||||||
|
/>
|
||||||
|
<SkillCatalogSelect
|
||||||
|
label={t('integrations.skillsFilterScenario')}
|
||||||
|
value={filters.scenario}
|
||||||
|
options={options.scenarios}
|
||||||
|
onChange={(value) => update('scenario', value)}
|
||||||
|
/>
|
||||||
|
<SkillCatalogSelect
|
||||||
|
label={t('integrations.skillsFilterCategory')}
|
||||||
|
value={filters.category}
|
||||||
|
options={options.categories}
|
||||||
|
onChange={(value) => update('category', value)}
|
||||||
|
/>
|
||||||
|
<SkillCatalogSelect
|
||||||
|
label={t('integrations.skillsFilterPlatform')}
|
||||||
|
value={filters.platform}
|
||||||
|
options={options.platforms}
|
||||||
|
onChange={(value) => update('platform', value)}
|
||||||
|
/>
|
||||||
|
<SkillCatalogSelect
|
||||||
|
label={t('integrations.skillsFilterPreview')}
|
||||||
|
value={filters.previewType}
|
||||||
|
options={options.previewTypes}
|
||||||
|
onChange={(value) => update('previewType', value)}
|
||||||
|
/>
|
||||||
|
<label className="integrations-skills-tree__filter">
|
||||||
|
<span>{t('integrations.skillsFilterDesignSystem')}</span>
|
||||||
|
<select
|
||||||
|
value={filters.designSystem}
|
||||||
|
onChange={(event) => update('designSystem', event.target.value as SkillDesignSystemFilter)}
|
||||||
|
data-testid="integrations-skill-filter-design-system"
|
||||||
|
>
|
||||||
|
<option value="all">{t('integrations.skillsFilterAll')}</option>
|
||||||
|
<option value="required">{t('integrations.skillsTreeDesignSystemRequired')}</option>
|
||||||
|
<option value="optional">{t('integrations.skillsTreeDesignSystemOptional')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillCatalogSelect({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
options: SkillCatalogFilterOption[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="integrations-skills-tree__filter">
|
||||||
|
<span>{label}</span>
|
||||||
|
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
||||||
|
<option value="all">{t('integrations.skillsFilterAll')}</option>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{option.label} ({option.count})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillListView({
|
||||||
|
skills,
|
||||||
|
selectedSkill,
|
||||||
|
selectedSkillId,
|
||||||
|
onSelectSkill,
|
||||||
|
}: {
|
||||||
|
skills: SkillSummary[];
|
||||||
|
selectedSkill: SkillSummary | null;
|
||||||
|
selectedSkillId: string | null;
|
||||||
|
onSelectSkill: (skillId: string) => void;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const { locale } = useI18n();
|
||||||
|
const selectedTreeSkill = selectedSkill
|
||||||
|
? skillSummaryToTreeSkill(selectedSkill)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="integrations-skills-tree__grid">
|
||||||
|
<div className="integrations-skills-tree__list" role="list">
|
||||||
|
{skills.map((skill) => {
|
||||||
|
const name = localizeSkillName(locale, skill) || skill.name || skill.id;
|
||||||
|
const description = localizeSkillDescription(locale, skill);
|
||||||
|
const isActive = skill.id === selectedSkillId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={skill.id}
|
||||||
|
type="button"
|
||||||
|
className={`integrations-skills-tree__list-row${isActive ? ' is-active' : ''}`}
|
||||||
|
onClick={() => onSelectSkill(skill.id)}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
data-testid={`integrations-skill-list-row-${skill.id}`}
|
||||||
|
>
|
||||||
|
<span className="integrations-skills-tree__list-title">{name}</span>
|
||||||
|
{description ? (
|
||||||
|
<span className="integrations-skills-tree__list-description">{description}</span>
|
||||||
|
) : null}
|
||||||
|
<span className="integrations-skills-tree__list-meta">
|
||||||
|
<span>{skill.mode}</span>
|
||||||
|
<span>{skillCatalogScenario(skill)}</span>
|
||||||
|
{skill.category ? <span>{skill.category}</span> : null}
|
||||||
|
{skill.platform ? <span>{skill.platform}</span> : null}
|
||||||
|
<span>{skill.previewType}</span>
|
||||||
|
<span>
|
||||||
|
{skill.designSystemRequired
|
||||||
|
? t('integrations.skillsTreeDesignSystemRequired')
|
||||||
|
: t('integrations.skillsTreeDesignSystemOptional')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<SkillTreeInfoPanel skill={selectedTreeSkill} emptyLabel={t('integrations.skillsListEmpty')} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillTreeGraphProps {
|
||||||
|
tree: SkillCatalogTree;
|
||||||
|
selectedSkillId: string | null;
|
||||||
|
selectedSkill: SkillSummary | null;
|
||||||
|
onSelectSkill: (skillId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillTreeGraph({
|
||||||
|
tree,
|
||||||
|
selectedSkillId,
|
||||||
|
selectedSkill,
|
||||||
|
onSelectSkill,
|
||||||
|
}: SkillTreeGraphProps) {
|
||||||
|
const t = useT();
|
||||||
|
const { locale } = useI18n();
|
||||||
|
const layout = useMemo(
|
||||||
|
() => buildSkillTreeSvgLayout(tree, selectedSkillId, locale, {
|
||||||
|
mode: t('integrations.skillsTreeGuideMode'),
|
||||||
|
scenario: t('integrations.skillsTreeGuideScenario'),
|
||||||
|
skill: t('integrations.skillsTreeGuideSkill'),
|
||||||
|
}),
|
||||||
|
[locale, selectedSkillId, t, tree],
|
||||||
|
);
|
||||||
|
const selectedTreeSkill = selectedSkill
|
||||||
|
? skillSummaryToTreeSkill(selectedSkill)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="integrations-skills-tree__grid">
|
||||||
|
<div className="integrations-skills-tree__canvas">
|
||||||
|
<svg
|
||||||
|
className="integrations-skills-tree__svg"
|
||||||
|
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
||||||
|
role="img"
|
||||||
|
aria-label={t('integrations.skillsTreeView')}
|
||||||
|
style={{ minWidth: layout.width }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<filter id="integrations-skill-node-glow" x="-80%" y="-80%" width="260%" height="260%">
|
||||||
|
<feGaussianBlur stdDeviation="6" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
{layout.guides.map((guide) => (
|
||||||
|
<g key={guide.id} className="integrations-skills-tree__guide">
|
||||||
|
<line x1={56} y1={guide.y} x2={layout.width - 24} y2={guide.y} />
|
||||||
|
<text x={24} y={guide.y + 4}>{guide.label}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{layout.edges.map((edge) => (
|
||||||
|
<g
|
||||||
|
key={edge.id}
|
||||||
|
className={`integrations-skills-tree__edge${edge.active ? ' is-active' : ''}`}
|
||||||
|
>
|
||||||
|
<path className="integrations-skills-tree__edge-aura" d={edge.d} />
|
||||||
|
<path className="integrations-skills-tree__edge-base" d={edge.d} />
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{layout.nodes.map((node) => {
|
||||||
|
const interactive = Boolean(node.skillId);
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={node.id}
|
||||||
|
className={`integrations-skills-tree__svg-node is-${node.kind}${interactive ? ' is-interactive' : ' is-branch'}${node.active ? ' is-active' : ''}${node.onPath ? ' is-on-path' : ''}`}
|
||||||
|
transform={`translate(${node.x} ${node.y})`}
|
||||||
|
role={interactive ? 'button' : undefined}
|
||||||
|
tabIndex={interactive ? 0 : undefined}
|
||||||
|
aria-pressed={interactive ? node.active : undefined}
|
||||||
|
onClick={interactive ? () => onSelectSkill(node.skillId!) : undefined}
|
||||||
|
onKeyDown={interactive ? (event) => {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
|
event.preventDefault();
|
||||||
|
onSelectSkill(node.skillId!);
|
||||||
|
} : undefined}
|
||||||
|
data-testid={node.kind === 'skill' ? `integrations-skill-node-${node.skillId}` : undefined}
|
||||||
|
>
|
||||||
|
<title>{node.title}</title>
|
||||||
|
{node.active ? <circle className="integrations-skills-tree__svg-glow" r={node.radius + 14} /> : null}
|
||||||
|
{interactive ? <circle className="integrations-skills-tree__svg-affordance" r={node.radius + 8} /> : null}
|
||||||
|
{interactive && !node.active ? <circle className="integrations-skills-tree__svg-pulse" r={node.radius + 6} /> : null}
|
||||||
|
<circle className="integrations-skills-tree__svg-core" r={node.radius} />
|
||||||
|
{interactive ? (
|
||||||
|
<circle className="integrations-skills-tree__svg-ring" r={node.radius - 7} />
|
||||||
|
) : (
|
||||||
|
<circle className="integrations-skills-tree__svg-branch-ring" r={node.radius - 9} />
|
||||||
|
)}
|
||||||
|
<text className="integrations-skills-tree__svg-label" y={node.kind === 'skill' ? -3 : -5}>
|
||||||
|
{node.label}
|
||||||
|
</text>
|
||||||
|
<text className="integrations-skills-tree__svg-sub" y={node.kind === 'skill' ? 12 : 11}>
|
||||||
|
{node.subLabel}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
<div className="integrations-skills-tree__legend" aria-hidden>
|
||||||
|
<span><i className="is-mode" /> {t('integrations.skillsTreeLegendMode')}</span>
|
||||||
|
<span><i className="is-scenario" /> {t('integrations.skillsTreeLegendScenario')}</span>
|
||||||
|
<span><i className="is-skill" /> {t('integrations.skillsTreeLegendSkill')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SkillTreeInfoPanel skill={selectedTreeSkill} emptyLabel={t('integrations.skillsTreeEmpty')} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillTreeSvgNode {
|
||||||
|
id: string;
|
||||||
|
kind: 'mode' | 'scenario' | 'skill';
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
radius: number;
|
||||||
|
label: string;
|
||||||
|
subLabel: string;
|
||||||
|
title: string;
|
||||||
|
skillId: string | null;
|
||||||
|
active: boolean;
|
||||||
|
onPath: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillTreeSvgEdge {
|
||||||
|
id: string;
|
||||||
|
d: string;
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillTreeSvgGuide {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillTreeSvgLayout {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
nodes: SkillTreeSvgNode[];
|
||||||
|
edges: SkillTreeSvgEdge[];
|
||||||
|
guides: SkillTreeSvgGuide[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSkillTreeSvgLayout(
|
||||||
|
tree: SkillCatalogTree,
|
||||||
|
selectedSkillId: string | null,
|
||||||
|
locale: ReturnType<typeof useI18n>['locale'],
|
||||||
|
guideLabels: {
|
||||||
|
mode: string;
|
||||||
|
scenario: string;
|
||||||
|
skill: string;
|
||||||
|
},
|
||||||
|
): SkillTreeSvgLayout {
|
||||||
|
const modeGap = 330;
|
||||||
|
const modeStartX = 170;
|
||||||
|
const modeY = 74;
|
||||||
|
const scenarioStartY = 178;
|
||||||
|
const scenarioGap = 118;
|
||||||
|
const skillRowGap = 78;
|
||||||
|
const skillColOffset = 74;
|
||||||
|
const nodes: SkillTreeSvgNode[] = [];
|
||||||
|
const edges: SkillTreeSvgEdge[] = [];
|
||||||
|
let height = 620;
|
||||||
|
|
||||||
|
tree.modes.forEach((mode, modeIndex) => {
|
||||||
|
const modeX = modeStartX + modeIndex * modeGap;
|
||||||
|
const modeContainsSelected = mode.scenarios.some((scenario) =>
|
||||||
|
scenario.skills.some((skill) => skill.id === selectedSkillId),
|
||||||
|
);
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: `mode-${mode.id}`,
|
||||||
|
kind: 'mode',
|
||||||
|
x: modeX,
|
||||||
|
y: modeY,
|
||||||
|
radius: 34,
|
||||||
|
label: truncateSvgLabel(mode.label, 10),
|
||||||
|
subLabel: String(mode.count),
|
||||||
|
title: `${mode.label} · ${mode.count}`,
|
||||||
|
skillId: null,
|
||||||
|
active: false,
|
||||||
|
onPath: modeContainsSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
let cursorY = scenarioStartY;
|
||||||
|
mode.scenarios.forEach((scenario, scenarioIndex) => {
|
||||||
|
const scenarioContainsSelected = scenario.skills.some((skill) => skill.id === selectedSkillId);
|
||||||
|
nodes.push({
|
||||||
|
id: `scenario-${mode.id}-${scenario.id}`,
|
||||||
|
kind: 'scenario',
|
||||||
|
x: modeX,
|
||||||
|
y: cursorY,
|
||||||
|
radius: 27,
|
||||||
|
label: truncateSvgLabel(scenario.label, 9),
|
||||||
|
subLabel: String(scenario.count),
|
||||||
|
title: `${mode.label} / ${scenario.label} · ${scenario.count}`,
|
||||||
|
skillId: null,
|
||||||
|
active: false,
|
||||||
|
onPath: scenarioContainsSelected,
|
||||||
|
});
|
||||||
|
edges.push({
|
||||||
|
id: `edge-${mode.id}-${scenario.id}`,
|
||||||
|
d: skillTreeConnectorPath(modeX, modeY + 34, modeX, cursorY - 27),
|
||||||
|
x1: modeX,
|
||||||
|
y1: modeY + 34,
|
||||||
|
x2: modeX,
|
||||||
|
y2: cursorY - 27,
|
||||||
|
active: scenarioContainsSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
scenario.skills.forEach((skill, skillIndex) => {
|
||||||
|
const row = Math.floor(skillIndex / 2);
|
||||||
|
const side = skillIndex % 2 === 0 ? -1 : 1;
|
||||||
|
const skillX = modeX + side * skillColOffset;
|
||||||
|
const skillY = cursorY + 72 + row * skillRowGap;
|
||||||
|
const name = localizeSkillName(locale, skill.skill) || skill.id;
|
||||||
|
const active = skill.id === selectedSkillId;
|
||||||
|
nodes.push({
|
||||||
|
id: `skill-${skill.id}`,
|
||||||
|
kind: 'skill',
|
||||||
|
x: skillX,
|
||||||
|
y: skillY,
|
||||||
|
radius: 27,
|
||||||
|
label: truncateSvgLabel(name, 8),
|
||||||
|
subLabel: skill.platform ?? skill.previewType,
|
||||||
|
title: `${name} · ${skill.platform ?? skill.previewType}`,
|
||||||
|
skillId: skill.id,
|
||||||
|
active,
|
||||||
|
onPath: active,
|
||||||
|
});
|
||||||
|
edges.push({
|
||||||
|
id: `edge-${scenario.id}-${skill.id}`,
|
||||||
|
d: skillTreeLeafConnectorPath(modeX, cursorY + 27, skillX, skillY, 27),
|
||||||
|
x1: modeX,
|
||||||
|
y1: cursorY + 27,
|
||||||
|
x2: skillX,
|
||||||
|
y2: skillY,
|
||||||
|
active,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = Math.max(1, Math.ceil(scenario.skills.length / 2));
|
||||||
|
cursorY += 72 + rows * skillRowGap + scenarioGap;
|
||||||
|
});
|
||||||
|
height = Math.max(height, cursorY + 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
const width = Math.max(720, modeStartX + Math.max(0, tree.modes.length - 1) * modeGap + 190);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
guides: [
|
||||||
|
{ id: 'mode', label: guideLabels.mode, y: modeY },
|
||||||
|
{ id: 'scenario', label: guideLabels.scenario, y: scenarioStartY },
|
||||||
|
{ id: 'skill', label: guideLabels.skill, y: scenarioStartY + 72 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateSvgLabel(value: string, max: number): string {
|
||||||
|
return value.length > max ? `${value.slice(0, Math.max(1, max - 1))}…` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillTreeConnectorPath(
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
x2: number,
|
||||||
|
y2: number,
|
||||||
|
): string {
|
||||||
|
if (Math.abs(x2 - x1) < 1) {
|
||||||
|
return `M ${x1} ${y1} V ${y2}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = x2 > x1 ? 1 : -1;
|
||||||
|
const distanceX = Math.abs(x2 - x1);
|
||||||
|
const distanceY = Math.abs(y2 - y1);
|
||||||
|
const corner = Math.min(18, distanceX / 2, distanceY / 2);
|
||||||
|
const busY = y1 + Math.max(28, Math.min(64, distanceY * 0.48));
|
||||||
|
const c1x = x1 + direction * corner;
|
||||||
|
const c2x = x2 - direction * corner;
|
||||||
|
const c2y = y2 - corner;
|
||||||
|
|
||||||
|
return [
|
||||||
|
`M ${x1} ${y1}`,
|
||||||
|
`V ${busY - corner}`,
|
||||||
|
`Q ${x1} ${busY} ${c1x} ${busY}`,
|
||||||
|
`H ${c2x}`,
|
||||||
|
`Q ${x2} ${busY} ${x2} ${busY + corner}`,
|
||||||
|
`V ${c2y}`,
|
||||||
|
`Q ${x2} ${y2} ${x2} ${y2}`,
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillTreeLeafConnectorPath(
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
x2: number,
|
||||||
|
y2: number,
|
||||||
|
targetRadius: number,
|
||||||
|
): string {
|
||||||
|
if (Math.abs(x2 - x1) < 1) {
|
||||||
|
return `M ${x1} ${y1} V ${y2 - targetRadius}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = x2 > x1 ? 1 : -1;
|
||||||
|
const endX = x2 - direction * targetRadius;
|
||||||
|
const distanceX = Math.abs(endX - x1);
|
||||||
|
const distanceY = Math.abs(y2 - y1);
|
||||||
|
const corner = Math.min(16, distanceX / 2, distanceY / 2);
|
||||||
|
|
||||||
|
return [
|
||||||
|
`M ${x1} ${y1}`,
|
||||||
|
`V ${y2 - corner}`,
|
||||||
|
`Q ${x1} ${y2} ${x1 + direction * corner} ${y2}`,
|
||||||
|
`H ${endX}`,
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillTreeInfoPanel({
|
||||||
|
skill,
|
||||||
|
emptyLabel,
|
||||||
|
}: {
|
||||||
|
skill: SkillCatalogTreeSkill | null;
|
||||||
|
emptyLabel: string;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const { locale } = useI18n();
|
||||||
|
|
||||||
|
if (!skill) {
|
||||||
|
return (
|
||||||
|
<aside className="integrations-skills-tree__detail is-empty">
|
||||||
|
{emptyLabel}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = localizeSkillName(locale, skill.skill) || skill.id;
|
||||||
|
const description = localizeSkillDescription(locale, skill.skill);
|
||||||
|
const prompt = localizeSkillPrompt(locale, skill.skill) || skill.examplePrompt;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="integrations-skills-tree__detail" data-testid="integrations-skill-detail">
|
||||||
|
<p className="integrations-skills-tree__detail-kicker">
|
||||||
|
{skill.mode} / {skill.scenario}
|
||||||
|
</p>
|
||||||
|
<h3>{name}</h3>
|
||||||
|
{description ? <p>{description}</p> : null}
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>{t('integrations.skillsTreePlatform')}</dt>
|
||||||
|
<dd>{skill.platform ?? '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{t('integrations.skillsTreePreviewType')}</dt>
|
||||||
|
<dd>{skill.previewType}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{t('integrations.skillsTreeDesignSystem')}</dt>
|
||||||
|
<dd>
|
||||||
|
{skill.designSystemRequired
|
||||||
|
? t('integrations.skillsTreeDesignSystemRequired')
|
||||||
|
: t('integrations.skillsTreeDesignSystemOptional')}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{t('integrations.skillsTreeSource')}</dt>
|
||||||
|
<dd>{skill.source ?? 'built-in'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{prompt ? (
|
||||||
|
<div className="integrations-skills-tree__prompt">
|
||||||
|
<span>{t('integrations.skillsTreeExamplePrompt')}</span>
|
||||||
|
<p>{prompt}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillSummaryToTreeSkill(skill: SkillSummary): SkillCatalogTreeSkill {
|
||||||
|
return buildSkillCatalogTree([skill]).modes[0]!.scenarios[0]!.skills[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillCatalogScenario(skill: SkillSummary): string {
|
||||||
|
return skill.scenario?.trim() || 'general';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSkillCatalogFilterOptions(skills: SkillSummary[]): SkillCatalogFilterOptions {
|
||||||
|
return {
|
||||||
|
modes: buildSkillCatalogFacetOptions(skills.map((skill) => skill.mode)),
|
||||||
|
scenarios: buildSkillCatalogFacetOptions(skills.map((skill) => skillCatalogScenario(skill))),
|
||||||
|
categories: buildSkillCatalogFacetOptions(skills.map((skill) => skill.category)),
|
||||||
|
platforms: buildSkillCatalogFacetOptions(skills.map((skill) => skill.platform)),
|
||||||
|
previewTypes: buildSkillCatalogFacetOptions(skills.map((skill) => skill.previewType)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSkillCatalogFacetOptions(values: Array<string | null | undefined>): SkillCatalogFilterOption[] {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
values.forEach((value) => {
|
||||||
|
const id = value?.trim();
|
||||||
|
if (!id) return;
|
||||||
|
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(counts, ([id, count]) => ({
|
||||||
|
id,
|
||||||
|
label: labelSkillCatalogFacet(id),
|
||||||
|
count,
|
||||||
|
})).sort((left, right) => {
|
||||||
|
if (left.id === 'general') return -1;
|
||||||
|
if (right.id === 'general') return 1;
|
||||||
|
return left.label.localeCompare(right.label, undefined, { sensitivity: 'base' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelSkillCatalogFacet(value: string): string {
|
||||||
|
return value
|
||||||
|
.split(/[-_\s]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
function integrationTabLabel(id: IntegrationTab, t: ReturnType<typeof useT>): string {
|
function integrationTabLabel(id: IntegrationTab, t: ReturnType<typeof useT>): string {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 'mcp': return t('integrations.tabLabel.mcp');
|
case 'mcp': return t('integrations.tabLabel.mcp');
|
||||||
|
|
@ -205,7 +963,7 @@ function integrationTabHint(id: IntegrationTab, t: ReturnType<typeof useT>): str
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 'mcp': return t('integrations.tabHint.mcp');
|
case 'mcp': return t('integrations.tabHint.mcp');
|
||||||
case 'connectors': return t('integrations.tabHint.connectors');
|
case 'connectors': return t('integrations.tabHint.connectors');
|
||||||
case 'skills': return t('tasks.comingSoon');
|
case 'skills': return t('integrations.skillsTreeView');
|
||||||
case 'use-everywhere': return t('integrations.tabHint.useEverywhere');
|
case 'use-everywhere': return t('integrations.tabHint.useEverywhere');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -684,8 +684,39 @@ export const en: Dict = {
|
||||||
'integrations.tabHint.mcp': 'External tools',
|
'integrations.tabHint.mcp': 'External tools',
|
||||||
'integrations.tabHint.connectors': 'Accounts and APIs',
|
'integrations.tabHint.connectors': 'Accounts and APIs',
|
||||||
'integrations.tabHint.useEverywhere': 'CLI, HTTP, MCP',
|
'integrations.tabHint.useEverywhere': 'CLI, HTTP, MCP',
|
||||||
'integrations.skillsTitle': 'Skills integrations',
|
'integrations.skillsTitle': 'Skill tree',
|
||||||
'integrations.skillsBody': 'Skill-level integration management is being carried over from another branch. This tab is reserved so MCP, Connectors, and future Skills setup live in the same Integration route.',
|
'integrations.skillsBody': 'Browse the installed Open Design skills by mode and scenario. Select any node to inspect its platform, preview type, design-system requirement, and example prompt.',
|
||||||
|
'integrations.skillsViewMode': 'Skills catalog view',
|
||||||
|
'integrations.skillsTreeView': 'Skill tree',
|
||||||
|
'integrations.skillsListView': 'List view',
|
||||||
|
'integrations.skillsSearch': 'Search skills...',
|
||||||
|
'integrations.skillsFilters': 'Skill filters',
|
||||||
|
'integrations.skillsFilterMode': 'Mode',
|
||||||
|
'integrations.skillsFilterScenario': 'Scenario',
|
||||||
|
'integrations.skillsFilterCategory': 'Category',
|
||||||
|
'integrations.skillsFilterPlatform': 'Platform',
|
||||||
|
'integrations.skillsFilterPreview': 'Preview',
|
||||||
|
'integrations.skillsFilterDesignSystem': 'Design system',
|
||||||
|
'integrations.skillsFilterAll': 'All',
|
||||||
|
'integrations.skillsLoading': 'Loading skills...',
|
||||||
|
'integrations.skillsLoadFailed': 'Could not load skills. Make sure the local daemon is running, then try again.',
|
||||||
|
'integrations.skillsNoResults': 'No skills match your search.',
|
||||||
|
'integrations.skillsNoFilterResults': 'No skills match these filters.',
|
||||||
|
'integrations.skillsListEmpty': 'Select a skill row to inspect it.',
|
||||||
|
'integrations.skillsTreeEmpty': 'Select a skill node to inspect it.',
|
||||||
|
'integrations.skillsTreeGuideMode': 'MODE',
|
||||||
|
'integrations.skillsTreeGuideScenario': 'SCENE',
|
||||||
|
'integrations.skillsTreeGuideSkill': 'SKILL',
|
||||||
|
'integrations.skillsTreeLegendMode': 'Mode',
|
||||||
|
'integrations.skillsTreeLegendScenario': 'Scenario',
|
||||||
|
'integrations.skillsTreeLegendSkill': 'Skill',
|
||||||
|
'integrations.skillsTreePlatform': 'Platform',
|
||||||
|
'integrations.skillsTreePreviewType': 'Preview',
|
||||||
|
'integrations.skillsTreeDesignSystem': 'Design system',
|
||||||
|
'integrations.skillsTreeDesignSystemRequired': 'Required',
|
||||||
|
'integrations.skillsTreeDesignSystemOptional': 'Optional',
|
||||||
|
'integrations.skillsTreeSource': 'Source',
|
||||||
|
'integrations.skillsTreeExamplePrompt': 'Example prompt',
|
||||||
'mcpClient.title': 'External MCP servers',
|
'mcpClient.title': 'External MCP servers',
|
||||||
'mcpClient.subtitle': 'Third-party tools for your coding agent.',
|
'mcpClient.subtitle': 'Third-party tools for your coding agent.',
|
||||||
'mcpClient.addServer': 'Add server',
|
'mcpClient.addServer': 'Add server',
|
||||||
|
|
|
||||||
|
|
@ -684,8 +684,39 @@ export const zhCN: Dict = {
|
||||||
'integrations.tabHint.mcp': '外部工具',
|
'integrations.tabHint.mcp': '外部工具',
|
||||||
'integrations.tabHint.connectors': '账号和 API',
|
'integrations.tabHint.connectors': '账号和 API',
|
||||||
'integrations.tabHint.useEverywhere': 'CLI、HTTP、MCP',
|
'integrations.tabHint.useEverywhere': 'CLI、HTTP、MCP',
|
||||||
'integrations.skillsTitle': '技能集成',
|
'integrations.skillsTitle': '技能树',
|
||||||
'integrations.skillsBody': '技能级集成管理正在从另一个分支迁移过来。此标签页预留给 MCP、连接器和未来技能设置,使它们位于同一个集成路由中。',
|
'integrations.skillsBody': '按模式和场景浏览已安装的 Open Design 技能。选择任意节点即可查看平台、预览类型、设计系统要求和示例提示词。',
|
||||||
|
'integrations.skillsViewMode': '技能目录视图',
|
||||||
|
'integrations.skillsTreeView': '技能树',
|
||||||
|
'integrations.skillsListView': '列表视图',
|
||||||
|
'integrations.skillsSearch': '搜索技能...',
|
||||||
|
'integrations.skillsFilters': '技能筛选',
|
||||||
|
'integrations.skillsFilterMode': '模式',
|
||||||
|
'integrations.skillsFilterScenario': '场景',
|
||||||
|
'integrations.skillsFilterCategory': '分类',
|
||||||
|
'integrations.skillsFilterPlatform': '平台',
|
||||||
|
'integrations.skillsFilterPreview': '预览',
|
||||||
|
'integrations.skillsFilterDesignSystem': '设计系统',
|
||||||
|
'integrations.skillsFilterAll': '全部',
|
||||||
|
'integrations.skillsLoading': '正在加载技能...',
|
||||||
|
'integrations.skillsLoadFailed': '无法加载技能。请确认本地 daemon 正在运行,然后重试。',
|
||||||
|
'integrations.skillsNoResults': '没有匹配的技能。',
|
||||||
|
'integrations.skillsNoFilterResults': '没有匹配这些筛选条件的技能。',
|
||||||
|
'integrations.skillsListEmpty': '选择一个技能行查看详情。',
|
||||||
|
'integrations.skillsTreeEmpty': '选择一个技能节点查看详情。',
|
||||||
|
'integrations.skillsTreeGuideMode': '模式',
|
||||||
|
'integrations.skillsTreeGuideScenario': '场景',
|
||||||
|
'integrations.skillsTreeGuideSkill': '技能',
|
||||||
|
'integrations.skillsTreeLegendMode': '模式',
|
||||||
|
'integrations.skillsTreeLegendScenario': '场景',
|
||||||
|
'integrations.skillsTreeLegendSkill': '技能',
|
||||||
|
'integrations.skillsTreePlatform': '平台',
|
||||||
|
'integrations.skillsTreePreviewType': '预览',
|
||||||
|
'integrations.skillsTreeDesignSystem': '设计系统',
|
||||||
|
'integrations.skillsTreeDesignSystemRequired': '需要',
|
||||||
|
'integrations.skillsTreeDesignSystemOptional': '可选',
|
||||||
|
'integrations.skillsTreeSource': '来源',
|
||||||
|
'integrations.skillsTreeExamplePrompt': '示例提示词',
|
||||||
'mcpClient.title': '外部 MCP 服务器',
|
'mcpClient.title': '外部 MCP 服务器',
|
||||||
'mcpClient.subtitle': '供编码智能体使用的第三方工具。',
|
'mcpClient.subtitle': '供编码智能体使用的第三方工具。',
|
||||||
'mcpClient.addServer': '添加服务器',
|
'mcpClient.addServer': '添加服务器',
|
||||||
|
|
|
||||||
|
|
@ -610,8 +610,39 @@ export const zhTW: Dict = {
|
||||||
'integrations.tabHint.mcp': '外部工具',
|
'integrations.tabHint.mcp': '外部工具',
|
||||||
'integrations.tabHint.connectors': '帳號與 API',
|
'integrations.tabHint.connectors': '帳號與 API',
|
||||||
'integrations.tabHint.useEverywhere': 'CLI、HTTP、MCP',
|
'integrations.tabHint.useEverywhere': 'CLI、HTTP、MCP',
|
||||||
'integrations.skillsTitle': '技能整合',
|
'integrations.skillsTitle': '技能樹',
|
||||||
'integrations.skillsBody': '技能層級的整合管理正從另一個分支搬移過來。此分頁預留給 MCP、連接器與未來的技能設定,讓它們集中在同一個整合路由中。',
|
'integrations.skillsBody': '依模式與場景瀏覽已安裝的 Open Design 技能。選取任一節點即可查看平台、預覽類型、設計系統需求與範例提示詞。',
|
||||||
|
'integrations.skillsViewMode': '技能目錄視圖',
|
||||||
|
'integrations.skillsTreeView': '技能樹',
|
||||||
|
'integrations.skillsListView': '列表視圖',
|
||||||
|
'integrations.skillsSearch': '搜尋技能...',
|
||||||
|
'integrations.skillsFilters': '技能篩選',
|
||||||
|
'integrations.skillsFilterMode': '模式',
|
||||||
|
'integrations.skillsFilterScenario': '場景',
|
||||||
|
'integrations.skillsFilterCategory': '分類',
|
||||||
|
'integrations.skillsFilterPlatform': '平台',
|
||||||
|
'integrations.skillsFilterPreview': '預覽',
|
||||||
|
'integrations.skillsFilterDesignSystem': '設計系統',
|
||||||
|
'integrations.skillsFilterAll': '全部',
|
||||||
|
'integrations.skillsLoading': '正在載入技能...',
|
||||||
|
'integrations.skillsLoadFailed': '無法載入技能。請確認本機 daemon 正在執行,然後再試一次。',
|
||||||
|
'integrations.skillsNoResults': '沒有符合的技能。',
|
||||||
|
'integrations.skillsNoFilterResults': '沒有符合這些篩選條件的技能。',
|
||||||
|
'integrations.skillsListEmpty': '選取一個技能列以查看詳細資訊。',
|
||||||
|
'integrations.skillsTreeEmpty': '選取一個技能節點以查看詳細資訊。',
|
||||||
|
'integrations.skillsTreeGuideMode': '模式',
|
||||||
|
'integrations.skillsTreeGuideScenario': '場景',
|
||||||
|
'integrations.skillsTreeGuideSkill': '技能',
|
||||||
|
'integrations.skillsTreeLegendMode': '模式',
|
||||||
|
'integrations.skillsTreeLegendScenario': '場景',
|
||||||
|
'integrations.skillsTreeLegendSkill': '技能',
|
||||||
|
'integrations.skillsTreePlatform': '平台',
|
||||||
|
'integrations.skillsTreePreviewType': '預覽',
|
||||||
|
'integrations.skillsTreeDesignSystem': '設計系統',
|
||||||
|
'integrations.skillsTreeDesignSystemRequired': '需要',
|
||||||
|
'integrations.skillsTreeDesignSystemOptional': '可選',
|
||||||
|
'integrations.skillsTreeSource': '來源',
|
||||||
|
'integrations.skillsTreeExamplePrompt': '範例提示詞',
|
||||||
'mcpClient.title': '外部 MCP 伺服器',
|
'mcpClient.title': '外部 MCP 伺服器',
|
||||||
'mcpClient.subtitle': '提供給編碼智能體使用的第三方工具。',
|
'mcpClient.subtitle': '提供給編碼智能體使用的第三方工具。',
|
||||||
'mcpClient.addServer': '新增伺服器',
|
'mcpClient.addServer': '新增伺服器',
|
||||||
|
|
|
||||||
|
|
@ -998,6 +998,37 @@ export interface Dict {
|
||||||
'integrations.tabHint.useEverywhere': string;
|
'integrations.tabHint.useEverywhere': string;
|
||||||
'integrations.skillsTitle': string;
|
'integrations.skillsTitle': string;
|
||||||
'integrations.skillsBody': string;
|
'integrations.skillsBody': string;
|
||||||
|
'integrations.skillsViewMode': string;
|
||||||
|
'integrations.skillsTreeView': string;
|
||||||
|
'integrations.skillsListView': string;
|
||||||
|
'integrations.skillsSearch': string;
|
||||||
|
'integrations.skillsFilters': string;
|
||||||
|
'integrations.skillsFilterMode': string;
|
||||||
|
'integrations.skillsFilterScenario': string;
|
||||||
|
'integrations.skillsFilterCategory': string;
|
||||||
|
'integrations.skillsFilterPlatform': string;
|
||||||
|
'integrations.skillsFilterPreview': string;
|
||||||
|
'integrations.skillsFilterDesignSystem': string;
|
||||||
|
'integrations.skillsFilterAll': string;
|
||||||
|
'integrations.skillsLoading': string;
|
||||||
|
'integrations.skillsLoadFailed': string;
|
||||||
|
'integrations.skillsNoResults': string;
|
||||||
|
'integrations.skillsNoFilterResults': string;
|
||||||
|
'integrations.skillsListEmpty': string;
|
||||||
|
'integrations.skillsTreeEmpty': string;
|
||||||
|
'integrations.skillsTreeGuideMode': string;
|
||||||
|
'integrations.skillsTreeGuideScenario': string;
|
||||||
|
'integrations.skillsTreeGuideSkill': string;
|
||||||
|
'integrations.skillsTreeLegendMode': string;
|
||||||
|
'integrations.skillsTreeLegendScenario': string;
|
||||||
|
'integrations.skillsTreeLegendSkill': string;
|
||||||
|
'integrations.skillsTreePlatform': string;
|
||||||
|
'integrations.skillsTreePreviewType': string;
|
||||||
|
'integrations.skillsTreeDesignSystem': string;
|
||||||
|
'integrations.skillsTreeDesignSystemRequired': string;
|
||||||
|
'integrations.skillsTreeDesignSystemOptional': string;
|
||||||
|
'integrations.skillsTreeSource': string;
|
||||||
|
'integrations.skillsTreeExamplePrompt': string;
|
||||||
'mcpClient.title': string;
|
'mcpClient.title': string;
|
||||||
'mcpClient.subtitle': string;
|
'mcpClient.subtitle': string;
|
||||||
'mcpClient.addServer': string;
|
'mcpClient.addServer': string;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import type {
|
||||||
ImportLocalDesignSystemResponse,
|
ImportLocalDesignSystemResponse,
|
||||||
ReplaceProjectWorkingDirResponse,
|
ReplaceProjectWorkingDirResponse,
|
||||||
} from '@open-design/contracts';
|
} from '@open-design/contracts';
|
||||||
|
import { parseSkillSummaries } from '@open-design/contracts';
|
||||||
import type {
|
import type {
|
||||||
AgentInfo,
|
AgentInfo,
|
||||||
AppVersionInfo,
|
AppVersionInfo,
|
||||||
|
|
@ -100,13 +101,21 @@ export async function fetchAgents(options?: { throwOnError?: boolean }): Promise
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSkills(): Promise<SkillSummary[]> {
|
export async function fetchSkills(options?: { throwOnError?: boolean }): Promise<SkillSummary[]> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/skills');
|
const resp = await fetch('/api/skills');
|
||||||
if (!resp.ok) return [];
|
if (!resp.ok) {
|
||||||
const json = (await resp.json()) as { skills: SkillSummary[] };
|
if (options?.throwOnError) throw new Error(`skills ${resp.status}`);
|
||||||
return json.skills ?? [];
|
return [];
|
||||||
} catch {
|
}
|
||||||
|
const json = await resp.json() as { skills?: unknown };
|
||||||
|
if (!Array.isArray(json.skills)) {
|
||||||
|
if (options?.throwOnError) throw new Error('skills response malformed');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return parseSkillSummaries(json.skills);
|
||||||
|
} catch (err) {
|
||||||
|
if (options?.throwOnError) throw err;
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,588 @@
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__tools {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: min(480px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__view-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__view-toggle button {
|
||||||
|
appearance: none;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: calc(var(--radius-sm) - 3px);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__view-toggle button:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__view-toggle button.is-active {
|
||||||
|
background: var(--accent-tint);
|
||||||
|
color: var(--accent-strong, var(--accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__search {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: min(300px, 100%);
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__search input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(120px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__filter {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__filter span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__filter select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 34px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 9px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__summary p {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 760px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__summary span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 32px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border));
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--accent-strong, var(--accent));
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__empty {
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(260px, 320px);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__canvas {
|
||||||
|
overflow: auto;
|
||||||
|
min-height: 640px;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in srgb, var(--bg-panel) 78%, transparent), transparent 42%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 640px;
|
||||||
|
min-height: 420px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__list-row {
|
||||||
|
appearance: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__list-row:hover,
|
||||||
|
.integrations-skills-tree__list-row:focus-visible {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 34%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--accent-tint) 24%, var(--bg-panel));
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__list-row.is-active {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 56%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--accent-tint) 42%, var(--bg-panel));
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__list-title {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 760;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__list-description {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__list-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__list-meta span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__guide line {
|
||||||
|
stroke: color-mix(in srgb, var(--border) 78%, transparent);
|
||||||
|
stroke-dasharray: 3 8;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__guide text {
|
||||||
|
fill: color-mix(in srgb, var(--text-muted) 42%, transparent);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__edge path {
|
||||||
|
fill: none;
|
||||||
|
pointer-events: none;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__edge-base {
|
||||||
|
opacity: 0.72;
|
||||||
|
stroke: color-mix(in srgb, var(--text-muted) 34%, var(--border));
|
||||||
|
stroke-width: 2.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__edge-aura {
|
||||||
|
opacity: 0;
|
||||||
|
stroke: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||||
|
stroke-width: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__edge.is-active .integrations-skills-tree__edge-aura {
|
||||||
|
opacity: 0.28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__edge.is-active .integrations-skills-tree__edge-base {
|
||||||
|
opacity: 1;
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 2.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-node {
|
||||||
|
cursor: default;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-node[role="button"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-node.is-branch {
|
||||||
|
opacity: 0.86;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-core {
|
||||||
|
fill: color-mix(in srgb, var(--bg-panel) 86%, black);
|
||||||
|
stroke: color-mix(in srgb, var(--accent) 34%, var(--border));
|
||||||
|
stroke-width: 1.4;
|
||||||
|
transition: fill 160ms ease, stroke 160ms ease, stroke-width 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-node.is-mode .integrations-skills-tree__svg-core {
|
||||||
|
fill: color-mix(in srgb, var(--bg-panel) 82%, var(--border));
|
||||||
|
stroke: color-mix(in srgb, var(--text-muted) 30%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-node.is-scenario .integrations-skills-tree__svg-core {
|
||||||
|
fill: color-mix(in srgb, var(--bg-panel) 76%, var(--border));
|
||||||
|
stroke: color-mix(in srgb, var(--text-muted) 28%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-node.is-interactive.is-active .integrations-skills-tree__svg-core,
|
||||||
|
.integrations-skills-tree__svg-node.is-interactive[role="button"]:hover .integrations-skills-tree__svg-core,
|
||||||
|
.integrations-skills-tree__svg-node.is-interactive[role="button"]:focus-visible .integrations-skills-tree__svg-core {
|
||||||
|
fill: color-mix(in srgb, var(--accent-tint) 68%, var(--bg));
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 2.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-node.is-branch.is-on-path .integrations-skills-tree__svg-core {
|
||||||
|
stroke: color-mix(in srgb, var(--accent) 42%, var(--border));
|
||||||
|
stroke-width: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-node.is-branch .integrations-skills-tree__svg-label {
|
||||||
|
fill: color-mix(in srgb, var(--text-strong) 86%, var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-ring,
|
||||||
|
.integrations-skills-tree__svg-branch-ring,
|
||||||
|
.integrations-skills-tree__svg-affordance,
|
||||||
|
.integrations-skills-tree__svg-pulse {
|
||||||
|
fill: none;
|
||||||
|
stroke: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-affordance {
|
||||||
|
opacity: 0.62;
|
||||||
|
stroke: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||||
|
stroke-width: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-node.is-interactive[role="button"]:hover .integrations-skills-tree__svg-affordance,
|
||||||
|
.integrations-skills-tree__svg-node.is-interactive[role="button"]:focus-visible .integrations-skills-tree__svg-affordance,
|
||||||
|
.integrations-skills-tree__svg-node.is-interactive.is-active .integrations-skills-tree__svg-affordance {
|
||||||
|
opacity: 1;
|
||||||
|
stroke: color-mix(in srgb, var(--accent) 62%, transparent);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-branch-ring {
|
||||||
|
stroke: color-mix(in srgb, var(--text-muted) 22%, transparent);
|
||||||
|
stroke-dasharray: 2 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-pulse {
|
||||||
|
opacity: 0.42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-pulse {
|
||||||
|
animation: integrations-skill-pulse 2.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-glow {
|
||||||
|
opacity: 0.36;
|
||||||
|
fill: color-mix(in srgb, var(--accent) 42%, transparent);
|
||||||
|
filter: url("#integrations-skill-node-glow");
|
||||||
|
animation: integrations-skill-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-label,
|
||||||
|
.integrations-skills-tree__svg-sub {
|
||||||
|
text-anchor: middle;
|
||||||
|
dominant-baseline: middle;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-label {
|
||||||
|
fill: var(--text-strong);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-node.is-mode .integrations-skills-tree__svg-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 820;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-node.is-scenario .integrations-skills-tree__svg-label {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-node.is-skill .integrations-skills-tree__svg-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-sub {
|
||||||
|
fill: var(--text-muted);
|
||||||
|
font-size: 9.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-node.is-mode .integrations-skills-tree__svg-sub {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__svg-node.is-scenario .integrations-skills-tree__svg-sub,
|
||||||
|
.integrations-skills-tree__svg-node.is-skill .integrations-skills-tree__svg-sub {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__legend {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__legend span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__legend i {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__legend .is-mode {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__legend .is-scenario {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes integrations-skill-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.42;
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes integrations-skill-glow {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.34;
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.12;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__detail {
|
||||||
|
position: sticky;
|
||||||
|
top: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__detail.is-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__detail-kicker {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: var(--accent-strong, var(--accent));
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__detail h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__detail > p:not(.integrations-skills-tree__detail-kicker) {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__detail dl {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 14px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__detail dl div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__detail dt {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__detail dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__prompt {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__prompt span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__prompt p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.integrations-view__hero {
|
.integrations-view__hero {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -198,4 +780,42 @@
|
||||||
.integrations-view__coming-soon {
|
.integrations-view__coming-soon {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__head,
|
||||||
|
.integrations-skills-tree__summary {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__tools {
|
||||||
|
justify-content: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__view-toggle,
|
||||||
|
.integrations-skills-tree__search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__view-toggle button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__filters {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations-skills-tree__detail {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.integrations-skills-tree__filters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
287
apps/web/tests/components/IntegrationsView.skills.test.tsx
Normal file
287
apps/web/tests/components/IntegrationsView.skills.test.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { IntegrationsView } from '../../src/components/IntegrationsView';
|
||||||
|
import { I18nProvider, type Locale } from '../../src/i18n';
|
||||||
|
import type { AppConfig, SkillSummary } from '../../src/types';
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
const TEST_CONFIG: AppConfig = {
|
||||||
|
mode: 'daemon',
|
||||||
|
apiKey: '',
|
||||||
|
baseUrl: '',
|
||||||
|
model: '',
|
||||||
|
agentId: null,
|
||||||
|
skillId: null,
|
||||||
|
designSystemId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function skill(overrides: Partial<SkillSummary>): SkillSummary {
|
||||||
|
return {
|
||||||
|
id: 'skill',
|
||||||
|
name: 'Skill',
|
||||||
|
description: 'A reusable skill.',
|
||||||
|
triggers: [],
|
||||||
|
mode: 'prototype',
|
||||||
|
previewType: 'html',
|
||||||
|
designSystemRequired: true,
|
||||||
|
defaultFor: [],
|
||||||
|
upstream: null,
|
||||||
|
hasBody: true,
|
||||||
|
examplePrompt: '',
|
||||||
|
aggregatesExamples: false,
|
||||||
|
source: 'built-in',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSkillsIntegration(
|
||||||
|
skills: SkillSummary[],
|
||||||
|
options: { skillsStatus?: number; skillsBody?: unknown; locale?: Locale } = {},
|
||||||
|
) {
|
||||||
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = input.toString();
|
||||||
|
if (url === '/api/skills') {
|
||||||
|
return new Response(JSON.stringify(options.skillsBody ?? { skills }), {
|
||||||
|
status: options.skillsStatus ?? 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({}), { status: 404 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<I18nProvider initial={options.locale ?? 'en'}>
|
||||||
|
<IntegrationsView
|
||||||
|
config={TEST_CONFIG}
|
||||||
|
initialTab="skills"
|
||||||
|
onPersistComposioKey={() => undefined}
|
||||||
|
/>
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('IntegrationsView skills tree', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders skills as a read-only list view by default and shows row metadata', async () => {
|
||||||
|
renderSkillsIntegration([
|
||||||
|
skill({
|
||||||
|
id: 'dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
description: 'Dense operations UI.',
|
||||||
|
scenario: 'operation',
|
||||||
|
platform: 'desktop',
|
||||||
|
previewType: 'html',
|
||||||
|
examplePrompt: 'Build an operations dashboard.',
|
||||||
|
}),
|
||||||
|
skill({
|
||||||
|
id: 'pitch-deck',
|
||||||
|
name: 'Pitch Deck',
|
||||||
|
mode: 'deck',
|
||||||
|
scenario: 'product',
|
||||||
|
previewType: 'pptx',
|
||||||
|
designSystemRequired: false,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('integrations-skill-list-row-dashboard')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('integrations-skill-list-row-pitch-deck')).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: 'List view' }).getAttribute('class')).toContain('is-active');
|
||||||
|
expect(screen.queryByTestId('integrations-skill-node-dashboard')).toBeNull();
|
||||||
|
expect(screen.queryByTestId('integrations-skill-detail')).toBeNull();
|
||||||
|
expect(screen.getByText('Select a skill row to inspect it.')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('Select a skill node to inspect it.')).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('integrations-skill-list-row-pitch-deck'));
|
||||||
|
|
||||||
|
const detail = await screen.findByTestId('integrations-skill-detail');
|
||||||
|
expect(within(detail).getByText('Pitch Deck')).toBeTruthy();
|
||||||
|
expect(within(detail).getByText('pptx')).toBeTruthy();
|
||||||
|
expect(within(detail).getByText('Optional')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to the mode/scenario tree view and reuses the detail panel', async () => {
|
||||||
|
renderSkillsIntegration([
|
||||||
|
skill({
|
||||||
|
id: 'dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
description: 'Dense operations UI.',
|
||||||
|
scenario: 'operation',
|
||||||
|
category: 'operations',
|
||||||
|
platform: 'desktop',
|
||||||
|
}),
|
||||||
|
skill({
|
||||||
|
id: 'pitch-deck',
|
||||||
|
name: 'Pitch Deck',
|
||||||
|
mode: 'deck',
|
||||||
|
scenario: 'product',
|
||||||
|
category: 'sales',
|
||||||
|
previewType: 'pptx',
|
||||||
|
designSystemRequired: false,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await screen.findByTestId('integrations-skill-list-row-dashboard');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Skill tree' }));
|
||||||
|
|
||||||
|
expect(screen.getByText('Prototype')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Operation')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Deck')).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId('integrations-skill-list-row-dashboard')).toBeNull();
|
||||||
|
expect(screen.getByText('Select a skill node to inspect it.')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Operation').closest('g')?.getAttribute('class')).toContain('is-branch');
|
||||||
|
expect(screen.getByText('Operation').closest('g')?.getAttribute('role')).toBeNull();
|
||||||
|
expect(screen.getByTestId('integrations-skill-node-pitch-deck').getAttribute('class')).toContain('is-interactive');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('integrations-skill-node-pitch-deck'));
|
||||||
|
|
||||||
|
const detail = await screen.findByTestId('integrations-skill-detail');
|
||||||
|
expect(within(detail).getByText('Pitch Deck')).toBeTruthy();
|
||||||
|
expect(within(detail).getByText('pptx')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies catalog facet filters to the same result set', async () => {
|
||||||
|
renderSkillsIntegration([
|
||||||
|
skill({
|
||||||
|
id: 'dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
scenario: 'operation',
|
||||||
|
category: 'operations',
|
||||||
|
platform: 'desktop',
|
||||||
|
}),
|
||||||
|
skill({
|
||||||
|
id: 'poster',
|
||||||
|
name: 'Poster',
|
||||||
|
scenario: 'marketing',
|
||||||
|
category: 'marketing',
|
||||||
|
platform: 'mobile',
|
||||||
|
designSystemRequired: false,
|
||||||
|
}),
|
||||||
|
skill({
|
||||||
|
id: 'pitch-deck',
|
||||||
|
name: 'Pitch Deck',
|
||||||
|
mode: 'deck',
|
||||||
|
scenario: 'product',
|
||||||
|
category: 'sales',
|
||||||
|
platform: 'desktop',
|
||||||
|
previewType: 'pptx',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await screen.findByTestId('integrations-skill-list-row-dashboard');
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Mode'), { target: { value: 'prototype' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Scenario'), { target: { value: 'operation' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Category'), { target: { value: 'operations' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Platform'), { target: { value: 'desktop' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Design system'), { target: { value: 'required' } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('integrations-skill-list-row-dashboard')).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId('integrations-skill-list-row-poster')).toBeNull();
|
||||||
|
expect(screen.queryByTestId('integrations-skill-list-row-pitch-deck')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears stale selected detail when search and filters hide the selected skill', async () => {
|
||||||
|
renderSkillsIntegration([
|
||||||
|
skill({ id: 'dashboard', name: 'Dashboard', scenario: 'operation', platform: 'desktop' }),
|
||||||
|
skill({ id: 'poster', name: 'Poster', scenario: 'marketing', platform: 'mobile' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await screen.findByTestId('integrations-skill-list-row-dashboard');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Skill tree' }));
|
||||||
|
|
||||||
|
await screen.findByTestId('integrations-skill-node-dashboard');
|
||||||
|
fireEvent.click(screen.getByTestId('integrations-skill-node-dashboard'));
|
||||||
|
expect(screen.getByTestId('integrations-skill-detail').textContent).toContain('Dashboard');
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Platform'), {
|
||||||
|
target: { value: 'mobile' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Search skills...'), {
|
||||||
|
target: { value: 'poster' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId('integrations-skill-node-dashboard')).toBeNull();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('integrations-skill-node-poster')).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId('integrations-skill-detail')).toBeNull();
|
||||||
|
expect(screen.getByText('Select a skill node to inspect it.')).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('integrations-skill-node-poster'));
|
||||||
|
expect(screen.getByTestId('integrations-skill-detail').textContent).toContain('Poster');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an empty state when search and filters remove every skill', async () => {
|
||||||
|
renderSkillsIntegration([
|
||||||
|
skill({ id: 'dashboard', name: 'Dashboard', scenario: 'operation', platform: 'desktop' }),
|
||||||
|
skill({ id: 'poster', name: 'Poster', scenario: 'marketing', platform: 'mobile' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await screen.findByTestId('integrations-skill-list-row-dashboard');
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('Platform'), {
|
||||||
|
target: { value: 'desktop' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('Search skills...'), {
|
||||||
|
target: { value: 'poster' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText('No skills match these filters.')).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId('integrations-skill-detail')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a load failure instead of an empty filter state when skills fail to load', async () => {
|
||||||
|
renderSkillsIntegration([], {
|
||||||
|
skillsStatus: 500,
|
||||||
|
skillsBody: { error: { message: 'boom' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText('Could not load skills. Make sure the local daemon is running, then try again.')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('No skills match these filters.')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a load failure when skills payload contains malformed entries', async () => {
|
||||||
|
renderSkillsIntegration([], {
|
||||||
|
skillsBody: {
|
||||||
|
skills: [
|
||||||
|
skill({ id: 'dashboard', name: 'Dashboard' }),
|
||||||
|
{ id: 'broken', mode: 'prototype' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText('Could not load skills. Make sure the local daemon is running, then try again.')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('No skills match these filters.')).toBeNull();
|
||||||
|
expect(screen.queryByTestId('integrations-skill-list-row-dashboard')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('localizes tree guide and legend labels', async () => {
|
||||||
|
renderSkillsIntegration([
|
||||||
|
skill({ id: 'dashboard', name: 'Dashboard', scenario: 'operation', platform: 'desktop' }),
|
||||||
|
], { locale: 'zh-CN' });
|
||||||
|
|
||||||
|
await screen.findByTestId('integrations-skill-list-row-dashboard');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '技能树' }));
|
||||||
|
|
||||||
|
await screen.findByTestId('integrations-skill-node-dashboard');
|
||||||
|
|
||||||
|
expect(screen.getAllByText('模式').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText('场景').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText('技能').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByText('Mode')).toBeNull();
|
||||||
|
expect(screen.queryByText('Scenario')).toBeNull();
|
||||||
|
expect(screen.queryByText('Skill')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
export interface AgentModelOption {
|
export interface AgentModelOption {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -148,6 +150,205 @@ export interface SkillsResponse {
|
||||||
skills: SkillSummary[];
|
skills: SkillSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SkillSummarySchema: z.ZodType<SkillSummary> = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
displayName: z.record(z.string()).optional(),
|
||||||
|
description: z.string(),
|
||||||
|
descriptionI18n: z.record(z.string()).optional(),
|
||||||
|
triggers: z.array(z.string()),
|
||||||
|
mode: z.enum([
|
||||||
|
'prototype',
|
||||||
|
'deck',
|
||||||
|
'template',
|
||||||
|
'design-system',
|
||||||
|
'image',
|
||||||
|
'video',
|
||||||
|
'audio',
|
||||||
|
]),
|
||||||
|
surface: z.enum(['web', 'image', 'video', 'audio']).optional(),
|
||||||
|
platform: z.enum(['desktop', 'mobile']).nullable().optional(),
|
||||||
|
scenario: z.string().nullable().optional(),
|
||||||
|
category: z.string().nullable().optional(),
|
||||||
|
source: z.enum(['built-in', 'user']).optional(),
|
||||||
|
previewType: z.string(),
|
||||||
|
designSystemRequired: z.boolean(),
|
||||||
|
defaultFor: z.array(z.string()),
|
||||||
|
upstream: z.string().nullable(),
|
||||||
|
featured: z.number().nullable().optional(),
|
||||||
|
fidelity: z.enum(['wireframe', 'high-fidelity']).nullable().optional(),
|
||||||
|
speakerNotes: z.boolean().nullable().optional(),
|
||||||
|
animations: z.boolean().nullable().optional(),
|
||||||
|
craftRequires: z.array(z.string()).optional(),
|
||||||
|
hasBody: z.boolean(),
|
||||||
|
examplePrompt: z.string(),
|
||||||
|
examplePromptI18n: z.record(z.string()).optional(),
|
||||||
|
aggregatesExamples: z.boolean(),
|
||||||
|
}).passthrough() as z.ZodType<SkillSummary>;
|
||||||
|
|
||||||
|
export const SkillSummaryArraySchema = z.array(SkillSummarySchema);
|
||||||
|
|
||||||
|
export function parseSkillSummaries(value: unknown): SkillSummary[] {
|
||||||
|
return SkillSummaryArraySchema.parse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSkillSummaries(value: unknown): value is SkillSummary[] {
|
||||||
|
return SkillSummaryArraySchema.safeParse(value).success;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillCatalogTreeSkill {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
mode: SkillSummary['mode'];
|
||||||
|
scenario: string;
|
||||||
|
platform: SkillSummary['platform'];
|
||||||
|
previewType: string;
|
||||||
|
designSystemRequired: boolean;
|
||||||
|
examplePrompt: string;
|
||||||
|
source: SkillSummary['source'];
|
||||||
|
featured: number | null;
|
||||||
|
defaultFor: string[];
|
||||||
|
category: string | null;
|
||||||
|
skill: SkillSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillCatalogTreeScenario {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
skills: SkillCatalogTreeSkill[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillCatalogTreeMode {
|
||||||
|
id: SkillSummary['mode'];
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
scenarios: SkillCatalogTreeScenario[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillCatalogTree {
|
||||||
|
total: number;
|
||||||
|
modes: SkillCatalogTreeMode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKILL_MODE_ORDER: readonly SkillSummary['mode'][] = [
|
||||||
|
'prototype',
|
||||||
|
'deck',
|
||||||
|
'template',
|
||||||
|
'design-system',
|
||||||
|
'image',
|
||||||
|
'video',
|
||||||
|
'audio',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function buildSkillCatalogTree(
|
||||||
|
skills: readonly SkillSummary[],
|
||||||
|
): SkillCatalogTree {
|
||||||
|
const modeBuckets = new Map<SkillSummary['mode'], Map<string, SkillCatalogTreeSkill[]>>();
|
||||||
|
|
||||||
|
for (const skill of skills) {
|
||||||
|
const scenario = normalizeTreeScenario(skill.scenario);
|
||||||
|
let scenarioBuckets = modeBuckets.get(skill.mode);
|
||||||
|
if (!scenarioBuckets) {
|
||||||
|
scenarioBuckets = new Map<string, SkillCatalogTreeSkill[]>();
|
||||||
|
modeBuckets.set(skill.mode, scenarioBuckets);
|
||||||
|
}
|
||||||
|
const bucket = scenarioBuckets.get(scenario) ?? [];
|
||||||
|
bucket.push({
|
||||||
|
id: skill.id,
|
||||||
|
name: skill.name,
|
||||||
|
description: skill.description,
|
||||||
|
mode: skill.mode,
|
||||||
|
scenario,
|
||||||
|
platform: skill.platform ?? null,
|
||||||
|
previewType: skill.previewType,
|
||||||
|
designSystemRequired: skill.designSystemRequired,
|
||||||
|
examplePrompt: skill.examplePrompt,
|
||||||
|
source: skill.source,
|
||||||
|
featured: skill.featured ?? null,
|
||||||
|
defaultFor: skill.defaultFor ?? [],
|
||||||
|
category: skill.category ?? null,
|
||||||
|
skill,
|
||||||
|
});
|
||||||
|
scenarioBuckets.set(scenario, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modes = [...modeBuckets.entries()]
|
||||||
|
.sort(([a], [b]) => compareMode(a, b))
|
||||||
|
.map(([mode, scenarioBuckets]) => {
|
||||||
|
const scenarios = [...scenarioBuckets.entries()]
|
||||||
|
.sort(([a], [b]) => compareScenario(a, b))
|
||||||
|
.map(([scenario, scenarioSkills]) => {
|
||||||
|
const sortedSkills = [...scenarioSkills].sort(compareTreeSkill);
|
||||||
|
return {
|
||||||
|
id: scenario,
|
||||||
|
label: labelFromSlug(scenario),
|
||||||
|
count: sortedSkills.length,
|
||||||
|
skills: sortedSkills,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: mode,
|
||||||
|
label: labelFromSlug(mode),
|
||||||
|
count: scenarios.reduce((total, scenario) => total + scenario.count, 0),
|
||||||
|
scenarios,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: skills.length,
|
||||||
|
modes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTreeScenario(scenario: SkillSummary['scenario']): string {
|
||||||
|
const normalized = typeof scenario === 'string' ? scenario.trim() : '';
|
||||||
|
return normalized.length > 0 ? normalized : 'general';
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareMode(a: SkillSummary['mode'], b: SkillSummary['mode']): number {
|
||||||
|
const aIndex = SKILL_MODE_ORDER.indexOf(a);
|
||||||
|
const bIndex = SKILL_MODE_ORDER.indexOf(b);
|
||||||
|
if (aIndex !== bIndex) return aIndex - bIndex;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareScenario(a: string, b: string): number {
|
||||||
|
if (a === 'general' && b !== 'general') return -1;
|
||||||
|
if (b === 'general' && a !== 'general') return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareTreeSkill(
|
||||||
|
a: SkillCatalogTreeSkill,
|
||||||
|
b: SkillCatalogTreeSkill,
|
||||||
|
): number {
|
||||||
|
const rankDiff = treeSkillRank(a) - treeSkillRank(b);
|
||||||
|
if (rankDiff !== 0) return rankDiff;
|
||||||
|
if (a.featured !== null || b.featured !== null) {
|
||||||
|
const featuredDiff = (a.featured ?? Number.MAX_SAFE_INTEGER) - (b.featured ?? Number.MAX_SAFE_INTEGER);
|
||||||
|
if (featuredDiff !== 0) return featuredDiff;
|
||||||
|
}
|
||||||
|
const nameDiff = a.name.localeCompare(b.name);
|
||||||
|
if (nameDiff !== 0) return nameDiff;
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function treeSkillRank(skill: SkillCatalogTreeSkill): number {
|
||||||
|
if (skill.defaultFor.length > 0) return 0;
|
||||||
|
if (skill.featured !== null) return 1;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFromSlug(slug: string): string {
|
||||||
|
return slug
|
||||||
|
.split('-')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
export interface SkillResponse {
|
export interface SkillResponse {
|
||||||
skill: SkillDetail;
|
skill: SkillDetail;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
132
packages/contracts/tests/skill-catalog-tree.test.ts
Normal file
132
packages/contracts/tests/skill-catalog-tree.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildSkillCatalogTree,
|
||||||
|
parseSkillSummaries,
|
||||||
|
type SkillSummary,
|
||||||
|
} from '../src/api/registry.js';
|
||||||
|
|
||||||
|
function skill(overrides: Partial<SkillSummary>): SkillSummary {
|
||||||
|
return {
|
||||||
|
id: 'skill',
|
||||||
|
name: 'Skill',
|
||||||
|
description: 'A reusable skill.',
|
||||||
|
triggers: [],
|
||||||
|
mode: 'prototype',
|
||||||
|
previewType: 'html',
|
||||||
|
designSystemRequired: true,
|
||||||
|
defaultFor: [],
|
||||||
|
upstream: null,
|
||||||
|
hasBody: true,
|
||||||
|
examplePrompt: '',
|
||||||
|
aggregatesExamples: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildSkillCatalogTree', () => {
|
||||||
|
it('groups skills by mode and scenario with deterministic labels', () => {
|
||||||
|
const tree = buildSkillCatalogTree([
|
||||||
|
skill({ id: 'deck', name: 'Deck', mode: 'deck', scenario: 'product' }),
|
||||||
|
skill({ id: 'web', name: 'Web', mode: 'prototype', scenario: 'design' }),
|
||||||
|
skill({ id: 'image', name: 'Image', mode: 'image', scenario: null }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(tree.total).toBe(3);
|
||||||
|
expect(tree.modes.map((mode) => mode.id)).toEqual([
|
||||||
|
'prototype',
|
||||||
|
'deck',
|
||||||
|
'image',
|
||||||
|
]);
|
||||||
|
expect(tree.modes[0]?.scenarios[0]).toMatchObject({
|
||||||
|
id: 'design',
|
||||||
|
label: 'Design',
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
expect(tree.modes[2]?.scenarios[0]).toMatchObject({
|
||||||
|
id: 'general',
|
||||||
|
label: 'General',
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts default and featured skills before regular skills stably', () => {
|
||||||
|
const tree = buildSkillCatalogTree([
|
||||||
|
skill({ id: 'regular-b', name: 'Regular B', scenario: 'design' }),
|
||||||
|
skill({
|
||||||
|
id: 'featured-two',
|
||||||
|
name: 'Featured two',
|
||||||
|
featured: 2,
|
||||||
|
scenario: 'design',
|
||||||
|
}),
|
||||||
|
skill({
|
||||||
|
id: 'default',
|
||||||
|
name: 'Default',
|
||||||
|
defaultFor: ['prototype'],
|
||||||
|
scenario: 'design',
|
||||||
|
}),
|
||||||
|
skill({
|
||||||
|
id: 'featured-one',
|
||||||
|
name: 'Featured one',
|
||||||
|
featured: 1,
|
||||||
|
scenario: 'design',
|
||||||
|
}),
|
||||||
|
skill({ id: 'regular-a', name: 'Regular A', scenario: 'design' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
tree.modes[0]?.scenarios[0]?.skills.map((treeSkill) => treeSkill.id),
|
||||||
|
).toEqual([
|
||||||
|
'default',
|
||||||
|
'featured-one',
|
||||||
|
'featured-two',
|
||||||
|
'regular-a',
|
||||||
|
'regular-b',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the original skill summary on leaf nodes', () => {
|
||||||
|
const source = skill({
|
||||||
|
id: 'dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
scenario: 'operation',
|
||||||
|
platform: 'desktop',
|
||||||
|
previewType: 'jsx',
|
||||||
|
designSystemRequired: false,
|
||||||
|
examplePrompt: 'Build an ops dashboard.',
|
||||||
|
category: 'ops-tools',
|
||||||
|
source: 'built-in',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = buildSkillCatalogTree([source]);
|
||||||
|
const leaf = tree.modes[0]?.scenarios[0]?.skills[0];
|
||||||
|
|
||||||
|
expect(leaf).toMatchObject({
|
||||||
|
id: 'dashboard',
|
||||||
|
platform: 'desktop',
|
||||||
|
previewType: 'jsx',
|
||||||
|
designSystemRequired: false,
|
||||||
|
examplePrompt: 'Build an ops dashboard.',
|
||||||
|
category: 'ops-tools',
|
||||||
|
source: 'built-in',
|
||||||
|
});
|
||||||
|
expect(leaf?.skill).toBe(source);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseSkillSummaries', () => {
|
||||||
|
it('accepts valid skill summary arrays', () => {
|
||||||
|
const summaries = parseSkillSummaries([
|
||||||
|
skill({ id: 'dashboard', name: 'Dashboard' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(summaries).toHaveLength(1);
|
||||||
|
expect(summaries[0]?.id).toBe('dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed skill summary array elements', () => {
|
||||||
|
expect(() => parseSkillSummaries([
|
||||||
|
skill({ id: 'dashboard', name: 'Dashboard' }),
|
||||||
|
{ id: 'broken', mode: 'prototype' },
|
||||||
|
])).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue