feat(coding): add project identity UX

This commit is contained in:
2026-08-26 18:48:22 +08:00
parent 5ac08d509f
commit ce8e2103d9
10 changed files with 683 additions and 0 deletions

View File

@@ -48,9 +48,11 @@ 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 = {
@@ -70,6 +72,7 @@ type ProjectEntryError = {
};
type ProjectDirectoryMode = 'use-selected-directory' | 'create-child-directory';
type ProjectIdentityKind = ProjectIdentityChoice['kind'];
function getFolderName(pathValue: string): string {
const trimmed = pathValue.trim();
@@ -164,6 +167,8 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
const [newProjectSelectedPath, setNewProjectSelectedPath] = useState('');
const [newProjectDirectoryMode, setNewProjectDirectoryMode] = useState<ProjectDirectoryMode>('use-selected-directory');
const [newProjectType, setNewProjectType] = useState<ProjectType>('mini_game');
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);
@@ -394,6 +399,8 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
setNewProjectSelectedPath('');
setNewProjectDirectoryMode('use-selected-directory');
setNewProjectType('mini_game');
setNewProjectIdentityKind('create');
setNewProjectIdentityProjectId('');
setCreateProjectError(null);
setCreateDialogOpen(true);
};
@@ -402,6 +409,8 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
if (creatingProject) return;
setCreateDialogOpen(false);
setNewProjectType('mini_game');
setNewProjectIdentityKind('create');
setNewProjectIdentityProjectId('');
setCreateProjectError(null);
};
@@ -454,12 +463,20 @@ 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 },
...(newProjectDirectoryMode === 'create-child-directory'
? { parentPath: selectedPath, projectName }
: { projectPath: selectedPath }),
@@ -744,6 +761,81 @@ 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-3">

View File

@@ -5,6 +5,13 @@ import type {
CodingProjectSummary,
} from '@/types/coding-project';
import type { ProjectType } from '../../shared/project-config';
import type { ProjectIdentityChoice } from '../../shared/coding-project-contracts';
const CANONICAL_PROJECT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
export function isCanonicalCodingProjectId(value: string): boolean {
return CANONICAL_PROJECT_ID_PATTERN.test(value.trim());
}
export interface CodingProjectCatalog {
projects: CodingProjectSummary[];
@@ -28,6 +35,7 @@ export async function createCodingProject(input: {
parentPath?: string;
projectName?: string;
projectType?: ProjectType;
identity: ProjectIdentityChoice;
}): Promise<CodingProjectConfigSnapshot> {
const response = await hostApiFetch<{ snapshot: CodingProjectConfigSnapshot }>('/api/coding/projects/create', {
method: 'POST',
@@ -36,6 +44,33 @@ export async function createCodingProject(input: {
return response.snapshot;
}
export async function resolveCodingProjectIdentity(
localProjectId: string,
identity: ProjectIdentityChoice,
): Promise<CodingProjectConfigSnapshot> {
const response = await hostApiFetch<{ snapshot: CodingProjectConfigSnapshot }>(
'/api/coding/projects/identity',
{
method: 'POST',
body: JSON.stringify({ localProjectId, identity }),
},
);
return response.snapshot;
}
export async function makeCodingProjectIndependentCopy(
localProjectId: string,
): Promise<CodingProjectConfigSnapshot> {
const response = await hostApiFetch<{ snapshot: CodingProjectConfigSnapshot }>(
'/api/coding/projects/identity/independent-copy',
{
method: 'POST',
body: JSON.stringify({ localProjectId, confirmed: true }),
},
);
return response.snapshot;
}
export async function setActiveCodingProject(projectId: string): Promise<CodingProjectSummary> {
const response = await hostApiFetch<{ project: CodingProjectSummary }>('/api/coding/projects/active', {
method: 'POST',

View File

@@ -11,16 +11,20 @@ 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; name: string; description: string; location: string; content: string; entries: SkillStructureEntry[] };
@@ -121,6 +125,8 @@ 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);
@@ -132,6 +138,11 @@ 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);
@@ -152,6 +163,11 @@ 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;
@@ -243,6 +259,39 @@ export function ProjectConfiguration() {
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);
@@ -281,6 +330,120 @@ 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="🧠" 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) => <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">
@@ -339,6 +502,16 @@ 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>;
}

View File

@@ -20,6 +20,7 @@ import type {
CodingProjectSummary,
} from '@/types/coding-project';
import type { ProjectType } from '../../shared/project-config';
import type { ProjectIdentityChoice } from '../../shared/coding-project-contracts';
interface CodingWorkspaceDependencies {
listProjects(): Promise<CodingProjectCatalog>;
@@ -43,6 +44,7 @@ interface CodingWorkspaceDependencies {
parentPath?: string;
projectName?: string;
projectType?: ProjectType;
identity: ProjectIdentityChoice;
}): Promise<{ project: CodingProjectSummary; config: CodingProjectConfig; knowledgeFiles: string[] }>;
setActiveProject(projectId: string): Promise<CodingProjectSummary>;
removeProject(projectId: string): Promise<void>;
@@ -71,6 +73,7 @@ export interface CodingWorkspaceState {
parentPath?: string;
projectName?: string;
projectType?: ProjectType;
identity: ProjectIdentityChoice;
}): Promise<CodingProjectSummary>;
setActiveProject(projectId: string): Promise<CodingProjectSummary>;
removeProject(projectId: string): Promise<void>;

View File

@@ -1,6 +1,11 @@
import { create } from 'zustand';
import { hostApiFetch } from '@/lib/host-api';
import {
makeCodingProjectIndependentCopy,
resolveCodingProjectIdentity,
} from '@/lib/coding-projects';
import type { CodingProjectConfig, CodingProjectConfigSnapshot } from '@/types/coding-project';
import type { ProjectIdentityChoice } from '../../shared/coding-project-contracts';
type ProjectConfigResponse = {
status: 'valid' | 'missing' | 'invalid';
@@ -16,6 +21,8 @@ type ProjectConfigState = {
errorsByProjectId: Record<string, string>;
load: (projectId: string) => Promise<ProjectConfigResponse>;
save: (projectId: string, config: CodingProjectConfig) => Promise<CodingProjectConfig>;
resolveIdentity: (projectId: string, identity: ProjectIdentityChoice) => Promise<CodingProjectConfig>;
makeIndependentCopy: (projectId: string) => Promise<CodingProjectConfig>;
uploadKnowledge: (projectId: string, file: File) => Promise<void>;
remove: (projectId: string) => void;
};
@@ -64,6 +71,26 @@ export const useProjectConfigStore = create<ProjectConfigState>((set) => ({
return response.snapshot.config;
},
async resolveIdentity(projectId, identity) {
const snapshot = await resolveCodingProjectIdentity(projectId, identity);
set((state) => ({
configsByProjectId: { ...state.configsByProjectId, [projectId]: snapshot.config },
knowledgeByProjectId: { ...state.knowledgeByProjectId, [projectId]: snapshot.knowledgeFiles },
errorsByProjectId: Object.fromEntries(Object.entries(state.errorsByProjectId).filter(([id]) => id !== projectId)),
}));
return snapshot.config;
},
async makeIndependentCopy(projectId) {
const snapshot = await makeCodingProjectIndependentCopy(projectId);
set((state) => ({
configsByProjectId: { ...state.configsByProjectId, [projectId]: snapshot.config },
knowledgeByProjectId: { ...state.knowledgeByProjectId, [projectId]: snapshot.knowledgeFiles },
errorsByProjectId: Object.fromEntries(Object.entries(state.errorsByProjectId).filter(([id]) => id !== projectId)),
}));
return snapshot.config;
},
async uploadKnowledge(projectId, file) {
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = '';