feat: simplify code project creation
This commit is contained in:
@@ -226,7 +226,7 @@ function ProjectChatRoute() {
|
||||
const configResult = await loadProjectConfig(project.id);
|
||||
if (!cancelled) {
|
||||
setResolvedProjectKey(project.id);
|
||||
setResolvedRoute(configResult.status === 'valid' && Boolean(configResult.config?.initialized) ? 'chat' : 'config');
|
||||
setResolvedRoute(configResult.status === 'valid' && Boolean(configResult.config) ? 'chat' : 'config');
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
* Main Layout Component
|
||||
* TitleBar at top, then sidebar + content below.
|
||||
*/
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { LockKeyhole, Settings2 } from 'lucide-react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { TitleBar } from './TitleBar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getAiModuleForPath } from '@/lib/ai-modules';
|
||||
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import { useProjectConfigStore } from '@/stores/project-config';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SidebarPeekSource } from './sidebar-peek';
|
||||
@@ -18,12 +14,8 @@ import type { SidebarPeekSource } from './sidebar-peek';
|
||||
const SIDEBAR_PEEK_CLOSE_DELAY_MS = 180;
|
||||
|
||||
export function MainLayout() {
|
||||
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
|
||||
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||
const config = useProjectConfigStore((state) => activeProject ? state.configsByProjectId[activeProject.id] : undefined);
|
||||
const load = useProjectConfigStore((state) => state.load);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [sidebarPeekOpen, setSidebarPeekOpen] = useState(false);
|
||||
const sidebarPeekCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const sidebarPeekSourcesRef = useRef<Record<SidebarPeekSource, boolean>>({
|
||||
@@ -33,13 +25,9 @@ export function MainLayout() {
|
||||
titlebar: false,
|
||||
});
|
||||
const activeModule = getAiModuleForPath(location.pathname);
|
||||
const isProgrammingModule = activeModule === 'programming';
|
||||
const isPaintingModule = activeModule === 'painting';
|
||||
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
|
||||
const isChatWorkspace = location.pathname === '/chat';
|
||||
const isInitializationSafeRoute = location.pathname === '/project-config'
|
||||
|| location.pathname === '/plugins'
|
||||
|| !isProgrammingModule;
|
||||
|
||||
const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => {
|
||||
if (!sidebarCollapsed) return;
|
||||
@@ -84,11 +72,6 @@ export function MainLayout() {
|
||||
if (sidebarPeekCloseTimerRef.current) clearTimeout(sidebarPeekCloseTimerRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProject && isProgrammingModule) void load(activeProject.id).catch(() => undefined);
|
||||
}, [activeProject, isProgrammingModule, load]);
|
||||
|
||||
const initializationBlocked = Boolean(activeProject && (!config || !config.initialized) && !isInitializationSafeRoute);
|
||||
return (
|
||||
<div data-testid="main-layout" className="relative flex h-[100dvh] flex-col overflow-hidden bg-transparent font-sans">
|
||||
{/* Title bar: drag region on macOS, icon + controls on Windows */}
|
||||
@@ -117,16 +100,6 @@ export function MainLayout() {
|
||||
)}
|
||||
>
|
||||
<Outlet />
|
||||
{initializationBlocked ? (
|
||||
<div data-testid="project-initialization-gate" className="glass-surface absolute inset-0 z-[100] flex items-center justify-center bg-background/90 p-6">
|
||||
<div className="surface-card max-w-md rounded-2xl border border-border/80 bg-background p-8 text-center shadow-float">
|
||||
<LockKeyhole className="mx-auto h-10 w-10" />
|
||||
<h1 className="mt-4 text-2xl font-semibold">项目尚未初始化</h1>
|
||||
<p className="mt-3 text-sm font-medium text-muted-foreground">请先在项目配置中完成至少一个手动 Agent 的名称、模型和职责设置。</p>
|
||||
<Button onClick={() => navigate('/project-config')} className="mt-6 border border-brand/20 bg-brand font-semibold text-primary-foreground"><Settings2 className="mr-2 h-4 w-4" />打开项目配置</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,11 +48,8 @@ import { useSettingsStore } from '@/stores/settings';
|
||||
import { useAuthStore, type AuthUser } from '@/stores/auth';
|
||||
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import { useProviderStore } from '@/stores/providers';
|
||||
import { isCanonicalCodingProjectId } from '@/lib/coding-projects';
|
||||
import type { CodingProjectSummary } from '@/types/coding-project';
|
||||
import { useProjectConfigStore } from '@/stores/project-config';
|
||||
import type { ProjectType } from '../../../shared/project-config';
|
||||
import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type OpenDialogResult = {
|
||||
@@ -72,7 +69,6 @@ type ProjectEntryError = {
|
||||
};
|
||||
|
||||
type ProjectDirectoryMode = 'use-selected-directory' | 'create-child-directory';
|
||||
type ProjectIdentityKind = ProjectIdentityChoice['kind'];
|
||||
|
||||
function getFolderName(pathValue: string): string {
|
||||
const trimmed = pathValue.trim();
|
||||
@@ -166,9 +162,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [newProjectSelectedPath, setNewProjectSelectedPath] = useState('');
|
||||
const [newProjectDirectoryMode, setNewProjectDirectoryMode] = useState<ProjectDirectoryMode>('use-selected-directory');
|
||||
const [newProjectType, setNewProjectType] = useState<ProjectType>('interactive_ai_app');
|
||||
const [newProjectIdentityKind, setNewProjectIdentityKind] = useState<ProjectIdentityKind>('create');
|
||||
const [newProjectIdentityProjectId, setNewProjectIdentityProjectId] = useState('');
|
||||
const [createProjectError, setCreateProjectError] = useState<string | null>(null);
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
const [projectEntryError, setProjectEntryError] = useState<ProjectEntryError | null>(null);
|
||||
@@ -186,7 +179,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
const isPaintingModule = activeModule === 'painting';
|
||||
const isRobotModule = activeModule === 'robot';
|
||||
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
|
||||
const projectConfigPath = '/project-config';
|
||||
const visibleProjects = projects;
|
||||
const selectedProjectFolderName = getFolderName(newProjectSelectedPath);
|
||||
const accountName = userProfile?.displayName?.trim() || getAuthUserDisplayName(authUser) || '未登录用户';
|
||||
@@ -397,9 +389,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
setNewProjectName('');
|
||||
setNewProjectSelectedPath('');
|
||||
setNewProjectDirectoryMode('use-selected-directory');
|
||||
setNewProjectType('interactive_ai_app');
|
||||
setNewProjectIdentityKind('create');
|
||||
setNewProjectIdentityProjectId('');
|
||||
setCreateProjectError(null);
|
||||
setCreateDialogOpen(true);
|
||||
};
|
||||
@@ -407,9 +396,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
const closeCreateProject = () => {
|
||||
if (creatingProject) return;
|
||||
setCreateDialogOpen(false);
|
||||
setNewProjectType('interactive_ai_app');
|
||||
setNewProjectIdentityKind('create');
|
||||
setNewProjectIdentityProjectId('');
|
||||
setCreateProjectError(null);
|
||||
};
|
||||
|
||||
@@ -436,9 +422,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
return;
|
||||
}
|
||||
await setActiveProject(project.id);
|
||||
navigate(result.config.initialized
|
||||
? '/chat'
|
||||
: projectConfigPath);
|
||||
navigate('/chat');
|
||||
};
|
||||
|
||||
const readAndEnterProject = async (project: CodingProjectSummary) => {
|
||||
@@ -462,20 +446,12 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
setCreateProjectError('请选择项目路径');
|
||||
return;
|
||||
}
|
||||
const boundProjectId = newProjectIdentityProjectId.trim();
|
||||
if (newProjectIdentityKind === 'bind' && !isCanonicalCodingProjectId(boundProjectId)) {
|
||||
setCreateProjectError('请输入有效的项目 ID(小写 UUID)。');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatingProject(true);
|
||||
setCreateProjectError(null);
|
||||
try {
|
||||
const project = await createProject({
|
||||
projectType: newProjectType,
|
||||
identity: newProjectIdentityKind === 'create'
|
||||
? { kind: 'create' }
|
||||
: { kind: 'bind', projectId: boundProjectId },
|
||||
projectType: 'interactive_ai_app',
|
||||
identity: { kind: 'create' },
|
||||
...(newProjectDirectoryMode === 'create-child-directory'
|
||||
? { parentPath: selectedPath, projectName }
|
||||
: { projectPath: selectedPath }),
|
||||
@@ -756,7 +732,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
<DialogHeader className="flex-row items-start justify-between gap-3 space-y-0">
|
||||
<div>
|
||||
<DialogTitle className="text-xl">新建项目</DialogTitle>
|
||||
<DialogDescription className="mt-1 text-xs">默认直接使用所选文件夹,也可以在所选位置新建下级文件夹。</DialogDescription>
|
||||
<DialogDescription className="mt-1 text-xs">只需选择项目文件夹,其他设置会自动准备好。</DialogDescription>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -775,120 +751,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
void confirmCreateProject();
|
||||
}}
|
||||
>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-xs font-semibold">项目身份</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',
|
||||
newProjectIdentityKind === 'create' ? 'ring-2 ring-brand/35 shadow-float' : 'hover:shadow-float',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="project-identity"
|
||||
value="create"
|
||||
aria-label="创建新的项目 ID"
|
||||
checked={newProjectIdentityKind === 'create'}
|
||||
onChange={() => {
|
||||
setNewProjectIdentityKind('create');
|
||||
setCreateProjectError(null);
|
||||
}}
|
||||
disabled={creatingProject}
|
||||
/>
|
||||
<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',
|
||||
newProjectIdentityKind === 'bind' ? 'ring-2 ring-brand/35 shadow-float' : 'hover:shadow-float',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="project-identity"
|
||||
value="bind"
|
||||
aria-label="绑定已有项目 ID"
|
||||
checked={newProjectIdentityKind === 'bind'}
|
||||
onChange={() => {
|
||||
setNewProjectIdentityKind('bind');
|
||||
setCreateProjectError(null);
|
||||
}}
|
||||
disabled={creatingProject}
|
||||
/>
|
||||
<span className="ml-2">绑定已有项目 ID</span>
|
||||
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground">
|
||||
仅接受规范的小写 UUID;绑定不会把项目 ID 当作登录凭据。
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{newProjectIdentityKind === '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="new-project-identity-id">
|
||||
已有项目 ID
|
||||
</label>
|
||||
<input
|
||||
id="new-project-identity-id"
|
||||
value={newProjectIdentityProjectId}
|
||||
onChange={(event) => {
|
||||
setNewProjectIdentityProjectId(event.target.value);
|
||||
setCreateProjectError(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={creatingProject}
|
||||
/>
|
||||
<p className="text-[11px] font-medium leading-5 text-muted-foreground">
|
||||
同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-xs font-semibold">项目类型</legend>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{([
|
||||
['interactive_ai_app', '交互式 AI 应用', '创建可交互、可发布的 AI 应用项目;进入对话后可用项目初始化 Skill 生成工程骨架。'],
|
||||
['custom', '自定义项目', '只创建项目空间,不配置默认发布方式。'],
|
||||
] as const).map(([value, title, description]) => (
|
||||
<label
|
||||
key={value}
|
||||
className={cn(
|
||||
'min-h-28 cursor-pointer rounded-xl bg-white p-3 shadow-soft',
|
||||
newProjectType === value
|
||||
? 'ring-2 ring-brand/35 shadow-float'
|
||||
: 'ring-1 ring-foreground/10 hover:shadow-float',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-semibold">
|
||||
<input
|
||||
type="radio"
|
||||
name="project-type"
|
||||
value={value}
|
||||
aria-label={title}
|
||||
checked={newProjectType === value}
|
||||
onChange={() => {
|
||||
setNewProjectType(value);
|
||||
setCreateProjectError(null);
|
||||
}}
|
||||
disabled={creatingProject}
|
||||
/>
|
||||
{title}
|
||||
</span>
|
||||
<span className="mt-2 block text-[11px] font-medium leading-5 text-muted-foreground [text-wrap:pretty]">
|
||||
{description}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-xs font-semibold">项目目录方式</legend>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
|
||||
@@ -681,6 +681,25 @@ export function CodingChatPanel({
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: agents.length === 0
|
||||
? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center p-6" data-testid="coding-chat-empty-agent">
|
||||
<div className="max-w-sm text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-surface-subtle shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
|
||||
<Bot className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
|
||||
</div>
|
||||
<h1 className="mt-4 text-balance text-lg font-semibold">项目已准备好</h1>
|
||||
<p className="mt-2 text-pretty text-sm leading-6 text-muted-foreground">
|
||||
需要开始对话时,再创建一个项目智能体并选择模型即可。
|
||||
</p>
|
||||
{onOpenProjectSettings ? (
|
||||
<Button className="mt-5 min-h-10 rounded-xl" onClick={onOpenProjectSettings}>
|
||||
创建项目智能体
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="min-h-0 flex-1" data-testid="coding-conversation-pending" />
|
||||
)}
|
||||
|
||||
@@ -11,20 +11,16 @@ 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 { getAgentAvatarSrc } from '@/lib/agent-avatars';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { codingConversationStore } from '@/stores/coding-conversations';
|
||||
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' | null;
|
||||
type ProjectIdentityKind = ProjectIdentityChoice['kind'];
|
||||
type SkillStructureEntry = { path: string; type: 'file' | 'directory' };
|
||||
type SkillInfo = {
|
||||
id: string;
|
||||
@@ -134,8 +130,6 @@ export function ProjectConfiguration() {
|
||||
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);
|
||||
@@ -147,11 +141,6 @@ export function ProjectConfiguration() {
|
||||
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);
|
||||
@@ -174,11 +163,6 @@ export function ProjectConfiguration() {
|
||||
|
||||
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;
|
||||
@@ -263,46 +247,13 @@ export function ProjectConfiguration() {
|
||||
await save(activeProject.id, { ...draft, initialized: true });
|
||||
await reloadWorkspace();
|
||||
playSaveConfirmation();
|
||||
toast.success(draft.initialized ? '项目配置已更新' : '项目初始化完成');
|
||||
toast.success('项目配置已保存');
|
||||
} 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);
|
||||
@@ -341,120 +292,6 @@ export function ProjectConfiguration() {
|
||||
</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 md:grid-cols-3"><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')} /></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) => <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">
|
||||
@@ -469,7 +306,7 @@ export function ProjectConfiguration() {
|
||||
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>
|
||||
<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 ? '保存中…' : '保存项目配置'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -513,16 +350,6 @@ export function ProjectConfiguration() {
|
||||
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>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user