feat: organize project conversations and add coding teacher side chat

This commit is contained in:
brother7 committed 2026-09-22 12:30:26 +08:00
1 parent 2f82b9f79c
commit 9242a40d87
45 files changed
+2987 -1137

No files matched your search

+21 -147
View File
@@ -1,20 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ArrowLeft, Check, Cpu, Files, Plus, Puzzle, Trash2, Upload, X } from 'lucide-react';
import { ArrowLeft, Check, Cpu, Files, 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 { ProjectPublishAction } from '@/components/works/ProjectPublishAction';
import { Plugins } from '@/pages/Plugins';
import { PROJECT_SETTINGS_PLUGINS_PATH } from '@/pages/Plugins/legacy-plugin-redirect';
import { buildCodingModelOptions, parseCodingModelKey, type CodingModelOption } from '@/lib/coding-model-options';
import { getCodingSkills } from '@/lib/coding-product-tools';
import { abortCodingConversation } from '@/lib/coding-conversations';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import { codingConversationStore } from '@/stores/coding-conversations';
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
import { useProjectConfigStore } from '@/stores/project-config';
import { useProviderStore } from '@/stores/providers';
@@ -39,28 +35,6 @@ const THINKING_LEVEL_LABELS = {
max: '最高',
} as const;
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 min-w-0 rounded-2xl border border-border/70 bg-background p-3 text-left shadow-none hover:shadow-soft">
<div className="flex min-w-0 items-center gap-2"><span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-surface-subtle text-brand [&>svg]:h-4 [&>svg]:w-4">{icon}</span><p className="truncate text-sm font-semibold">{title}</p></div><p className="mt-1.5 truncate text-[11px] font-medium text-muted-foreground">{subtitle}</p>
@@ -89,18 +63,6 @@ function PluginWorkspaceSheet({ open, onClose }: { open: boolean; onClose: () =>
</Sheet>;
}
function AgentCard({
agent,
capabilityCount,
onOpen,
}: {
agent: CodingProjectAgent;
capabilityCount: number;
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">{capabilityCount} 项插件能力</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">
@@ -133,11 +95,11 @@ function ModelCard({ model }: { model: CodingModelOption }) {
export 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>
<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>
@@ -146,7 +108,6 @@ export function ModelList({ models }: { models: CodingModelOption[] }) {
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);
@@ -164,8 +125,6 @@ export function ProjectConfiguration() {
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [saving, setSaving] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [partnerDialogOpen, setPartnerDialogOpen] = useState(false);
const [archiveAgentId, setArchiveAgentId] = useState<string | null>(null);
const knowledgeInputRef = useRef<HTMLInputElement | null>(null);
const navigate = useNavigate();
const location = useLocation();
@@ -187,7 +146,6 @@ export function ProjectConfiguration() {
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(() => { void refreshProviderSnapshot(); }, [refreshProviderSnapshot]);
@@ -195,7 +153,7 @@ export function ProjectConfiguration() {
() => buildCodingModelOptions(providerAccounts, providerVendors),
[providerAccounts, providerVendors],
);
const activeAgent = draft?.agents.find((agent) => agent.id === activeAgentId) ?? null;
const activeAgent = draft?.agents.find((agent) => agent.id === (activeAgentId ?? draft.defaultAgentId)) ?? null;
const playSaveConfirmation = () => undefined;
const handleBack = () => navigate(-1);
const pluginWorkspaceOpen = location.pathname === PROJECT_SETTINGS_PLUGINS_PATH;
@@ -208,65 +166,11 @@ export function ProjectConfiguration() {
navigate('/project-config', { replace: true });
};
if (!activeProject) return <><main data-testid="project-configuration-empty-state" className="min-h-full bg-background p-8 text-foreground"><div className="mx-auto w-full max-w-7xl"><h1 className="text-xl font-semibold">项目配置</h1><p className="mt-4 text-xl font-semibold tracking-[-0.02em]">一念成光,万物可创。</p><p className="mt-2 text-sm font-medium text-muted-foreground">新建或打开一个项目后即可配置智能体、知识与项目能力;插件目录仍可在这里查看。</p><div className="mt-6 max-w-sm"><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="查看可选与已安装能力" onClick={openPluginWorkspace} /></div></div></main><PluginWorkspaceSheet open={pluginWorkspaceOpen} onClose={closePluginWorkspace} /></>;
if (!activeProject) return <><main data-testid="project-configuration-empty-state" className="min-h-full bg-background p-8 text-foreground"><div className="mx-auto w-full max-w-7xl"><h1 className="text-xl font-semibold">项目配置</h1><p className="mt-4 text-xl font-semibold tracking-[-0.02em]">一念成光,万物可创。</p><p className="mt-2 text-sm font-medium text-muted-foreground">新建或打开一个项目后即可配置模型、提示词、知识与项目能力;插件目录仍可在这里查看。</p><div className="mt-6 max-w-sm"><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="查看可选与已安装能力" onClick={openPluginWorkspace} /></div></div></main><PluginWorkspaceSheet open={pluginWorkspaceOpen} onClose={closePluginWorkspace} /></>;
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 });
@@ -287,28 +191,6 @@ export function ProjectConfiguration() {
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">
@@ -318,7 +200,22 @@ export function ProjectConfiguration() {
<h1 className="text-xl font-semibold">项目配置</h1>
</div>
<section data-testid="project-resource-row" className="grid grid-cols-3 gap-3"><ResourceCard id="models" icon={<Cpu />} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} /><ResourceCard id="knowledge" icon={<Files />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} /><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="管理云端与本机能力" onClick={openPluginWorkspace} /></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} capabilityCount={agent.skillIds.filter((id) => skills.some((skill) => skill.id === id)).length} 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>
{activeAgent && <section className="surface-card rounded-2xl border border-border/70 p-5" data-testid="project-default-settings">
<h2 className="text-lg font-semibold">{activeAgent.id === draft.defaultAgentId ? '默认编程配置' : '历史会话编程配置'}</h2>
<p className="mt-1 text-sm text-muted-foreground">新会话使用项目默认配置;历史会话保留原来的配置与记录。</p>
<label className="mt-4 block text-sm">默认模型<select className="mt-2 block w-full rounded-lg border bg-background p-2"
value={activeAgent.model ? JSON.stringify([activeAgent.model.accountId, activeAgent.model.modelId]) : ''}
onChange={event => { const model = parseCodingModelKey(event.target.value); updateAgent({...activeAgent, model: model ? {...model, thinkingLevel: 'off', reasoningChoice: {mode: 'default'}} : null, modelResolution: model ? 'resolved' : 'required'}); }}>
<option value="">发送前选择模型</option>{modelOptions.map(model => <option key={model.key} value={model.key}>{model.modelId}</option>)}
</select></label>
<label className="mt-4 block text-sm">项目提示词<textarea className="mt-2 min-h-36 w-full rounded-lg border bg-background p-3" value={activeAgent.prompt} onChange={event => updateAgent({...activeAgent,prompt:event.target.value})} /></label>
<h3 className="mt-4 text-sm font-medium">项目 Skills</h3>
<div className="mt-2 space-y-2">{skills.map(skill => <label key={skill.id} className="flex items-start gap-2 text-sm">
<input type="checkbox" checked={activeAgent.skillIds.includes(skill.id)} disabled={!skill.available}
onChange={event => updateAgent({...activeAgent,skillIds:event.target.checked ? [...activeAgent.skillIds,skill.id] : activeAgent.skillIds.filter(id=>id!==skill.id)})} />
<span>{skill.name}<span className="block text-xs text-muted-foreground">{skill.description}</span></span>
</label>)}</div>
</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">
@@ -335,34 +232,11 @@ export function ProjectConfiguration() {
</div>
</div>
</div>
<AgentCreationDialog
open={partnerDialogOpen}
onOpenChange={handlePartnerDialogOpenChange}
modelOptions={modelOptions}
skills={skills}
agent={activeAgent}
existingAgentNames={draft.agents.filter((agent) => agent.id !== activeAgentId).map((agent) => agent.name)}
onCreate={handleCreatePartner}
onUpdate={handleUpdatePartner}
onArchive={activeAgent && !activeAgent.builtIn ? () => setArchiveAgentId(activeAgent.id) : undefined}
title={activeAgent ? `${activeAgent.name || '未命名智能体'} · 编辑智能体` : '创建项目智能体'}
submitLabel={activeAgent ? '保存智能体' : '创建智能体'}
/>
<Sheet open={drawerMode !== null} onOpenChange={(open) => { if (!open) setDrawerMode(null); }}><SheetContent className="glass-surface flex w-[min(430px,94vw)] flex-col overflow-hidden border-l border-border/70 bg-background/90 p-0 text-foreground sm:max-w-none">
{drawerMode === 'models' ? <DrawerFrame title="可用模型" desc="查看具体模型与可选思考强度。" onClose={() => setDrawerMode(null)}><ModelList models={modelOptions} /></DrawerFrame> : null}
{drawerMode === 'knowledge' ? <DrawerFrame title="知识文件" desc="文件保存在当前项目的 knowledge/ 目录中。" onClose={() => setDrawerMode(null)}><input ref={knowledgeInputRef} type="file" className="sr-only" onChange={(event) => { void handleKnowledge(event.target.files?.[0]); event.currentTarget.value = ''; }} /><Button className="w-full border border-foreground/15 bg-brand-soft text-foreground" onClick={() => knowledgeInputRef.current?.click()}><Upload className="mr-2 h-4 w-4" />上传知识文件</Button><div className="mt-5 space-y-3">{knowledge.length ? knowledge.map((file) => <div key={file} className="rounded-lg border border-foreground/15 bg-white p-3 font-semibold">{file}</div>) : <div className="rounded-lg border border-dashed border-foreground/15 bg-white p-4 text-sm font-medium">当前还没有知识文件。</div>}</div></DrawerFrame> : null}
</SheetContent></Sheet>
<PluginWorkspaceSheet open={pluginWorkspaceOpen} onClose={closePluginWorkspace} />
<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}”?`}