421 lines
32 KiB
TypeScript
421 lines
32 KiB
TypeScript
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;
|