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(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 ; } 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(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 = ; return
{title}{desc}
{closeIcon === 'back' ? closeButton : {closeButton}}
{children}
; } function SkillDetail({ skill }: { skill: SkillInfo }) { return

Skill 内结构

{skill.location}

{skill.entries.length > 0 ? skill.entries.map((entry) => { const depth = entry.path.split('/').length - 1; return
{entry.type === 'directory' ? : } {entry.type === 'directory' ? `${entry.path}/` : entry.path}
; }) :

暂无可展示的文件结构。

}

SKILL.md

{skill.content || 'SKILL.md 暂无内容。'}
; } function AgentCard({ agent, onOpen }: { agent: ProjectAgentConfig; onOpen: () => void }) { const cardRef = useRef(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 ; } function ModelCard({ model }: { model: ConfiguredModelOption }) { return

{model.label}

{getConfiguredModelCapabilityLabel(model)}

{model.runtimeProviderKey}

; } function ModelList({ models }: { models: ConfiguredModelOption[] }) { if (models.length === 0) { return
当前没有可用模型,请先在模型管理中完成配置;模型切换请进入伙伴配置。
; } return

以下模型已完成配置。项目只展示模型,实际使用模型由每个伙伴单独配置。

{models.map((model) => )}
; } 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(null); const [drawerMode, setDrawerMode] = useState(null); const [activeSkillId, setActiveSkillId] = useState(null); const [activeAgentId, setActiveAgentId] = useState(null); const [skills, setSkills] = useState([]); const [saving, setSaving] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [partnerDialogOpen, setPartnerDialogOpen] = useState(false); const [archiveAgentId, setArchiveAgentId] = useState(null); const [deleteAgentId, setDeleteAgentId] = useState(null); const pageRef = useRef(null); const knowledgeInputRef = useRef(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

一念成光,万物可创。

; if (!draft) return
正在读取项目配置…
; 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

配置你的项目空间

setDrawerMode('models')} />} title="工具箱(技能)" subtitle={`已安装${skills.length}个`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} /> setDrawerMode('knowledge')} />

我的伙伴

每个伙伴只属于当前项目;一个伙伴可以拥有多条独立会话。

{draft.agents.filter((agent) => !agent.archivedAt).map((agent) => { setActiveAgentId(agent.id); setPartnerDialogOpen(true); }} />)}{draft.agents.every((agent) => agent.archivedAt) ?
还没有伙伴,点击右上角的 + 创建第一个。
: null}
{draft.agents.some((agent) => agent.archivedAt) ?

已归档

{draft.agents.filter((agent) => agent.archivedAt).map((agent) =>
{!agent.builtIn ? : null}
)}
: null}
{draft.projectType === 'mini_game' || draft.projectType === 'mini_program' ? ( ) : null}
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 ? '保存伙伴' : '创建伙伴'} /> { if (!open) { setDrawerMode(null); setActiveSkillId(null); } }}> {drawerMode === 'models' ? setDrawerMode(null)}> : null} {drawerMode === 'knowledge' ? setDrawerMode(null)}> { void handleKnowledge(event.target.files?.[0]); event.currentTarget.value = ''; }} />
{knowledge.length ? knowledge.map((file) =>
{file}
) :
当前还没有知识文件。
}
: null} {drawerMode === 'skills' && activeSkill ? setActiveSkillId(null)} closeLabel="返回技能列表" closeIcon="back"> : null} {drawerMode === 'skills' && !activeSkill ? setDrawerMode(null)}>
{skills.length > 0 ? skills.map((skill) => ) :
当前没有可查看的技能。
}
: null}
agent.id === archiveAgentId)?.name || '未命名伙伴'}”?`} message="归档会停止后续使用入口,但会保留伙伴和全部会话记录;你可以之后在伙伴列表底部恢复。" confirmLabel="确认归档" cancelLabel="取消" onCancel={() => setArchiveAgentId(null)} onConfirm={handleArchiveAgent} onError={(error) => toast.error(error instanceof Error ? error.message : String(error))} /> 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))} /> setDeleteDialogOpen(false)} onConfirm={handleDeleteProject} onError={(error) => toast.error(error instanceof Error ? error.message : String(error))} />
; } export default ProjectConfiguration;