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

@@ -1,230 +0,0 @@
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
import { Search, Trash2, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { prepareAgentAvatar } from '@/lib/agent-avatar-upload';
import { agentAvatarOptions } from '@/lib/agent-avatars';
import { codingModelKey, type CodingModelOption } from '@/lib/coding-model-options';
import { cn } from '@/lib/utils';
import type { CodingProjectAgent } from '@/types/coding-project';
export type AgentCreationSkillOption = {
id: string;
name: string;
description: string;
available?: boolean;
effective?: boolean;
};
export type AgentCreationInput = {
name: string;
avatarId: string;
avatarDataUrl?: string;
model: string;
responsibility: string;
prompt: string;
skillIds: string[];
};
type AgentCreationDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
modelOptions: CodingModelOption[];
skills?: AgentCreationSkillOption[];
existingAgentNames?: string[];
agent?: CodingProjectAgent | null;
onCreate?: (input: AgentCreationInput) => Promise<void>;
onUpdate?: (input: AgentCreationInput) => Promise<void>;
onArchive?: () => void;
title?: string;
submitLabel?: string;
};
function createInitialInput(modelOptions: CodingModelOption[], agent?: CodingProjectAgent | null): AgentCreationInput {
if (agent) {
return {
name: agent.name,
avatarId: agent.avatarId,
avatarDataUrl: agent.avatarDataUrl,
model: agent.model ? codingModelKey(agent.model) : '',
responsibility: agent.responsibility.mission,
prompt: agent.prompt,
skillIds: [...agent.skillIds],
};
}
return {
name: '',
avatarId: agentAvatarOptions[0]?.id ?? 'avatar-01',
avatarDataUrl: undefined,
model: modelOptions[0]?.key ?? '',
responsibility: '',
prompt: '',
skillIds: [],
};
}
export function AgentCreationDialog({
open,
onOpenChange,
modelOptions,
skills = [],
existingAgentNames = [],
agent = null,
onCreate,
onUpdate,
onArchive,
title,
submitLabel,
}: AgentCreationDialogProps) {
const editing = Boolean(agent);
const [input, setInput] = useState<AgentCreationInput>(() => createInitialInput(modelOptions, agent));
const [submitting, setSubmitting] = useState(false);
const [skillQuery, setSkillQuery] = useState('');
const [avatarPickerOpen, setAvatarPickerOpen] = useState(false);
const [avatarProcessing, setAvatarProcessing] = useState(false);
const avatarUploadInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!open) {
return;
}
setInput(createInitialInput(modelOptions, agent));
setSkillQuery('');
setAvatarPickerOpen(false);
}, [agent, editing, modelOptions, open]);
const handleAvatarFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setAvatarProcessing(true);
try {
const prepared = await prepareAgentAvatar(file);
setInput((current) => ({ ...current, avatarDataUrl: prepared.previewUrl }));
setAvatarPickerOpen(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : '智能体头像图片处理失败');
} finally {
setAvatarProcessing(false);
}
};
const filteredSkills = useMemo(() => {
const query = skillQuery.trim().toLocaleLowerCase();
if (!query) return skills;
return skills.filter((skill) => `${skill.name} ${skill.description}`.toLocaleLowerCase().includes(query));
}, [skillQuery, skills]);
const selectedAvatar = agentAvatarOptions.find((option) => option.id === input.avatarId) ?? agentAvatarOptions[0];
const selectedAvatarSrc = input.avatarDataUrl ?? selectedAvatar?.src;
const handleSubmit = async () => {
const name = input.name.trim();
const responsibility = input.responsibility.trim();
if (!name || !responsibility || !input.model) {
toast.error('请填写智能体名称和职责说明,并配置默认模型。');
return;
}
if (existingAgentNames.some((agentName) => agentName.trim() === name)) {
toast.error('智能体名称需要在当前项目内唯一。');
return;
}
const submit = editing ? onUpdate : onCreate;
if (!submit) return;
setSubmitting(true);
try {
await submit({ ...input, name, responsibility });
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[min(820px,calc(100vh-2rem))] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{title ?? (editing ? '编辑项目智能体' : '创建项目智能体')}</DialogTitle>
<DialogDescription>配置智能体的身份、默认模型、职责、系统指令和插件能力。</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-1">
<div>
<label htmlFor="agent-create-name" className="text-sm font-semibold">智能体名称 <span className="text-destructive">*</span></label>
<div className="mt-2 flex items-center gap-3">
<button type="button" aria-label="更换头像" title="更换头像" aria-haspopup="dialog" onClick={() => setAvatarPickerOpen(true)} className="motion-press h-10 w-10 shrink-0 overflow-hidden rounded-xl border border-brand bg-brand-soft p-0.5 shadow-soft">
<img src={selectedAvatarSrc} alt={input.avatarDataUrl ? '当前上传头像' : selectedAvatar?.label ?? '当前头像'} className="h-full w-full rounded-lg object-cover [image-rendering:pixelated]" />
</button>
<Input id="agent-create-name" value={input.name} maxLength={30} placeholder="例如:前端开发智能体" onChange={(event) => setInput((current) => ({ ...current, name: event.target.value }))} className="mt-0 flex-1" />
</div>
</div>
<div>
<label htmlFor="agent-create-model" className="text-sm font-semibold">默认模型 <span className="text-destructive">*</span></label>
<p className="mt-1 text-xs font-medium text-muted-foreground">新对话默认使用此模型;在对话中临时切换不会修改这里的配置。</p>
<select id="agent-create-model" value={input.model} onChange={(event) => setInput((current) => ({ ...current, model: event.target.value }))} className="mt-2 h-10 w-full rounded-xl border border-border bg-surface-input px-3 text-sm">
<option value="" disabled>请选择已配置模型</option>
{modelOptions.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}
</select>
</div>
<div>
<label htmlFor="agent-create-responsibility" className="text-sm font-semibold">职责说明 <span className="text-destructive">*</span></label>
<Input id="agent-create-responsibility" value={input.responsibility} maxLength={200} placeholder="例如:负责把想法拆成清晰的产品计划。" onChange={(event) => setInput((current) => ({ ...current, responsibility: event.target.value }))} className="mt-2" />
</div>
<section className="space-y-4 rounded-lg border border-border/80 bg-surface-subtle p-4">
<h3 className="font-semibold">高级设置</h3>
<div><label htmlFor="agent-create-system-instructions" className="text-sm font-semibold">系统指令</label><Textarea id="agent-create-system-instructions" value={input.prompt} onChange={(event) => setInput((current) => ({ ...current, prompt: event.target.value }))} className="mt-2 min-h-28 bg-background font-mono text-xs leading-5" placeholder="可选:定义智能体的工作方式、约束和输出要求。" /></div>
<div className="flex items-center justify-between gap-3"><h4 className="text-sm font-semibold">插件能力</h4><Badge className="border border-border/80 bg-background text-foreground">已选 {input.skillIds.length}</Badge></div>
{skills.length > 0 ? <>
<div className="relative"><Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /><Input aria-label="搜索插件能力" value={skillQuery} onChange={(event) => setSkillQuery(event.target.value)} placeholder="搜索插件能力" className="bg-background pl-9" /></div>
<div className="space-y-2">{filteredSkills.map((skill) => { const checked = input.skillIds.includes(skill.id); const unavailable = skill.available === false; return <label key={skill.id} className={cn('flex items-start gap-2 rounded-md border border-border/80 p-2.5', unavailable ? 'cursor-not-allowed opacity-70' : 'cursor-pointer', checked ? 'bg-background' : 'bg-surface-subtle')}><input type="checkbox" aria-label={`绑定插件能力:${skill.name}`} checked={checked} disabled={unavailable} onChange={() => setInput((current) => ({ ...current, skillIds: checked ? current.skillIds.filter((id) => id !== skill.id) : [...current.skillIds, skill.id] }))} /><span><span className="text-sm font-semibold">{skill.name}</span><span className="mt-0.5 block text-xs text-muted-foreground">{skill.description}</span>{unavailable ? <span className="mt-1 block text-xs font-medium text-amber-700">插件未启用;现有分配会保留,但当前不可用。</span> : null}</span></label>; })}{filteredSkills.length === 0 ? <p className="rounded-md border border-dashed border-border/80 p-3 text-center text-xs font-medium text-muted-foreground">没有匹配的插件能力</p> : null}</div>
</> : <p className="mt-3 rounded-md border border-dashed border-border/80 p-3 text-xs font-medium text-muted-foreground">当前暂无可分配能力,请先在插件模块完成添加与启用。</p>}
</section>
</div>
<DialogFooter className={cn(agent && onArchive && 'sm:justify-between')}>
{agent && onArchive ? <Button type="button" variant="outline" aria-label="归档智能体" className="border-border/80 bg-accent-soft" onClick={onArchive} disabled={submitting}><Trash2 className="mr-2 h-4 w-4" />归档智能体</Button> : null}
<div className="flex flex-col-reverse gap-2 sm:ml-auto sm:flex-row">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>取消</Button>
<Button type="button" onClick={() => void handleSubmit()} disabled={submitting || modelOptions.length === 0}>{submitting ? (editing ? '保存中…' : '创建中…') : (submitLabel ?? (editing ? '保存智能体' : '创建并开始对话'))}</Button>
</div>
</DialogFooter>
</DialogContent>
<Dialog open={avatarPickerOpen} onOpenChange={setAvatarPickerOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader><DialogTitle>选择头像</DialogTitle><DialogDescription>点击一个头像替换当前头像。</DialogDescription></DialogHeader>
<input
ref={avatarUploadInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
aria-label="上传智能体头像"
data-testid="agent-avatar-upload-input"
className="sr-only"
onChange={(event) => { void handleAvatarFileChange(event); }}
/>
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-brand/35 bg-brand-soft/40 p-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold">上传本地图片</p>
<p className="mt-1 text-xs font-medium text-muted-foreground">自动裁剪为 256×256,并优先压缩为 WebP,减少项目配置体积。</p>
</div>
<Button type="button" variant="outline" className="shrink-0 border-brand/30 bg-background" onClick={() => avatarUploadInputRef.current?.click()} disabled={avatarProcessing}>
<Upload className="mr-2 h-4 w-4" />{avatarProcessing ? '处理中…' : '从本地上传'}
</Button>
</div>
{input.avatarDataUrl ? <Button type="button" variant="ghost" className="w-full text-xs text-muted-foreground" onClick={() => { setInput((current) => ({ ...current, avatarDataUrl: undefined })); setAvatarPickerOpen(false); }}>恢复内置头像</Button> : null}
<div className="grid grid-cols-8 gap-2">
{agentAvatarOptions.map((option) => (
<button key={option.id} type="button" aria-label={`选择头像:${option.label}`} aria-pressed={!input.avatarDataUrl && input.avatarId === option.id} onClick={() => { setInput((current) => ({ ...current, avatarId: option.id, avatarDataUrl: undefined })); setAvatarPickerOpen(false); }} className={cn('rounded-lg border p-1', !input.avatarDataUrl && input.avatarId === option.id ? 'border-brand bg-brand-soft shadow-soft' : 'border-border bg-background')}>
<img src={option.src} alt="" className="aspect-square w-full rounded object-cover [image-rendering:pixelated]" />
</button>
))}
</div>
</DialogContent>
</Dialog>
</Dialog>
);
}
+8 -21
View File
@@ -1,3 +1,4 @@
import { CodingProjectConversations } from '@/pages/Chat/CodingProjectConversations';
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
@@ -21,7 +22,6 @@ import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { DisclosureContent } from '@/components/ui/disclosure';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import { getAiHardwareOverview, type AiHardwareOverview } from '@/lib/ai-hardware';
import {
AI_HARDWARE_NAVIGATION_EVENT,
@@ -216,7 +216,6 @@ export function Sidebar({
const setActiveProject = useCodingWorkspaceStore((state) => state.setActiveProject);
const removeProject = useCodingWorkspaceStore((state) => state.removeProject);
const createProject = useCodingWorkspaceStore((state) => state.createProject);
const projectConfigs = useProjectConfigStore((state) => state.configsByProjectId);
const loadProjectConfig = useProjectConfigStore((state) => state.load);
const removeProjectConfig = useProjectConfigStore((state) => state.remove);
const authUser = useAuthStore((state) => state.user);
@@ -251,6 +250,7 @@ export function Sidebar({
const accountButtonRef = useRef<HTMLButtonElement | null>(null);
const accountMenuRef = useRef<HTMLDivElement | null>(null);
const tokenPointRequestIdRef = useRef(0);
const projectEntryIntentRef = useRef(0);
const resetCardRequestIdRef = useRef(0);
const navigate = useNavigate();
const location = useLocation();
@@ -523,13 +523,15 @@ export function Sidebar({
}, []);
const enterProject = useCallback(async (project: CodingProjectSummary) => {
const intent = ++projectEntryIntentRef.current;
const result = await loadProjectConfig(project.id);
if (intent !== projectEntryIntentRef.current) return;
if (result.status !== 'valid' || !result.config) {
showProjectEntryError(project, result.status === 'missing' ? '项目缺少必要的配置文件' : result.error ?? '项目配置无效');
return;
}
await setActiveProject(project.id);
navigate('/chat');
if (intent === projectEntryIntentRef.current) navigate('/chat');
}, [loadProjectConfig, navigate, setActiveProject, showProjectEntryError]);
const readAndEnterProject = useCallback(async (project: CodingProjectSummary) => {
@@ -740,12 +742,10 @@ export function Sidebar({
) : null}
{visibleProjects.map((project) => {
const projectConfig = projectConfigs[project.id];
const active = activeProject?.id === project.id;
return (
<button
<div key={project.id}><button
type="button"
key={project.id}
data-testid={`sidebar-course-project-${project.id}`}
aria-label={`进入项目 ${project.name}`}
aria-current={active ? 'page' : undefined}
@@ -758,23 +758,10 @@ export function Sidebar({
)}
>
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{project.name}</span>
<div className="flex shrink-0 -space-x-1.5" aria-label="项目智能体">
{projectConfig?.agents.map((agent) => (
<span
key={agent.id}
title={agent.name.trim() || agent.roleName}
className="h-5 w-5 overflow-hidden rounded-full border border-background bg-white"
>
<img
src={getAgentAvatarSrc(agent.avatarId, agent.avatarDataUrl)}
alt=""
className="h-full w-full object-cover [image-rendering:pixelated]"
/>
</span>
))}
</div>
<ChevronRight aria-hidden="true" className="h-3.5 w-3.5 shrink-0 -translate-x-0.5 text-muted-foreground opacity-50 transition-[opacity,transform,color] duration-200 ease-out group-hover/project:translate-x-0 group-hover/project:text-foreground group-hover/project:opacity-100 group-focus-visible/project:translate-x-0 group-focus-visible/project:text-foreground group-focus-visible/project:opacity-100" />
</button>
{active && <CodingProjectConversations key={project.id} projectId={project.id} />}
</div>
);
})}