feat(plugins): merge Skill management into plugins

This commit is contained in:
inman committed 2026-09-07 18:36:33 +08:00
1 parent bb8a19eecf
commit ab9f889233
49 files changed
+461 -1218

No files matched your search

+32 -194
View File
@@ -10,7 +10,6 @@ import {
FileCode2,
FileText,
FolderKanban,
FolderOpen,
Layers3,
Lock,
Mic,
@@ -23,7 +22,6 @@ import {
Square,
Upload,
Volume2,
Wrench,
} from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
@@ -52,13 +50,12 @@ import {
type CreatedPartnerSubagent,
} from '@/stores/partner-profiles';
import { useProviderStore } from '@/stores/providers';
import { getSkillDisplayInfo } from '@/lib/skill-display';
type ModelState = 'using' | 'available' | 'pending' | 'locked';
export type DeliverableType = 'html' | 'markdown' | 'code' | 'table';
type MessageRole = 'user' | 'assistant';
type ChatMode = 'focus' | 'history';
type CharacterDrawerMode = 'models' | 'knowledge' | 'skills' | 'character' | 'createPartner' | null;
type CharacterDrawerMode = 'models' | 'knowledge' | 'character' | 'createPartner' | null;
const lightFieldClassName = 'border-border/70 bg-surface-input text-foreground placeholder:text-muted-foreground';
const whiteControlClassName = 'border-border/70 bg-background text-foreground hover:bg-surface-subtle';
@@ -94,28 +91,14 @@ type Skill = {
id: string;
name: string;
description: string;
prompt?: string;
inputs?: string[];
outputs?: string[];
guardrails?: string[];
appliesTo?: string[];
statusLabel: string;
location?: string;
content?: string;
entries?: SkillStructureEntry[];
};
type CodingSkillInfo = {
id: string;
name: string;
description?: string;
location: string;
content: string;
entries?: SkillStructureEntry[];
};
type SkillStructureEntry = { path: string; type: 'file' | 'directory' };
type KnowledgeFile = {
id: string;
title: string;
@@ -208,53 +191,6 @@ const demoModels: DisplayModel[] = [
},
];
const courseSkills: Skill[] = [
{
id: 'agent-browser',
name: '开发浏览器',
description: '打开、查看或调试本地及公网网页,读取 Console、Network、DOM 和样式信息。',
prompt: '只在需要检查或调试网页时使用开发浏览器,并根据真实页面证据说明结果。',
inputs: ['开发中的网页地址', '需要检查的页面状态'],
outputs: ['页面检查结果', 'Console/Network/DOM 证据', '调试建议'],
guardrails: ['只用于开发过程', '不把本地调试结果当成已发布作品', '不绕过项目浏览器工具访问页面'],
appliesTo: ['需要网页调试的项目伙伴'],
statusLabel: '平台内置 · 默认勾选',
},
{
id: 'grilling',
name: '方案质询',
description: '在重要工作开始前逐项澄清目标、范围和关键取舍,确认后再执行。',
prompt: '当任务涉及重要实现或设计决策时,先逐项确认关键选择,不要在未确认前修改项目。',
inputs: ['用户目标', '范围与约束', '验收标准'],
outputs: ['已确认的方案', '主要取舍', '剩余不确定性'],
guardrails: ['每轮只问一个问题', '先查代码事实', '确认前不执行实现'],
appliesTo: ['需要澄清复杂需求的项目伙伴'],
statusLabel: '平台内置 · 默认勾选',
},
{
id: 'planning-with-files',
name: '项目规划',
description: '为复杂任务维护可恢复的计划、发现和进展,直接记录在当前项目根目录。',
prompt: '复杂任务开始前在当前项目根目录建立并持续更新 task_plan.md、findings.md 和 progress.md。',
inputs: ['复杂任务目标', '阶段与成功标准', '代码和验证结果'],
outputs: ['可恢复的计划', '研究发现', '进展与验证记录'],
guardrails: ['简单任务不创建规划文件', '不记录密钥和敏感数据', '遵守项目 AGENTS.md'],
appliesTo: ['需要持续推进复杂任务的项目伙伴'],
statusLabel: '平台内置 · 默认勾选',
},
{
id: 'frontend-slides',
name: '项目演示',
description: '把项目内容整理成可播放的 16:9 HTML 幻灯片,不生成 PPTX 或云端发布。',
prompt: '当用户准备汇报、结题展示或项目介绍时,主动把真实项目内容整理成可播放的本地 HTML 幻灯片,并用浏览器检查布局和素材。',
inputs: ['项目目标与受众', '功能截图或素材', '想讲清楚的过程与结果'],
outputs: ['16:9 HTML 演示', '风格草图', '浏览器验收结果'],
guardrails: ['只生成本地 HTML', '不生成 PPTX', '不部署或上传到云端', '不编造项目数据'],
appliesTo: ['需要结题展示的项目伙伴'],
statusLabel: '平台内置 · 可手动选择',
},
];
const knowledgeFiles: KnowledgeFile[] = [
{
id: 'kid-html',
@@ -314,46 +250,18 @@ function getSubagentSkillIds(subagent: CreatedPartnerSubagent | null | undefined
}
function mapCodingSkillToDisplaySkill(skill: CodingSkillInfo): Skill {
const display = getSkillDisplayInfo(skill.name);
return {
id: skill.id,
name: display.name,
description: display.description,
prompt: skill.content.trim().split(/\r?\n/).find((line) => line.trim() && !line.startsWith('---'))?.trim() || '从本地技能目录加载。',
inputs: [],
outputs: [],
guardrails: [],
appliesTo: [],
statusLabel: '本地已安装',
location: skill.location,
content: skill.content,
entries: skill.entries,
name: skill.name,
description: skill.description ?? '',
};
}
function normalizeSelectedSkillIds(skillIds: string[], availableSkills: Skill[] = courseSkills): string[] {
function normalizeSelectedSkillIds(skillIds: string[], availableSkills: Skill[] = []): string[] {
const selected = new Set(skillIds);
return availableSkills.map((skill) => skill.id).filter((skillId) => selected.has(skillId));
}
function mergeInstalledSkills(runtimeSkills: Skill[]): Skill[] {
const runtimeById = new Map(runtimeSkills.map((skill) => [skill.id, skill]));
const bundledSkills = courseSkills.map((skill) => {
const runtimeSkill = runtimeById.get(skill.id);
return runtimeSkill
? {
...skill,
location: runtimeSkill.location,
statusLabel: runtimeSkill.statusLabel,
content: runtimeSkill.content,
entries: runtimeSkill.entries,
}
: skill;
});
const knownIds = new Set(bundledSkills.map((skill) => skill.id));
return [...bundledSkills, ...runtimeSkills.filter((skill) => !knownIds.has(skill.id))];
}
const fallbackCharacter: Character = {
id: 'custom',
name: '自定义伙伴',
@@ -728,35 +636,9 @@ function DrawerFrame({
);
}
function SkillDetail({ skill }: { skill: Skill }) {
const entries = skill.entries ?? [{ path: 'SKILL.md', type: 'file' as const }];
return (
<div data-testid="skill-detail-view" className="space-y-4">
<section data-testid="skill-structure" className="rounded-xl border border-foreground/15 bg-surface-tertiary p-4">
<div className="flex items-center gap-2"><FolderOpen className="h-4 w-4 text-brand" /><h3 className="font-semibold">Skill 内结构</h3></div>
<p className="mt-1 truncate text-[11px] font-medium text-muted-foreground" title={skill.location}>{skill.location ?? '运行时 Skill 目录'}</p>
<div className="mt-3 space-y-1 rounded-lg border border-foreground/10 bg-white p-2">
{entries.map((entry) => {
const depth = entry.path.split('/').length - 1;
return <div key={`${entry.type}:${entry.path}`} className="flex items-center gap-2 rounded-md py-1 text-xs font-medium text-foreground" style={{ paddingLeft: `${8 + depth * 14}px` }}>
{entry.type === 'directory' ? <FolderOpen className="h-3.5 w-3.5 shrink-0 text-brand" /> : <FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
<code className="truncate">{entry.type === 'directory' ? `${entry.path}/` : entry.path}</code>
</div>;
})}
</div>
</section>
<section data-testid="skill-main-file" className="rounded-xl border border-foreground/15 bg-white p-4">
<div className="flex items-center gap-2"><FileText className="h-4 w-4 text-brand" /><h3 className="font-semibold">SKILL.md</h3></div>
<pre className="mt-3 max-h-[min(58vh,680px)] overflow-auto whitespace-pre-wrap break-words rounded-lg border border-foreground/10 bg-surface-tertiary p-3 font-mono text-[11px] leading-5 text-foreground">{skill.content || 'SKILL.md 暂无内容。'}</pre>
</section>
</div>
);
}
export function CharacterScene() {
const navigate = useNavigate();
const [activeId, setActiveId] = useState<string | null>(null);
const [activeSkillId, setActiveSkillId] = useState<string | null>(null);
const [drawerMode, setDrawerMode] = useState<CharacterDrawerMode>(null);
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
const partnerSubagents = usePartnerProfilesStore((state) => state.subagents);
@@ -817,7 +699,7 @@ export function CharacterScene() {
const [knowledgeItems, setKnowledgeItems] = useState<KnowledgeFile[]>(knowledgeFiles);
const [knowledgeNotice, setKnowledgeNotice] = useState<string | null>(null);
const [knowledgeDragActive, setKnowledgeDragActive] = useState(false);
const [installedSkills, setInstalledSkills] = useState<Skill[]>(courseSkills);
const [installedSkills, setInstalledSkills] = useState<Skill[]>([]);
const [installedSkillsLoading, setInstalledSkillsLoading] = useState(false);
const [installedSkillsError, setInstalledSkillsError] = useState<string | null>(null);
const knowledgeFileInputRef = useRef<HTMLInputElement | null>(null);
@@ -827,7 +709,6 @@ export function CharacterScene() {
() => normalizeSelectedSkillIds(draftSkillIds, installedSkills),
[draftSkillIds, installedSkills],
);
const activeSkill = installedSkills.find((skill) => skill.id === activeSkillId) ?? null;
useEffect(() => {
const handleOpenProjectDialog = () => setProjectDialogOpen(true);
@@ -837,12 +718,12 @@ export function CharacterScene() {
useEffect(() => {
if (!activeProject) {
setInstalledSkills(courseSkills);
setInstalledSkills([]);
setInstalledSkillsLoading(false);
setInstalledSkillsError(null);
return;
}
if (drawerMode !== 'skills' && drawerMode !== 'character') {
if (drawerMode !== 'character') {
return;
}
@@ -855,17 +736,17 @@ export function CharacterScene() {
if (cancelled) return;
const runtimeSkills = Array.isArray(payload?.skills) ? payload.skills : [];
if (runtimeSkills.length === 0) {
setInstalledSkills(courseSkills);
setInstalledSkillsError('当前没有返回已安装技能,正在显示课程内置技能。');
setInstalledSkills([]);
setInstalledSkillsError(null);
return;
}
setInstalledSkills(mergeInstalledSkills(runtimeSkills.map(mapCodingSkillToDisplaySkill)));
setInstalledSkills(runtimeSkills.map(mapCodingSkillToDisplaySkill));
})
.catch((error) => {
if (cancelled) return;
console.warn('[makelore] Failed to load coding skills', error);
setInstalledSkills(courseSkills);
setInstalledSkillsError('暂时无法读取已安装技能,正在显示课程内置技能。');
setInstalledSkills([]);
setInstalledSkillsError('暂时无法读取已安装的插件能力。');
})
.finally(() => {
if (!cancelled) setInstalledSkillsLoading(false);
@@ -1159,14 +1040,11 @@ export function CharacterScene() {
}}
/>
<ResourceCard
id="skills"
icon={<Wrench className="h-10 w-10 text-brand" />}
title="技能库"
subtitle={installedSkillsLoading ? '正在读取技能' : `已安装${installedSkills.length}个`}
onClick={() => {
setActiveSkillId(null);
setDrawerMode('skills');
}}
id="plugins"
icon="🧩"
title="插件"
subtitle="可选与已安装"
onClick={() => navigate('/project-config/plugins?scope=all&source=all&state=available')}
/>
<ResourceCard
id="tools"
@@ -1178,7 +1056,7 @@ export function CharacterScene() {
/>
</section>
<Panel title="我的伙伴" desc="从这里创建和保存手动配置的伙伴;项目使用时会读取你保存的提示词和技能。">
<Panel title="我的伙伴" desc="从这里创建和保存手动配置的伙伴;项目使用时会读取你保存的提示词和插件能力。">
<div className="mb-3 flex justify-end">
<Button
type="button"
@@ -1228,9 +1106,7 @@ export function CharacterScene() {
<p className="mt-1 text-xs font-medium text-muted-foreground">{baseCharacter.name} · {character.role}</p>
<p className="mt-1 truncate font-mono text-[11px] font-medium text-muted-foreground">{character.id}</p>
<p className="mt-2 line-clamp-2 min-h-8 text-xs text-muted-foreground">{character.customInfo}</p>
<p className="mt-1 text-[11px] text-muted-foreground">
{getSubagentSkillIds(subagent).length} 个技能
</p>
<p className="mt-1 text-[11px] text-muted-foreground">插件能力按项目配置</p>
</button>
);
})}
@@ -1243,7 +1119,6 @@ export function CharacterScene() {
<Sheet open={drawerMode !== null} onOpenChange={(open) => {
if (!open) {
setDrawerMode(null);
setActiveSkillId(null);
}
}}>
<SheetContent className="flex w-[min(430px,94vw)] flex-col overflow-hidden border-l border-foreground/15 bg-background p-0 text-foreground sm:max-w-none">
@@ -1399,52 +1274,6 @@ export function CharacterScene() {
</DrawerFrame>
) : null}
{drawerMode === 'skills' && activeSkill ? (
<DrawerFrame title={`${activeSkill.name} · 技能详情`} desc="先查看 Skill 内结构,再阅读主文件 SKILL.md。" onClose={() => setActiveSkillId(null)} closeLabel="返回技能列表" closeIcon="back">
<SkillDetail skill={activeSkill} />
</DrawerFrame>
) : null}
{drawerMode === 'skills' && !activeSkill ? (
<DrawerFrame title="可用技能库" desc="点击技能查看文件结构和主文件内容;绑定请进入伙伴配置。" onClose={() => setDrawerMode(null)}>
<div className="mb-4 flex items-center justify-between rounded-lg border border-foreground/15 bg-surface-tertiary px-3 py-2">
<span className="text-sm font-semibold text-foreground">技能列表</span>
<Badge className="border border-foreground/15 bg-white text-foreground">{installedSkills.length} 个技能</Badge>
</div>
{installedSkillsError ? (
<div className="mb-3 rounded-md border border-foreground/15 bg-surface-tertiary px-3 py-2 text-sm font-medium text-foreground">
{installedSkillsError}
</div>
) : null}
<div className="space-y-3">
{installedSkills.map((skill) => (
<button
key={skill.id}
type="button"
data-testid={`skill-library-card-${skill.id}`}
aria-label={`查看技能详情:${skill.name}`}
onClick={() => setActiveSkillId(skill.id)}
className="w-full rounded-lg border border-foreground/15 bg-white p-3 text-left transition hover:border-brand/40 hover:bg-brand-soft/30"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="flex items-center gap-2 font-semibold"><Wrench className="h-4 w-4 shrink-0 text-brand" />{skill.name}</p>
</div>
<div className="flex items-center gap-2">
<Badge className="shrink-0 border border-foreground/15 bg-brand-soft text-foreground">
{skill.statusLabel}
</Badge>
<ArrowLeft className="h-4 w-4 shrink-0 rotate-180 text-muted-foreground" aria-hidden="true" />
</div>
</div>
<p className="mt-3 text-sm font-medium leading-6 text-muted-foreground">{skill.description}</p>
<p className="mt-2 text-[11px] font-medium text-brand">查看结构与 SKILL.md</p>
</button>
))}
</div>
</DrawerFrame>
) : null}
{drawerMode === 'createPartner' ? (
<DrawerFrame title="新增伙伴" desc="所有伙伴都由你手动配置;不会自动套用任何角色预设。" onClose={() => setDrawerMode(null)}>
<form className="space-y-4" onSubmit={handleCreatePartner}>
@@ -1484,7 +1313,7 @@ export function CharacterScene() {
) : null}
{drawerMode === 'character' ? (
<DrawerFrame title={active.name} desc="编辑展示资料和运行时技能绑定。保存后会写入当前项目的伙伴配置。" onClose={() => setDrawerMode(null)}>
<DrawerFrame title={active.name} desc="编辑展示资料和插件能力绑定。保存后会写入当前项目的伙伴配置。" onClose={() => setDrawerMode(null)}>
<div data-testid={`partner-profile-editor-${activeRuntimeId}`}>
<div className="flex flex-col items-center rounded-lg border border-foreground/15 bg-surface-tertiary p-4">
<PixelAvatar
@@ -1580,9 +1409,9 @@ export function CharacterScene() {
<div className="mt-4 rounded-lg border border-foreground/15 bg-white p-3 text-foreground">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-semibold">绑定技能</p>
<p className="font-semibold">绑定插件能力</p>
<p className="mt-1 text-xs font-medium text-muted-foreground">
勾选后会写入伙伴的技能权限;不勾选任何技能时会禁止加载技能。
这里只显示插件提供、且支持按伙伴分配的能力;安装与启停请前往插件模块。
</p>
</div>
<Badge className="shrink-0 border border-foreground/15 bg-accent-soft text-foreground">
@@ -1590,6 +1419,15 @@ export function CharacterScene() {
</Badge>
</div>
<div className="mt-3 space-y-2" data-testid={`partner-skill-bindings-${activeRuntimeId}`}>
{installedSkillsLoading ? (
<p className="text-xs text-muted-foreground">正在读取插件能力…</p>
) : null}
{installedSkillsError ? (
<p className="text-xs text-muted-foreground">{installedSkillsError}</p>
) : null}
{!installedSkillsLoading && !installedSkillsError && installedSkills.length === 0 ? (
<p className="text-xs text-muted-foreground">当前没有可分配的插件能力。</p>
) : null}
{installedSkills.map((skill) => {
const checked = effectiveDraftSkillIds.includes(skill.id);
return (
@@ -1604,7 +1442,7 @@ export function CharacterScene() {
<input
type="checkbox"
className="mt-1 h-4 w-4 accent-brand"
aria-label={`绑定技能:${skill.name}`}
aria-label={`绑定插件能力:${skill.name}`}
checked={checked}
onChange={() => handleToggleDraftSkill(skill.id)}
/>