Files
makelore/src/pages/ProjectConfiguration/index.tsx
inman c809002180
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: add bundled OpenCode skills and refine module flow
2026-08-14 18:15:55 +08:00

421 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useGSAP } from '@gsap/react';
import { gsap } from 'gsap';
import { ArrowLeft, Check, FileText, FolderOpen, Plus, Trash2, Upload, Wrench, X } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet';
import { AgentCreationDialog, type AgentCreationInput } from '@/components/opencode/AgentCreationDialog';
import { ProjectPublishAction } from '@/components/works/ProjectPublishAction';
import { hostApiFetch } from '@/lib/host-api';
import { buildConfiguredModelOptions, getConfiguredModelCapabilityLabel, type ConfiguredModelOption } from '@/lib/model-options';
import { getSkillDisplayInfo } from '@/lib/skill-display';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import { useOpencodeStore } from '@/stores/opencode';
import { useProjectConversationStore } from '@/stores/project-conversations';
import { useProjectConfigStore } from '@/stores/project-config';
import { useProviderStore } from '@/stores/providers';
import { validateAgentConfigs, type ProjectAgentConfig, type ProjectConfig } from '../../../shared/project-config';
const EMPTY_FILES: string[] = [];
type DrawerMode = 'models' | 'knowledge' | 'skills' | null;
type SkillStructureEntry = { path: string; type: 'file' | 'directory' };
type RuntimeSkillInfo = { name: string; description?: string; location?: string; content?: string; entries?: SkillStructureEntry[] };
type SkillInfo = { id: string; name: string; description: string; location: string; content: string; entries: SkillStructureEntry[] };
gsap.registerPlugin(useGSAP);
function prefersReducedMotion() {
return typeof window === 'undefined'
|| typeof window.matchMedia !== 'function'
|| window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function supportsHover() {
return typeof window !== 'undefined'
&& typeof window.matchMedia === 'function'
&& window.matchMedia('(hover: hover) and (pointer: fine)').matches;
}
function isMissingRuntimeSessionError(error: unknown): boolean {
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
return message.includes('404') || message.includes('not found') || message.includes('不存在');
}
function createCustomAgent(index: number, model: string | null): ProjectAgentConfig {
const now = new Date().toISOString();
return {
id: `custom-agent-${Date.now()}-${index}`,
avatarId: 'avatar-01',
roleName: '项目伙伴',
name: '',
builtIn: false,
enabled: true,
model,
skillIds: [],
responsibility: { mission: '', owns: [], boundaries: [], collaborators: [], principles: [] },
prompt: '',
archivedAt: null,
pinned: false,
createdAt: now,
updatedAt: now,
};
}
function ResourceCard({ id, icon, title, subtitle, onClick }: { id: string; icon: React.ReactNode; title: string; subtitle: string; onClick: () => void }) {
const cardRef = useRef<HTMLButtonElement | null>(null);
const { contextSafe } = useGSAP({ scope: cardRef });
const animateHover = contextSafe((target: HTMLButtonElement, active: boolean) => {
if (prefersReducedMotion() || !supportsHover()) return;
gsap.to(target, { y: active ? -4 : 0, scale: active ? 1.015 : 1, duration: 0.22, ease: 'power2.out', overwrite: 'auto' });
});
return <button ref={cardRef} type="button" data-testid={`resource-card-${id}`} onClick={onClick} onPointerEnter={(event) => animateHover(event.currentTarget, true)} onPointerLeave={(event) => animateHover(event.currentTarget, false)} className="config-motion-resource surface-card motion-press rounded-2xl border border-border/70 bg-background p-3 text-left shadow-none hover:shadow-soft">
<div className="flex items-center gap-2"><span className="text-2xl">{icon}</span><p className="text-sm font-semibold">{title}</p></div><p className="mt-1.5 truncate text-[11px] font-medium text-muted-foreground">{subtitle}</p>
</button>;
}
function DrawerFrame({ title, desc, onClose, children, closeLabel = '关闭', closeIcon = 'close' }: { title: string; desc: string; onClose: () => void; children: React.ReactNode; closeLabel?: string; closeIcon?: 'close' | 'back' }) {
const drawerRef = useRef<HTMLDivElement | null>(null);
useGSAP(() => {
if (prefersReducedMotion()) return;
gsap.timeline({ defaults: { ease: 'power3.out' } })
// Keep the drawer content render-safe: movement should never make the
// existing controls disappear if the entrance timeline is interrupted.
.from('.drawer-motion-header', { x: 8, duration: 0.22 })
.from('.drawer-motion-body > *', { y: 6, duration: 0.22, stagger: 0.035 }, '-=0.12');
}, { scope: drawerRef });
const closeButton = <Button type="button" variant="ghost" size="icon" onClick={onClose} aria-label={closeLabel} title={closeLabel} className="relative z-10 shrink-0">{closeIcon === 'back' ? <ArrowLeft className="h-4 w-4" /> : <X className="h-4 w-4" />}</Button>;
return <div ref={drawerRef} className="flex min-h-0 flex-1 flex-col"><div className="glass-surface drawer-motion-header flex items-center justify-between gap-3 border-b border-border/70 bg-background/80 px-5 py-4"><div className="min-w-0 flex-1"><SheetTitle className="text-lg font-semibold">{title}</SheetTitle><SheetDescription className="mt-1 text-sm font-medium text-muted-foreground">{desc}</SheetDescription></div>{closeIcon === 'back' ? closeButton : <SheetClose asChild>{closeButton}</SheetClose>}</div><div className="drawer-motion-body min-h-0 flex-1 overflow-auto p-5">{children}</div></div>;
}
function SkillDetail({ skill }: { skill: SkillInfo }) {
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-subtle 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}</p>
<div className="mt-3 space-y-1 rounded-lg border border-foreground/10 bg-white p-2">
{skill.entries.length > 0 ? skill.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>;
}) : <p className="p-2 text-xs text-muted-foreground"></p>}
</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-subtle p-3 font-mono text-[11px] leading-5 text-foreground">{skill.content || 'SKILL.md 暂无内容。'}</pre>
</section>
</div>;
}
function AgentCard({ agent, onOpen }: { agent: ProjectAgentConfig; onOpen: () => void }) {
const cardRef = useRef<HTMLButtonElement | null>(null);
const { contextSafe } = useGSAP({ scope: cardRef });
const animateHover = contextSafe((target: HTMLButtonElement, active: boolean) => {
if (prefersReducedMotion() || !supportsHover()) return;
gsap.to(target, { y: active ? -7 : 0, rotation: active ? -0.4 : 0, zIndex: active ? 2 : 0, duration: 0.25, ease: 'power2.out', overwrite: 'auto' });
gsap.to(target.querySelector('.agent-motion-avatar'), { scale: active ? 1.07 : 1, duration: 0.28, ease: 'back.out(1.7)', overwrite: 'auto' });
});
return <button ref={cardRef} type="button" data-testid={`project-agent-${agent.id}`} onClick={onOpen} onPointerEnter={(event) => animateHover(event.currentTarget, true)} onPointerLeave={(event) => animateHover(event.currentTarget, false)} className="config-motion-agent surface-card motion-press h-[260px] w-[210px] flex-none rounded-2xl border border-border/70 bg-background p-4 text-left shadow-none hover:shadow-soft"><img src={getAgentAvatarSrc(agent.avatarId, agent.avatarDataUrl)} alt="" className="agent-motion-avatar mx-auto h-20 w-20 rounded-xl border border-border object-cover [image-rendering:pixelated]" /><div className="mt-4 flex items-center justify-between gap-2"><p className="truncate font-semibold">{agent.name || '未命名'}</p><Badge className="shrink-0 border border-brand/15 bg-brand-soft text-brand"></Badge></div><p className="mt-1 truncate text-xs font-semibold text-muted-foreground">{agent.roleName}</p><p className="mt-3 line-clamp-3 min-h-12 text-xs text-muted-foreground">{agent.responsibility.mission}</p><p className="mt-2 text-[11px] text-muted-foreground">{agent.skillIds.length} </p></button>;
}
function ModelCard({ model }: { model: ConfiguredModelOption }) {
return <div data-testid={`model-card-${model.modelRef.replace(/[^a-zA-Z0-9_-]+/gu, '-')}`} className="rounded-xl border border-foreground/15 bg-white p-3">
<div className="flex items-start gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-brand-soft text-lg" aria-hidden="true">🧠</span>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate font-semibold" title={model.label}>{model.label}</p>
<Badge className="shrink-0 border border-border/80 bg-surface-subtle text-foreground">{getConfiguredModelCapabilityLabel(model)}</Badge>
</div>
<p className="mt-1 truncate text-xs font-medium text-muted-foreground" title={model.runtimeProviderKey}>{model.runtimeProviderKey}</p>
</div>
</div>
</div>;
}
function ModelList({ models }: { models: ConfiguredModelOption[] }) {
if (models.length === 0) {
return <div data-testid="project-model-empty-state" className="rounded-xl border border-dashed border-foreground/15 bg-white p-4 text-sm font-medium text-muted-foreground">
当前没有可用模型,请先在模型管理中完成配置;模型切换请进入伙伴配置。
</div>;
}
return <div data-testid="project-model-list" className="space-y-3">
<p className="text-xs font-medium leading-5 text-muted-foreground">以下模型已完成配置。项目只展示模型,实际使用模型由每个伙伴单独配置。</p>
<div className="space-y-2">
{models.map((model) => <ModelCard key={model.modelRef} model={model} />)}
</div>
</div>;
}
export function ProjectConfiguration() {
const activeProject = useOpencodeStore((state) => state.activeProject);
const stored = useProjectConfigStore((state) => activeProject ? state.configsByProjectId[activeProject.id] : undefined);
const knowledge = useProjectConfigStore((state) => activeProject ? state.knowledgeByProjectId[activeProject.id] ?? EMPTY_FILES : EMPTY_FILES);
const load = useProjectConfigStore((state) => state.load);
const save = useProjectConfigStore((state) => state.save);
const uploadKnowledge = useProjectConfigStore((state) => state.uploadKnowledge);
const removeProjectConfig = useProjectConfigStore((state) => state.remove);
const removeProject = useOpencodeStore((state) => state.removeProject);
const abortSession = useOpencodeStore((state) => state.abortSession);
const deleteOpencodeSession = useOpencodeStore((state) => state.deleteSession);
const sessionStatuses = useOpencodeStore((state) => state.sessionStatuses);
const sendingSessionIds = useOpencodeStore((state) => state.sendingSessionIds);
const queuedSessionPrompts = useOpencodeStore((state) => state.queuedSessionPrompts);
const loadConversationState = useProjectConversationStore((state) => state.load);
const deleteProjectSession = useProjectConversationStore((state) => state.deleteSession);
const providerAccounts = useProviderStore((state) => state.accounts);
const providerStatuses = useProviderStore((state) => state.statuses);
const providerDefaultAccountId = useProviderStore((state) => state.defaultAccountId);
const refreshProviderSnapshot = useProviderStore((state) => state.refreshProviderSnapshot);
const [draft, setDraft] = useState<ProjectConfig | null>(null);
const [drawerMode, setDrawerMode] = useState<DrawerMode>(null);
const [activeSkillId, setActiveSkillId] = useState<string | null>(null);
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [saving, setSaving] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [partnerDialogOpen, setPartnerDialogOpen] = useState(false);
const [archiveAgentId, setArchiveAgentId] = useState<string | null>(null);
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null);
const pageRef = useRef<HTMLElement | null>(null);
const knowledgeInputRef = useRef<HTMLInputElement | null>(null);
const navigate = useNavigate();
const location = useLocation();
const loadSkills = useCallback(() => {
void hostApiFetch<{ skills?: RuntimeSkillInfo[] }>('/api/opencode/skills')
.then((result) => setSkills((result.skills ?? []).map((skill) => ({
id: skill.name,
...getSkillDisplayInfo(skill.name),
location: skill.location ?? '',
content: skill.content ?? '',
entries: skill.entries ?? [{ path: 'SKILL.md', type: 'file' }],
}))))
.catch(() => setSkills([]));
}, []);
useEffect(() => { if (activeProject) void load(activeProject.id); }, [activeProject, load]);
useEffect(() => { if (stored) setDraft(structuredClone(stored)); }, [stored]);
useEffect(() => {
const requestedAgentId = new URLSearchParams(location.search).get('agent');
if (!requestedAgentId || !draft?.agents.some((agent) => agent.id === requestedAgentId)) return;
setActiveAgentId(requestedAgentId);
setPartnerDialogOpen(true);
}, [draft, location.search]);
useEffect(() => { loadSkills(); }, [loadSkills]);
useEffect(() => {
if (drawerMode === 'skills') loadSkills();
}, [drawerMode, loadSkills]);
useEffect(() => { void refreshProviderSnapshot(); }, [refreshProviderSnapshot]);
const modelOptions = useMemo(() => buildConfiguredModelOptions(providerAccounts, providerStatuses, providerDefaultAccountId), [providerAccounts, providerStatuses, providerDefaultAccountId]);
const activeAgent = draft?.agents.find((agent) => agent.id === activeAgentId) ?? null;
const activeSkill = skills.find((skill) => skill.id === activeSkillId) ?? null;
const { contextSafe } = useGSAP(() => {
if (!draft || prefersReducedMotion()) return;
gsap.timeline({ defaults: { ease: 'power3.out' } })
// Keep cards in their layout position on first paint. Their deliberate
// motion belongs to hover/open/save feedback, not page load.
.from('.config-motion-title', { y: 6, duration: 0.22 });
}, { scope: pageRef, dependencies: [Boolean(draft)], revertOnUpdate: true });
const playSaveConfirmation = contextSafe(() => {
if (prefersReducedMotion()) return;
gsap.fromTo('.project-save-button', { scale: 0.96 }, { scale: 1, duration: 0.42, ease: 'back.out(2.4)', overwrite: 'auto' });
});
const handleBack = () => navigate(-1);
if (!activeProject) return <main data-testid="project-configuration-empty-state" className="flex min-h-full items-start bg-background p-8 text-foreground"><p className="pt-4 text-xl font-semibold tracking-[-0.02em]">一念成光,万物可创。</p></main>;
if (!draft) return <div className="p-8 text-center text-sm font-medium">正在读取项目配置…</div>;
const updateAgent = (next: ProjectAgentConfig) => setDraft((current) => current ? { ...current, agents: current.agents.map((item) => item.id === next.id ? { ...next, updatedAt: new Date().toISOString() } : item) } : current);
const handlePartnerDialogOpenChange = (open: boolean) => {
setPartnerDialogOpen(open);
if (!open) setActiveAgentId(null);
};
const handleCreatePartner = async (input: AgentCreationInput) => {
const agent = {
...createCustomAgent(draft.agents.length + 1, input.model),
name: input.name,
avatarId: input.avatarId,
avatarDataUrl: input.avatarDataUrl,
model: input.model,
prompt: input.prompt,
skillIds: input.skillIds,
responsibility: { mission: input.responsibility, owns: [], boundaries: [], collaborators: [], principles: [] },
};
setDraft((current) => current ? { ...current, agents: [...current.agents, agent] } : current);
};
const handleUpdatePartner = async (input: AgentCreationInput) => {
if (!activeAgentId) return;
setDraft((current) => current ? {
...current,
agents: current.agents.map((agent) => agent.id === activeAgentId ? {
...agent,
name: input.name,
avatarId: input.avatarId,
avatarDataUrl: input.avatarDataUrl,
model: input.model,
prompt: input.prompt,
skillIds: input.skillIds,
responsibility: { ...agent.responsibility, mission: input.responsibility },
updatedAt: new Date().toISOString(),
} : agent),
} : current);
};
const submit = async () => { const errors = validateAgentConfigs(draft.agents); if (errors.length) { toast.error('请补全伙伴名称、头像、伙伴模型和职责;名称还需要在项目内唯一。'); return; } setSaving(true); try { await save(activeProject.id, { ...draft, initialized: true }); playSaveConfirmation(); toast.success(draft.initialized ? '项目配置已更新' : '项目初始化完成'); } catch (error) { toast.error(error instanceof Error ? error.message : String(error)); } finally { setSaving(false); } };
const handleKnowledge = async (file?: File) => { if (!file) return; try { await uploadKnowledge(activeProject.id, file); toast.success(` knowledge/${file.name}`); } catch (error) { toast.error(error instanceof Error ? error.message : String(error)); } };
const handleDeleteProject = async () => {
await removeProject(activeProject.id);
removeProjectConfig(activeProject.id);
setDeleteDialogOpen(false);
toast.success(` ${activeProject.name}`);
navigate('/project-config');
};
const handleArchiveAgent = async () => {
if (!archiveAgentId) return;
const conversationState = await loadConversationState(activeProject.id);
const sessionIds = conversationState.sessions
.filter((session) => session.agentId === archiveAgentId)
.map((session) => session.sessionId)
.filter((sessionId) => (
Boolean(sendingSessionIds[sessionId])
|| sessionStatuses[sessionId]?.type === 'busy'
|| sessionStatuses[sessionId]?.type === 'retry'
|| (queuedSessionPrompts[sessionId]?.length ?? 0) > 0
));
await Promise.all(sessionIds.map((sessionId) => abortSession(sessionId)));
const archivedAt = new Date().toISOString();
setDraft((current) => current ? {
...current,
agents: current.agents.map((agent) => agent.id === archiveAgentId
? { ...agent, archivedAt, updatedAt: archivedAt }
: agent),
} : current);
setArchiveAgentId(null);
setPartnerDialogOpen(false);
setActiveAgentId(null);
};
const handleDeleteAgent = async () => {
if (!deleteAgentId) return;
const target = draft.agents.find((agent) => agent.id === deleteAgentId);
if (!target || target.builtIn || !target.archivedAt) return;
const conversationState = await loadConversationState(activeProject.id);
const sessionIds = conversationState.sessions
.filter((session) => session.agentId === target.id)
.map((session) => session.sessionId);
const runningSessionIds = sessionIds.filter((sessionId) => (
Boolean(sendingSessionIds[sessionId])
|| sessionStatuses[sessionId]?.type === 'busy'
|| sessionStatuses[sessionId]?.type === 'retry'
|| (queuedSessionPrompts[sessionId]?.length ?? 0) > 0
));
await Promise.all(runningSessionIds.map((sessionId) => abortSession(sessionId)));
for (const sessionId of sessionIds) {
try {
await deleteOpencodeSession(sessionId);
} catch (error) {
if (!isMissingRuntimeSessionError(error)) throw error;
}
await deleteProjectSession(activeProject.id, sessionId);
}
const nextDraft = {
...draft,
agents: draft.agents.filter((agent) => agent.id !== target.id),
};
await save(activeProject.id, nextDraft);
setDraft(nextDraft);
setDeleteAgentId(null);
if (activeAgentId === target.id) {
setActiveAgentId(null);
setPartnerDialogOpen(false);
}
toast.success(`${target.name || '未命名伙伴'}`);
};
return <main ref={pageRef} className="-m-5 flex min-h-[calc(100%+2.5rem)] min-h-0 flex-col overflow-auto bg-background p-4 text-foreground sm:-m-6 sm:min-h-[calc(100%+3rem)]" data-testid="project-configuration-page">
<div className="mx-auto flex min-h-0 w-full max-w-7xl flex-1 flex-col gap-3">
<div className="config-motion-title flex items-center gap-2">
<Button type="button" variant="ghost" size="icon" data-testid="project-configuration-back-button" aria-label="返回" title="返回" onClick={handleBack} className="h-8 w-8 shrink-0 text-muted-foreground hover:bg-surface-subtle hover:text-foreground">
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-xl font-semibold">配置你的项目空间</h1>
</div>
<section className="grid gap-3 md:grid-cols-3"><ResourceCard id="models" icon="🧠" title="大脑(大语言模型)" subtitle={`${modelOptions.length}`} onClick={() => setDrawerMode('models')} /><ResourceCard id="skills" icon={<Wrench className="h-5 w-5" />} title="工具箱(技能)" subtitle={`${skills.length}`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} /><ResourceCard id="knowledge" icon="📓" title="笔记本(知识库)" subtitle={`${knowledge.length}`} onClick={() => setDrawerMode('knowledge')} /></section>
<section className="config-motion-partners surface-card rounded-2xl border border-border/70 bg-background p-5 shadow-soft"><div className="mb-4 flex items-center justify-between gap-3"><div><h2 className="text-xl font-semibold">我的伙伴</h2><p className="mt-1 text-sm font-medium text-muted-foreground">每个伙伴只属于当前项目;一个伙伴可以拥有多条独立会话。</p></div><Button type="button" size="icon" className="h-8 w-8 rounded-full border border-brand/20 bg-brand-soft text-brand" aria-label="创建伙伴" title="创建伙伴" onClick={() => { if (modelOptions.length === 0) { toast.error('请先在模型管理中配置至少一个可用模型。'); setDrawerMode('models'); return; } setActiveAgentId(null); setPartnerDialogOpen(true); }}><Plus className="h-4 w-4" /></Button></div><div data-testid="agent-cards-scroll-region" className="-m-2 flex gap-4 overflow-auto p-2">{draft.agents.filter((agent) => !agent.archivedAt).map((agent) => <AgentCard key={agent.id} agent={agent} onOpen={() => { setActiveAgentId(agent.id); setPartnerDialogOpen(true); }} />)}{draft.agents.every((agent) => agent.archivedAt) ? <div className="w-full rounded-xl border border-dashed border-foreground/15 p-6 text-center text-sm font-medium text-muted-foreground">还没有伙伴,点击右上角的 + 创建第一个。</div> : null}</div>{draft.agents.some((agent) => agent.archivedAt) ? <div className="mt-4 border-t border-border/70 pt-4"><p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">已归档</p><div className="mt-2 flex flex-wrap gap-2">{draft.agents.filter((agent) => agent.archivedAt).map((agent) => <div key={agent.id} className="flex items-center gap-1"><Button type="button" variant="outline" className="border-border/70 bg-surface-subtle" onClick={() => updateAgent({ ...agent, archivedAt: null })}>{agent.name || '未命名伙伴'} · 恢复</Button>{!agent.builtIn ? <Button type="button" variant="outline" size="icon" aria-label={'永久删除伙伴 ' + (agent.name || '未命名伙伴')} title="永久删除伙伴" className="h-9 w-9 border-border/70 text-muted-foreground hover:bg-accent-soft hover:text-destructive" onClick={() => setDeleteAgentId(agent.id)}><Trash2 className="h-3.5 w-3.5" /></Button> : null}</div>)}</div></div> : null}</section>
<div data-testid="project-configuration-actions" className="glass-surface config-motion-actions sticky bottom-3 z-10 mt-auto flex shrink-0 flex-col gap-2 rounded-2xl border border-border/70 bg-background/85 p-2 shadow-float sm:flex-row sm:items-start sm:justify-between">
<Button type="button" variant="outline" size="sm" onClick={() => setDeleteDialogOpen(true)} className="h-9 w-full border-brand/25 bg-background px-3 font-semibold text-foreground shadow-none hover:border-brand/40 hover:bg-brand-soft sm:w-auto"><Trash2 className="mr-2 h-4 w-4" />删除项目</Button>
<div data-testid="project-configuration-primary-actions" className="flex w-full flex-col gap-2 sm:ml-auto sm:w-auto sm:flex-row sm:items-start sm:justify-end">
{draft.projectType === 'mini_game' || draft.projectType === 'mini_program' ? (
<ProjectPublishAction
key={activeProject.id}
project={activeProject}
projectType={draft.projectType}
buttonVariant="outline"
buttonSize="sm"
/>
) : null}
<Button onClick={() => void submit()} disabled={saving} className="project-save-button h-10 w-full border border-brand/20 bg-brand px-4 font-semibold text-primary-foreground sm:w-auto"><Check className="mr-2 h-4 w-4" />{saving ? '保存中…' : draft.initialized ? '保存项目配置' : '确认并完成初始化'}</Button>
</div>
</div>
</div>
<AgentCreationDialog
open={partnerDialogOpen}
onOpenChange={handlePartnerDialogOpenChange}
modelOptions={modelOptions}
skills={skills}
agent={activeAgent}
existingAgentNames={draft.agents.filter((agent) => agent.id !== activeAgentId).map((agent) => agent.name)}
onCreate={handleCreatePartner}
onUpdate={handleUpdatePartner}
onArchive={activeAgent && !activeAgent.builtIn ? () => setArchiveAgentId(activeAgent.id) : undefined}
title={activeAgent ? `${activeAgent.name || '未命名伙伴'} · ` : '创建项目伙伴'}
submitLabel={activeAgent ? '保存伙伴' : '创建伙伴'}
/>
<Sheet open={drawerMode !== null} onOpenChange={(open) => { if (!open) { setDrawerMode(null); setActiveSkillId(null); } }}><SheetContent className="glass-surface flex w-[min(430px,94vw)] flex-col overflow-hidden border-l border-border/70 bg-background/90 p-0 text-foreground sm:max-w-none">
{drawerMode === 'models' ? <DrawerFrame title="大脑(大语言模型)" desc="查看当前已配置模型;模型切换请进入伙伴配置。" onClose={() => setDrawerMode(null)}><ModelList models={modelOptions} /></DrawerFrame> : null}
{drawerMode === 'knowledge' ? <DrawerFrame title="笔记本(知识库)" desc="知识文件会复制到项目的 knowledge/ 文件夹。" onClose={() => setDrawerMode(null)}><input ref={knowledgeInputRef} type="file" className="sr-only" onChange={(event) => { void handleKnowledge(event.target.files?.[0]); event.currentTarget.value = ''; }} /><Button className="w-full border border-foreground/15 bg-brand-soft text-foreground" onClick={() => knowledgeInputRef.current?.click()}><Upload className="mr-2 h-4 w-4" />上传知识文件</Button><div className="mt-5 space-y-3">{knowledge.length ? knowledge.map((file) => <div key={file} className="rounded-lg border border-foreground/15 bg-white p-3 font-semibold">{file}</div>) : <div className="rounded-lg border border-dashed border-foreground/15 bg-white p-4 text-sm font-medium">当前还没有知识文件。</div>}</div></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="space-y-3">{skills.length > 0 ? skills.map((skill) => <button key={skill.id} type="button" data-testid={`skill-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 gap-3"><Wrench className="mt-0.5 h-4 w-4 shrink-0 text-brand" /><span className="min-w-0 flex-1"><span className="block font-semibold">{skill.name}</span><span className="mt-1 block text-sm text-muted-foreground">{skill.description}</span><span className="mt-2 block text-[11px] font-medium text-brand">查看结构与 SKILL.md</span></span><ArrowLeft className="mt-0.5 h-4 w-4 shrink-0 rotate-180 text-muted-foreground" aria-hidden="true" /></div></button>) : <div className="rounded-lg border border-dashed border-foreground/15 bg-white p-4 text-sm font-medium text-muted-foreground">当前没有可查看的技能。</div>}</div></DrawerFrame> : null}
</SheetContent></Sheet>
<ConfirmDialog
open={Boolean(archiveAgentId)}
title={`${draft.agents.find((agent) => agent.id === archiveAgentId)?.name || '未命名伙伴'}`}
message="归档会停止后续使用入口,但会保留伙伴和全部会话记录;你可以之后在伙伴列表底部恢复。"
confirmLabel="确认归档"
cancelLabel="取消"
onCancel={() => setArchiveAgentId(null)}
onConfirm={handleArchiveAgent}
onError={(error) => toast.error(error instanceof Error ? error.message : String(error))}
/>
<ConfirmDialog
open={Boolean(deleteAgentId)}
title={`${draft.agents.find((agent) => agent.id === deleteAgentId)?.name || '未命名伙伴'}`}
message="这会永久删除伙伴及其全部会话记录,删除后无法恢复。"
confirmLabel="永久删除"
cancelLabel="取消"
variant="destructive"
onCancel={() => setDeleteAgentId(null)}
onConfirm={handleDeleteAgent}
onError={(error) => toast.error(error instanceof Error ? error.message : String(error))}
/>
<ConfirmDialog
open={deleteDialogOpen}
title={`${activeProject.name}`}
message="项目会从 Makelore 的项目列表中移除,但不会删除磁盘中的项目文件。此操作需要你明确确认。"
confirmLabel="确认删除项目"
cancelLabel="取消"
variant="destructive"
onCancel={() => setDeleteDialogOpen(false)}
onConfirm={handleDeleteProject}
onError={(error) => toast.error(error instanceof Error ? error.message : String(error))}
/>
</main>;
}
export default ProjectConfiguration;