Files
makelore/src/pages/ProjectConfiguration/index.tsx

575 lines
38 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 { ArrowLeft, Blocks, Check, Cpu, FileText, Files, FolderOpen, Plus, Puzzle, Trash2, Upload, 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/coding/AgentCreationDialog';
import { PluginServices } from '@/components/plugins/PluginServices';
import { ProjectPublishAction } from '@/components/works/ProjectPublishAction';
import { buildCodingModelOptions, parseCodingModelKey, type CodingModelOption } from '@/lib/coding-model-options';
import { getCodingSkills } from '@/lib/coding-product-tools';
import { abortCodingConversation } from '@/lib/coding-conversations';
import { isCanonicalCodingProjectId } from '@/lib/coding-projects';
import { getSkillDisplayInfo } from '@/lib/skill-display';
import { getPluginServiceTab, type PluginServiceTab } from '@/lib/plugin-services';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import { cn } from '@/lib/utils';
import { codingConversationStore } from '@/stores/coding-conversations';
import { codingPluginsStore, useCodingPluginsStore } from '@/stores/coding-plugins';
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
import { useProjectConfigStore } from '@/stores/project-config';
import { useProviderStore } from '@/stores/providers';
import type { CodingProjectAgent, CodingProjectConfig } from '@/types/coding-project';
import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts';
const EMPTY_FILES: string[] = [];
type DrawerMode = 'models' | 'knowledge' | 'skills' | 'plugins' | null;
type ProjectIdentityKind = ProjectIdentityChoice['kind'];
type SkillStructureEntry = { path: string; type: 'file' | 'directory' };
type SkillInfo = {
id: string;
name: string;
description: string;
available: boolean;
effective: boolean;
location: string;
content: string;
entries: SkillStructureEntry[];
};
function createCustomAgent(index: number, modelKey: string): CodingProjectAgent {
const now = new Date().toISOString();
const model = parseCodingModelKey(modelKey);
return {
id: `custom-agent-${Date.now()}-${index}`,
avatarId: 'avatar-01',
roleName: '项目智能体',
name: '',
builtIn: false,
enabled: true,
model: model ? { ...model, thinkingLevel: 'off' } : null,
modelResolution: model ? 'resolved' : 'required',
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 }) {
return <button type="button" data-testid={`resource-card-${id}`} onClick={onClick} 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="flex h-8 w-8 items-center justify-center rounded-lg bg-surface-subtle text-brand [&>svg]:h-4 [&>svg]:w-4">{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 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 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 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"></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: CodingProjectAgent; onOpen: () => void }) {
return <button type="button" data-testid={`project-agent-${agent.id}`} onClick={onOpen} 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: CodingModelOption }) {
return <div data-testid={`model-card-${model.key.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-brand" aria-hidden="true"><Cpu className="h-4 w-4" /></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">已配置</Badge>
</div>
<p className="mt-1 truncate text-xs font-medium text-muted-foreground" title={model.accountId}>{model.accountId}</p>
</div>
</div>
</div>;
}
function ModelList({ models }: { models: CodingModelOption[] }) {
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.key} model={model} />)}
</div>
</div>;
}
export function ProjectConfiguration() {
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
const conversations = useCodingWorkspaceStore((state) => state.conversations);
const removeProject = useCodingWorkspaceStore((state) => state.removeProject);
const reloadWorkspace = useCodingWorkspaceStore((state) => state.load);
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 resolveIdentity = useProjectConfigStore((state) => state.resolveIdentity);
const makeIndependentCopy = useProjectConfigStore((state) => state.makeIndependentCopy);
const uploadKnowledge = useProjectConfigStore((state) => state.uploadKnowledge);
const removeProjectConfig = useProjectConfigStore((state) => state.remove);
const providerAccounts = useProviderStore((state) => state.accounts);
const providerVendors = useProviderStore((state) => state.vendors);
const refreshProviderSnapshot = useProviderStore((state) => state.refreshProviderSnapshot);
const pluginProjection = useCodingPluginsStore((state) => state.projection);
const pluginLoadState = useCodingPluginsStore((state) => state.loadState);
const [draft, setDraft] = useState<CodingProjectConfig | null>(null);
const [drawerMode, setDrawerMode] = useState<DrawerMode>(null);
const [pluginServiceTab, setPluginServiceTab] = useState<PluginServiceTab>('project');
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 [identityKind, setIdentityKind] = useState<ProjectIdentityKind>('create');
const [identityProjectId, setIdentityProjectId] = useState('');
const [identitySaving, setIdentitySaving] = useState(false);
const [identityError, setIdentityError] = useState<string | null>(null);
const [independentCopyDialogOpen, setIndependentCopyDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [partnerDialogOpen, setPartnerDialogOpen] = useState(false);
const [archiveAgentId, setArchiveAgentId] = useState<string | null>(null);
const knowledgeInputRef = useRef<HTMLInputElement | null>(null);
const navigate = useNavigate();
const location = useLocation();
const loadSkills = useCallback(() => {
void getCodingSkills()
.then((result) => setSkills(result.skills.map((skill) => ({
id: skill.id,
...getSkillDisplayInfo(skill.id),
available: skill.available,
effective: skill.effective,
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(() => {
setIdentityKind('create');
setIdentityProjectId('');
setIdentityError(null);
}, [activeProject?.id, draft?.projectId]);
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(() => {
const params = new URLSearchParams(location.search);
if (params.get('resource') !== 'plugins') return;
setPluginServiceTab(getPluginServiceTab(params.get('pluginTab')));
setDrawerMode('plugins');
}, [location.search]);
useEffect(() => { loadSkills(); }, [loadSkills]);
useEffect(() => {
if (drawerMode === 'skills') loadSkills();
}, [drawerMode, loadSkills]);
useEffect(() => { void refreshProviderSnapshot(); }, [refreshProviderSnapshot]);
useEffect(() => {
if (activeProject) void codingPluginsStore.getState().load(activeProject.id).catch(() => undefined);
}, [activeProject]);
const modelOptions = useMemo(
() => buildCodingModelOptions(providerAccounts, providerVendors),
[providerAccounts, providerVendors],
);
const activeAgent = draft?.agents.find((agent) => agent.id === activeAgentId) ?? null;
const activeSkill = skills.find((skill) => skill.id === activeSkillId) ?? null;
const currentPluginProjection = pluginProjection?.project.localProjectId === activeProject?.id ? pluginProjection : null;
const availablePluginCount = currentPluginProjection?.items.length ?? 0;
const enabledPluginCount = currentPluginProjection?.items.filter(({ enabled }) => enabled).length ?? 0;
const pluginSummary = !currentPluginProjection
? pluginLoadState === 'error' ? '项目插件暂不可用' : '正在读取项目插件'
: `${enabledPluginCount} · ${availablePluginCount} `;
const playSaveConfirmation = () => undefined;
const handleBack = () => navigate(-1);
const handlePluginServiceTabChange = (tab: PluginServiceTab) => {
setPluginServiceTab(tab);
const params = new URLSearchParams(location.search);
params.set('resource', 'plugins');
params.set('pluginTab', tab);
navigate({ pathname: '/project-config', search: `?${params.toString()}` }, { replace: true });
};
const openPluginServices = () => {
setPluginServiceTab('project');
setDrawerMode('plugins');
const params = new URLSearchParams(location.search);
params.set('resource', 'plugins');
params.set('pluginTab', 'project');
navigate({ pathname: '/project-config', search: `?${params.toString()}` }, { replace: true });
};
const closePluginServices = () => {
setDrawerMode(null);
const params = new URLSearchParams(location.search);
params.delete('resource');
params.delete('pluginTab');
const search = params.toString();
navigate({ pathname: '/project-config', search: search ? `?${search}` : '' }, { replace: true });
};
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: CodingProjectAgent) => 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 selectedModel = parseCodingModelKey(input.model);
if (!selectedModel) throw new Error('默认模型无效');
const agent = {
...createCustomAgent(draft.agents.length + 1, input.model),
name: input.name,
avatarId: input.avatarId,
avatarDataUrl: input.avatarDataUrl,
model: { ...selectedModel, thinkingLevel: 'off' as const },
modelResolution: 'resolved' as const,
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;
const selectedModel = parseCodingModelKey(input.model);
if (!selectedModel) throw new Error('默认模型无效');
setDraft((current) => current ? {
...current,
agents: current.agents.map((agent) => agent.id === activeAgentId ? {
...agent,
name: input.name,
avatarId: input.avatarId,
avatarDataUrl: input.avatarDataUrl,
model: {
...selectedModel,
thinkingLevel: agent.model?.thinkingLevel ?? 'off',
},
modelResolution: 'resolved',
prompt: input.prompt,
skillIds: input.skillIds,
responsibility: { ...agent.responsibility, mission: input.responsibility },
updatedAt: new Date().toISOString(),
} : agent),
} : current);
};
const submit = async () => {
const names = draft.agents.map((agent) => agent.name.trim()).filter(Boolean);
const invalid = draft.agents.some((agent) => (
!agent.name.trim()
|| !agent.avatarId.trim()
|| !agent.model
|| !agent.responsibility.mission.trim()
)) || new Set(names).size !== draft.agents.length;
if (invalid) {
toast.error('请补全智能体名称、头像、默认模型和职责说明;名称需在当前项目内唯一。');
return;
}
setSaving(true);
try {
await save(activeProject.id, { ...draft, initialized: true });
await reloadWorkspace();
playSaveConfirmation();
toast.success(draft.initialized ? '项目配置已更新' : '项目初始化完成');
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setSaving(false);
}
};
const handleResolveIdentity = async () => {
const boundProjectId = identityProjectId.trim();
if (identityKind === 'bind' && !isCanonicalCodingProjectId(boundProjectId)) {
setIdentityError('请输入有效的项目 ID小写 UUID。');
return;
}
setIdentitySaving(true);
setIdentityError(null);
try {
await resolveIdentity(activeProject.id, identityKind === 'create'
? { kind: 'create' }
: { kind: 'bind', projectId: boundProjectId });
await reloadWorkspace();
toast.success('项目身份已设置,之后不可通过普通保存修改。');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setIdentityError(message);
toast.error(message);
} finally {
setIdentitySaving(false);
}
};
const handleIndependentCopy = async () => {
setIdentitySaving(true);
try {
await makeIndependentCopy(activeProject.id);
await reloadWorkspace();
setIndependentCopyDialogOpen(false);
toast.success('已设为独立副本,当前文件夹已获得新的项目 ID。');
} finally {
setIdentitySaving(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 summaries = codingConversationStore.getState().summariesByConversationId;
const runningConversationIds = conversations
.filter((conversation) => (
conversation.agentId === archiveAgentId
&& summaries[conversation.id]?.runStatus !== 'idle'
))
.map((conversation) => conversation.id);
await Promise.allSettled(runningConversationIds.map(abortCodingConversation));
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);
};
return <main 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 data-testid="project-identity-card" className="surface-card rounded-2xl border border-border/70 bg-background p-5 shadow-soft">
{draft.projectId ? (
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h2 className="text-base font-semibold">项目身份</h2>
<p className="mt-1 text-sm font-medium text-muted-foreground">当前项目 ID</p>
<code className="mt-2 block break-all rounded-lg border border-foreground/10 bg-surface-subtle px-3 py-2 text-xs font-semibold text-foreground" data-testid="project-identity-value">{draft.projectId}</code>
<p className="mt-2 text-xs font-medium leading-5 text-muted-foreground">
同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。
</p>
</div>
<Button
type="button"
variant="outline"
data-testid="independent-copy-button"
className="shrink-0 border-brand/25 bg-brand-soft text-foreground"
onClick={() => setIndependentCopyDialogOpen(true)}
disabled={identitySaving}
>
设为独立副本
</Button>
</div>
) : (
<form
data-testid="legacy-project-identity-card"
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void handleResolveIdentity();
}}
>
<div>
<h2 className="text-base font-semibold">为旧项目设置身份</h2>
<p className="mt-1 text-sm font-medium leading-5 text-muted-foreground">
这是一个没有项目 ID 的旧项目。此操作只会为当前文件夹设置一次身份;项目文件和云端数据都不会被复制。
</p>
</div>
<fieldset className="space-y-2">
<legend className="text-xs font-semibold">选择项目 ID</legend>
<div className="grid gap-2 sm:grid-cols-2">
<label className={cn(
'cursor-pointer rounded-xl border border-foreground/10 bg-white p-3 text-sm font-semibold',
identityKind === 'create' ? 'ring-2 ring-brand/35 shadow-float' : 'hover:shadow-float',
)}>
<input
type="radio"
name="legacy-project-identity"
value="create"
aria-label="创建新的项目 ID"
checked={identityKind === 'create'}
onChange={() => {
setIdentityKind('create');
setIdentityError(null);
}}
disabled={identitySaving}
/>
<span className="ml-2">创建新的项目 ID</span>
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground">
生成新的小写 UUID不会复制云端开发数据。
</span>
</label>
<label className={cn(
'cursor-pointer rounded-xl border border-foreground/10 bg-white p-3 text-sm font-semibold',
identityKind === 'bind' ? 'ring-2 ring-brand/35 shadow-float' : 'hover:shadow-float',
)}>
<input
type="radio"
name="legacy-project-identity"
value="bind"
aria-label="绑定已有项目 ID"
checked={identityKind === 'bind'}
onChange={() => {
setIdentityKind('bind');
setIdentityError(null);
}}
disabled={identitySaving}
/>
<span className="ml-2">绑定已有项目 ID</span>
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground">
只接受规范的小写 UUID。
</span>
</label>
</div>
</fieldset>
{identityKind === 'bind' ? (
<div className="space-y-2 rounded-xl border border-foreground/10 bg-surface-subtle p-3">
<label className="block text-xs font-semibold" htmlFor="legacy-project-identity-id">已有项目 ID</label>
<input
id="legacy-project-identity-id"
value={identityProjectId}
onChange={(event) => {
setIdentityProjectId(event.target.value);
setIdentityError(null);
}}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
autoComplete="off"
spellCheck={false}
className="w-full rounded-md border border-foreground/15 bg-white px-3 py-2 font-mono text-sm font-medium outline-none focus:ring-2 focus:ring-brand/20"
disabled={identitySaving}
/>
<p className="text-[11px] font-medium leading-5 text-muted-foreground">
同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。
</p>
</div>
) : null}
{identityError ? <p role="alert" data-testid="project-identity-error" className="rounded-md border border-foreground/15 bg-accent-soft px-3 py-2 text-xs font-semibold">{identityError}</p> : null}
<div className="flex justify-end">
<Button type="submit" data-testid="resolve-project-identity-button" className="border border-brand/20 bg-brand text-primary-foreground" disabled={identitySaving || (identityKind === 'bind' && !identityProjectId.trim())}>
{identitySaving ? '设置中…' : '设置项目身份'}
</Button>
</div>
</form>
)}
</section>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"><ResourceCard id="models" icon={<Cpu />} title="可用模型" subtitle={`${modelOptions.length} `} onClick={() => setDrawerMode('models')} /><ResourceCard id="skills" icon={<Blocks />} title="可用技能" subtitle={`${skills.length} `} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} /><ResourceCard id="knowledge" icon={<Files />} title="知识文件" subtitle={`${knowledge.length} `} onClick={() => setDrawerMode('knowledge')} /><ResourceCard id="plugins" icon={<Puzzle />} title="插件服务" subtitle={pluginSummary} onClick={openPluginServices} /></section>
<section id="project-agents-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) => <Button key={agent.id} type="button" variant="outline" className="border-border/70 bg-surface-subtle" onClick={() => updateAgent({ ...agent, archivedAt: null })}>{agent.name || '未命名智能体'} · 恢复</Button>)}</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) { if (drawerMode === 'plugins') closePluginServices(); else setDrawerMode(null); setActiveSkillId(null); } }}><SheetContent className={cn('glass-surface flex flex-col overflow-hidden border-l border-border/70 bg-background/90 p-0 text-foreground sm:max-w-none', drawerMode === 'plugins' ? 'w-[min(1080px,96vw)]' : 'w-[min(430px,94vw)]')}>
{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.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"><Blocks 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}
{drawerMode === 'plugins' ? <DrawerFrame title="插件服务" desc="发现与下载插件,并为当前项目单独启用;伙伴 Skill 仍在智能体配置中分配。" onClose={closePluginServices}><PluginServices value={pluginServiceTab} onValueChange={handlePluginServiceTabChange} onProjectPluginsChanged={loadSkills} onResolveIdentity={closePluginServices} onManageAgents={closePluginServices} /></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={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))}
/>
<ConfirmDialog
open={independentCopyDialogOpen}
title="设为独立副本?"
message="只更新当前文件夹中的项目 ID项目文件保持原位不会移动或复制文件也不会复制云端开发数据。此操作会为当前文件夹生成新的项目 ID原项目不会被修改。"
confirmLabel="确认设为独立副本"
cancelLabel="取消"
onCancel={() => setIndependentCopyDialogOpen(false)}
onConfirm={handleIndependentCopy}
onError={(error) => toast.error(error instanceof Error ? error.message : String(error))}
/>
</main>;
}
export default ProjectConfiguration;