feat: update Makelore modules and conversations
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-07-31 10:08:41 +08:00
parent b8ca3f8eea
commit 80e8386fa6
175 changed files with 8036 additions and 10581 deletions

View File

@@ -8,8 +8,6 @@ import {
ExternalLink,
FolderKanban,
LogOut,
PanelLeft,
PanelLeftClose,
Plus,
Settings as SettingsIcon,
UserCircle2,
@@ -17,9 +15,9 @@ import {
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import logoSvg from '@/assets/logo.svg';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import { AgentProfileApiError } from '@/lib/agent-profile';
import { getAiModuleForPath } from '@/lib/ai-modules';
@@ -39,7 +37,6 @@ import { useOpencodeStore, type ProjectDirectoryMode } from '@/stores/opencode';
import { useProviderStore } from '@/stores/providers';
import type { OpencodeProject } from '@/types/opencode';
import { buildConfiguredModelOptions } from '@/lib/model-options';
import { projectTemplates, type ProjectTemplateId } from '../../../shared/project-config';
import { useProjectConfigStore } from '@/stores/project-config';
import { EMPTY_AGENT_USER_PROFILE, getProfileAccountKey, isUserProfileComplete, useUserProfileStore, type UserGender } from '@/stores/user-profile';
import { toast } from 'sonner';
@@ -55,7 +52,7 @@ type TokenUsageState = {
error?: string;
};
type TemplateEntryError = {
type ProjectEntryError = {
project: OpencodeProject;
message: string;
};
@@ -120,67 +117,15 @@ function getSubscriptionMembershipLabel(user: AuthUser | null, state: TokenUsage
return `${levelLabel} 会员`;
}
function getTokenUsageSummary(user: AuthUser | null, state: TokenUsageState): string {
if (!user) return '登录后查看套餐额度';
if (state.status === 'loaded' && state.usage) {
return `5小时剩余 ${formatRemainingPercent(state.usage.five_hour_remaining_percent)} · 本周剩余 ${formatRemainingPercent(state.usage.weekly_remaining_percent)}`;
}
if (state.status === 'loading') return '套餐额度加载中';
if (state.status === 'error') return state.error || '暂时无法获取套餐额度';
return '打开后刷新套餐额度';
}
type SidebarProps = {
sidebarPeekOpen?: boolean;
onSidebarPeekChange?: (open: boolean) => void;
};
function ProjectTemplateChoices({
selectedTemplateId,
disabled,
recommendedTemplateId,
templates = projectTemplates,
onSelect,
}: {
selectedTemplateId: ProjectTemplateId | '';
disabled?: boolean;
recommendedTemplateId?: ProjectTemplateId;
templates?: typeof projectTemplates;
onSelect: (templateId: ProjectTemplateId) => void;
}) {
return (
<div className="grid gap-2">
{templates.map((template) => {
const templateId = template.id;
const selected = selectedTemplateId === templateId;
const recommended = recommendedTemplateId === templateId;
return (
<button
key={templateId}
type="button"
data-testid={`project-template-option-${templateId}`}
aria-pressed={selected}
disabled={disabled}
onClick={() => onSelect(templateId)}
className={cn(
'rounded-lg border-2 px-3 py-2 text-left transition-colors',
selected
? 'border-[#26384D] bg-[#DCE6EF] text-[#26384D] shadow-[3px_3px_0_#26384D]'
: 'border-[#26384D] bg-white text-[#26384D] hover:bg-[#EEF3F7]',
disabled && 'cursor-not-allowed opacity-60',
)}
>
<span className="block text-sm font-black">
{template.title}
{recommended ? '(推荐)' : ''}
</span>
<span className="mt-1 block text-xs font-bold text-[#68788B]">{template.description}</span>
</button>
);
})}
</div>
);
}
export function Sidebar() {
export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: SidebarProps = {}) {
const { t } = useTranslation('common');
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
const sidebarPinnedCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const sidebarCollapsed = sidebarPinnedCollapsed && !sidebarPeekOpen;
const opencodeProjects = useOpencodeStore((state) => state.projects);
const activeProject = useOpencodeStore((state) => state.activeProject);
const loadProjectList = useOpencodeStore((state) => state.loadProjectList);
@@ -217,11 +162,10 @@ export function Sidebar() {
const [newProjectName, setNewProjectName] = useState('');
const [newProjectSelectedPath, setNewProjectSelectedPath] = useState('');
const [newProjectDirectoryMode, setNewProjectDirectoryMode] = useState<ProjectDirectoryMode>('use-selected-directory');
const [newProjectTemplateId, setNewProjectTemplateId] = useState<ProjectTemplateId | ''>('');
const [newProjectDefaultModel, setNewProjectDefaultModel] = useState('');
const [createProjectError, setCreateProjectError] = useState<string | null>(null);
const [creatingProject, setCreatingProject] = useState(false);
const [templateEntryError, setTemplateEntryError] = useState<TemplateEntryError | null>(null);
const [projectEntryError, setProjectEntryError] = useState<ProjectEntryError | null>(null);
const [removeProjectCandidate, setRemoveProjectCandidate] = useState<OpencodeProject | null>(null);
const accountButtonRef = useRef<HTMLButtonElement | null>(null);
const accountMenuRef = useRef<HTMLDivElement | null>(null);
@@ -230,7 +174,6 @@ export function Sidebar() {
const location = useLocation();
const isPaintingModule = getAiModuleForPath(location.pathname) === 'painting';
const projectConfigPath = '/project-config';
const availableProjectTemplates = projectTemplates;
const configuredModelOptions = useMemo(
() => buildConfiguredModelOptions(providerAccounts, providerStatuses, providerDefaultAccountId),
[providerAccounts, providerDefaultAccountId, providerStatuses],
@@ -251,7 +194,6 @@ export function Sidebar() {
const settingsLabel = t('sidebar.settings', '设置');
const memberLabel = getSubscriptionPlanLabel(authUser, tokenUsageState);
const membershipLabel = getSubscriptionMembershipLabel(authUser, tokenUsageState);
const tokenUsageSummary = getTokenUsageSummary(authUser, tokenUsageState);
const tokenUsageExhausted = isTokenUsageExhausted(tokenUsageState);
const fiveHourRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.five_hour_refresh_at);
const weeklyRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.weekly_refresh_at);
@@ -449,7 +391,6 @@ export function Sidebar() {
setNewProjectName('');
setNewProjectSelectedPath('');
setNewProjectDirectoryMode('use-selected-directory');
setNewProjectTemplateId('');
setNewProjectDefaultModel('');
setCreateProjectError(null);
setCreateDialogOpen(true);
@@ -458,7 +399,6 @@ export function Sidebar() {
const closeCreateProject = () => {
if (creatingProject) return;
setCreateDialogOpen(false);
setNewProjectTemplateId('');
setNewProjectDefaultModel('');
setCreateProjectError(null);
};
@@ -475,14 +415,14 @@ export function Sidebar() {
setCreateProjectError(null);
};
const showTemplateEntryError = (project: OpencodeProject, message: string) => {
setTemplateEntryError({ project, message });
const showProjectEntryError = (project: OpencodeProject, message: string) => {
setProjectEntryError({ project, message });
};
const enterProject = async (project: OpencodeProject) => {
const result = await loadProjectConfig(project.id);
if (result.status !== 'valid' || !result.config) {
showTemplateEntryError(project, result.status === 'missing' ? '项目缺少 .niancode/project.json' : result.error ?? '项目配置无效');
showProjectEntryError(project, result.status === 'missing' ? '项目缺少 .niancode/project.json' : result.error ?? '项目配置无效');
return;
}
activateProjectLocally(project);
@@ -493,11 +433,11 @@ export function Sidebar() {
};
const readAndEnterProject = async (project: OpencodeProject) => {
setTemplateEntryError(null);
setProjectEntryError(null);
try {
await enterProject(project);
} catch (error) {
showTemplateEntryError(project, error instanceof Error ? error.message : String(error));
showProjectEntryError(project, error instanceof Error ? error.message : String(error));
}
};
@@ -514,11 +454,6 @@ export function Sidebar() {
return;
}
if (!newProjectTemplateId) {
setCreateProjectError('请选择项目模板');
return;
}
setCreatingProject(true);
setCreateProjectError(null);
try {
@@ -526,11 +461,9 @@ export function Sidebar() {
directoryMode: newProjectDirectoryMode,
selectedPath,
...(newProjectDirectoryMode === 'create-child-directory' ? { projectName } : {}),
templateId: newProjectTemplateId,
...(newProjectDefaultModel ? { defaultModel: newProjectDefaultModel } : {}),
});
setCreateDialogOpen(false);
setNewProjectTemplateId('');
setNewProjectDefaultModel('');
await enterProject(project);
} catch (error) {
@@ -544,26 +477,10 @@ export function Sidebar() {
await readAndEnterProject(project);
};
const openProjectConfig = async (project: OpencodeProject) => {
setTemplateEntryError(null);
try {
const result = await loadProjectConfig(project.id);
if (result.status !== 'valid' || !result.config) {
showTemplateEntryError(project, result.status === 'missing' ? '项目缺少 .niancode/project.json' : result.error ?? '项目配置无效');
return;
}
activateProjectLocally(project);
navigate(projectConfigPath);
void setActiveProject(project.id).catch(() => undefined);
} catch (error) {
showTemplateEntryError(project, error instanceof Error ? error.message : String(error));
}
};
const requestRemoveUnavailableProject = () => {
if (!templateEntryError) return;
setTemplateEntryError(null);
setRemoveProjectCandidate(templateEntryError.project);
if (!projectEntryError) return;
setProjectEntryError(null);
setRemoveProjectCandidate(projectEntryError.project);
};
const handleRemoveUnavailableProject = async () => {
@@ -602,67 +519,49 @@ export function Sidebar() {
return (
<aside
data-testid="sidebar"
aria-hidden={sidebarCollapsed}
onPointerEnter={() => onSidebarPeekChange?.(true)}
onPointerLeave={() => onSidebarPeekChange?.(false)}
className={cn(
'flex min-h-0 shrink-0 flex-col overflow-hidden border-r-4 border-[#26384D] bg-[#F6F8FA] text-[#26384D] transition-all duration-300',
sidebarCollapsed ? 'w-16' : 'w-72',
'flex min-h-0 shrink-0 flex-col overflow-hidden border-r border-border/80 bg-surface-sidebar text-foreground transition-[width,opacity,box-shadow] duration-200 ease-spring motion-reduce:transition-none',
sidebarCollapsed ? 'pointer-events-none w-0 border-r-0 opacity-0' : 'w-64 opacity-100',
sidebarPinnedCollapsed && sidebarPeekOpen && 'absolute inset-y-0 left-0 z-40 shadow-float',
)}
>
<div className={cn('flex h-16 items-center border-b-4 border-[#26384D] bg-[#F6F8FA] p-2 text-[#26384D]', sidebarCollapsed ? 'justify-center' : 'justify-between')}>
{!sidebarCollapsed && (
<div className="flex items-center gap-2 overflow-hidden px-2">
<img
src={logoSvg}
alt="Makelore logo"
className="h-9 w-10 shrink-0 object-contain"
/>
<div className="min-w-0">
<div className="truncate text-[15px] font-semibold leading-5 tracking-[-0.01em]">Makelore 工作台</div>
<div className="truncate text-[11px] font-medium leading-4 text-[#68788B]">一念成光,万物可创。</div>
</div>
</div>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-[#26384D] hover:bg-[#FFFFFF]/40"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
>
{sidebarCollapsed ? (
<PanelLeft className="h-[18px] w-[18px]" />
) : (
<PanelLeftClose className="h-[18px] w-[18px]" />
)}
</Button>
<div className={cn('bg-surface-sidebar px-3 py-2 text-foreground', sidebarCollapsed && 'px-2')}>
<div className="min-w-0 w-full">
<ModuleSwitcher sidebarCollapsed={sidebarCollapsed} compact={!sidebarCollapsed} />
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto p-2">
<div className="min-h-0 flex-1 overflow-auto px-2 py-2">
{isPaintingModule ? (
<ImageWorkspaceSidebar sidebarCollapsed={sidebarCollapsed} />
) : (
<>
<Button
<button
type="button"
data-testid="sidebar-create-project"
className={cn(
'mb-2 w-full border-2 border-[#26384D] bg-[#E9EFF5] text-[#26384D] shadow-[3px_3px_0_#26384D] hover:bg-[#DCE6EF]',
sidebarCollapsed ? 'px-0' : '',
'motion-press mb-1 flex min-h-9 w-full items-center gap-2 rounded-lg border-0 bg-transparent px-2 py-1.5 text-left text-sm font-medium text-foreground shadow-none transition-colors hover:bg-surface-subtle active:scale-[0.985]',
sidebarCollapsed ? 'justify-center px-0' : '',
)}
aria-label="新建项目"
onClick={openCreateProject}
>
<Plus className={cn('h-4 w-4', sidebarCollapsed ? '' : 'mr-2')} />
<Plus className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed && '新建项目'}
</Button>
<div className="mt-4 border-t-2 border-[#DCE6EF] pt-4">
</button>
<div className="mt-3">
<button
type="button"
onClick={() => setProjectsOpen(!projectsOpen)}
className={cn(
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm font-black transition-colors hover:bg-[#FFFFFF] hover:text-[#26384D]',
'motion-press flex min-h-8 w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs font-semibold text-muted-foreground hover:bg-surface-subtle hover:text-foreground',
sidebarCollapsed ? 'justify-center px-0' : '',
)}
>
<FolderKanban className="h-[18px] w-[18px] shrink-0" />
<FolderKanban className="h-4 w-4 shrink-0" />
{!sidebarCollapsed && (
<>
<span className="min-w-0 flex-1 truncate">项目列表</span>
@@ -672,10 +571,10 @@ export function Sidebar() {
</button>
{projectsOpen && !sidebarCollapsed ? (
<div className="mt-2 space-y-2">
<div className="mt-1 space-y-0.5">
{visibleProjects.length === 0 ? (
<div className="rounded-lg border-2 border-dashed border-[#DCE6EF] bg-white/70 p-3 text-xs font-bold text-[#68788B]">
暂无真实项目。添加本地项目后,请先选择项目模板。
<div className="px-2 py-2 text-xs text-muted-foreground">
暂无真实项目。添加本地项目后即可开始配置。
</div>
) : null}
@@ -687,20 +586,20 @@ export function Sidebar() {
key={project.id}
data-testid={`sidebar-course-project-${project.id}`}
className={cn(
'w-full rounded-lg border-2 p-3 text-left transition-[background-color,border-color,box-shadow,color]',
'motion-press w-full rounded-lg px-2 py-1.5 text-left transition-[background-color,color]',
active
? 'border-[#26384D] bg-[#DCE6EF] text-[#26384D] shadow-[4px_4px_0_#26384D]'
: 'border-[#DCE6EF] bg-[#FFFFFF] text-[#26384D]',
? 'bg-brand-soft text-foreground'
: 'bg-transparent text-foreground hover:bg-surface-subtle',
)}
>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-black">{project.name}</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{project.name}</span>
<div className="flex shrink-0 -space-x-1.5" aria-label="项目 Agent">
{projectConfig?.agents.map((agent) => (
<span
key={agent.id}
title={agent.name.trim() || agent.roleName}
className="h-6 w-6 overflow-hidden rounded-full border-2 border-[#26384D] bg-white"
className="h-5 w-5 overflow-hidden rounded-full border border-border bg-white"
>
<img
src={getAgentAvatarSrc(agent.avatarId)}
@@ -711,29 +610,15 @@ export function Sidebar() {
))}
</div>
</div>
<p className="mt-1 truncate text-[11px] font-bold opacity-80">
{projectConfig
? projectTemplates.find((item) => item.id === projectConfig.templateId)?.title ?? projectConfig.templateId
: '读取项目配置'}
</p>
<div className="mt-3 flex items-center gap-2">
<div className="mt-1 flex items-center gap-1">
<button
type="button"
aria-label={`进入项目 ${project.name}`}
onClick={() => void openProject(project)}
className="flex min-w-0 flex-1 items-center justify-center rounded-md border-2 border-[#26384D] bg-white px-3 py-1.5 text-xs font-black transition-colors hover:bg-[#EEF3F7]"
className="motion-press flex min-w-0 flex-1 items-center justify-center rounded-md bg-transparent px-1.5 py-1 text-left text-xs font-medium text-muted-foreground hover:bg-background hover:text-foreground"
>
进入项目
</button>
<button
type="button"
aria-label={`配置项目 ${project.name}`}
title="项目配置"
onClick={() => void openProjectConfig(project)}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border-2 border-[#26384D] bg-white transition-colors hover:bg-[#FDE8E1]"
>
<SettingsIcon className="h-4 w-4" />
</button>
</div>
</div>
);
@@ -747,27 +632,32 @@ export function Sidebar() {
</div>
{!isPaintingModule && createDialogOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 px-4">
<div
role="dialog"
aria-modal="true"
aria-labelledby="create-project-dialog-title"
className="w-full max-w-md rounded-lg border-4 border-[#26384D] bg-[#FFFFFF] p-4 text-[#26384D] shadow-[8px_8px_0_#26384D]"
<Dialog
open={createDialogOpen}
onOpenChange={(open) => {
if (!open) closeCreateProject();
}}
>
<DialogContent
className="max-h-[calc(100vh-2rem)] max-w-xl overflow-y-auto p-5"
onInteractOutside={(event) => {
if (creatingProject) event.preventDefault();
}}
>
<div className="flex items-start justify-between gap-3">
<DialogHeader className="flex-row items-start justify-between gap-3 space-y-0">
<div>
<h2 id="create-project-dialog-title" className="text-xl font-black">新建项目</h2>
<p className="mt-1 text-xs font-bold text-[#68788B]">默认直接使用所选文件夹,也可以在所选位置新建下级文件夹。</p>
<DialogTitle className="text-xl">新建项目</DialogTitle>
<DialogDescription className="mt-1 text-xs">默认直接使用所选文件夹,也可以在所选位置新建下级文件夹。</DialogDescription>
</div>
<button
type="button"
className="rounded-md border-2 border-[#26384D] px-2 py-1 text-xs font-black hover:bg-white"
className="rounded-md border border-foreground/15 px-2 py-1 text-xs font-semibold hover:bg-white"
onClick={closeCreateProject}
disabled={creatingProject}
>
取消
</button>
</div>
</DialogHeader>
<form
className="mt-4 space-y-3"
@@ -777,12 +667,12 @@ export function Sidebar() {
}}
>
<fieldset className="space-y-2">
<legend className="text-xs font-black">项目目录方式</legend>
<legend className="text-xs font-semibold">项目目录方式</legend>
<div className="grid gap-2 sm:grid-cols-2">
<label
className={cn(
'cursor-pointer rounded-md border-2 border-[#26384D] px-3 py-2 text-xs font-black',
newProjectDirectoryMode === 'use-selected-directory' ? 'bg-[#DCE6EF]' : 'bg-white',
'cursor-pointer rounded-md border border-foreground/15 px-3 py-2 text-xs font-semibold',
newProjectDirectoryMode === 'use-selected-directory' ? 'bg-brand-soft' : 'bg-white',
)}
>
<input
@@ -801,8 +691,8 @@ export function Sidebar() {
</label>
<label
className={cn(
'cursor-pointer rounded-md border-2 border-[#26384D] px-3 py-2 text-xs font-black',
newProjectDirectoryMode === 'create-child-directory' ? 'bg-[#DCE6EF]' : 'bg-white',
'cursor-pointer rounded-md border border-foreground/15 px-3 py-2 text-xs font-semibold',
newProjectDirectoryMode === 'create-child-directory' ? 'bg-brand-soft' : 'bg-white',
)}
>
<input
@@ -824,7 +714,7 @@ export function Sidebar() {
{newProjectDirectoryMode === 'create-child-directory' ? (
<div className="space-y-2">
<label className="block text-xs font-black" htmlFor="new-project-name">
<label className="block text-xs font-semibold" htmlFor="new-project-name">
项目名称
</label>
<input
@@ -834,14 +724,14 @@ export function Sidebar() {
setNewProjectName(event.target.value);
setCreateProjectError(null);
}}
className="w-full rounded-md border-2 border-[#26384D] bg-white px-3 py-2 text-sm font-bold outline-none focus:ring-2 focus:ring-[#DCE6EF]"
className="w-full rounded-md border border-foreground/15 bg-white px-3 py-2 text-sm font-medium outline-none focus:ring-2 focus:ring-brand/20"
autoFocus
disabled={creatingProject}
/>
</div>
) : null}
<label className="block text-xs font-black" htmlFor="new-project-path">
<label className="block text-xs font-semibold" htmlFor="new-project-path">
项目路径
</label>
<div className="flex gap-2">
@@ -849,19 +739,19 @@ export function Sidebar() {
id="new-project-path"
value={newProjectSelectedPath}
readOnly
className="min-w-0 flex-1 rounded-md border-2 border-[#26384D] bg-white px-3 py-2 text-sm font-bold outline-none"
className="min-w-0 flex-1 rounded-md border border-foreground/15 bg-white px-3 py-2 text-sm font-medium outline-none"
/>
<Button
type="button"
variant="outline"
className="shrink-0 border-2 border-[#26384D] bg-white text-[#26384D]"
className="shrink-0 border border-foreground/15 bg-white text-foreground"
onClick={() => void chooseProjectPath()}
disabled={creatingProject}
>
选择路径
</Button>
</div>
<p className="text-[11px] font-bold text-[#68788B]">
<p className="text-[11px] font-medium text-muted-foreground">
{newProjectDirectoryMode === 'use-selected-directory'
? selectedProjectFolderName
? `将直接接入此文件夹,项目名称使用“${selectedProjectFolderName}”,不会重命名目录。`
@@ -870,27 +760,14 @@ export function Sidebar() {
</p>
<div className="space-y-2">
<div className="text-xs font-black">项目模板</div>
<ProjectTemplateChoices
templates={availableProjectTemplates}
selectedTemplateId={newProjectTemplateId}
disabled={creatingProject}
onSelect={(templateId) => {
setNewProjectTemplateId(templateId);
setCreateProjectError(null);
}}
/>
</div>
<div className="space-y-2">
<label className="block text-xs font-black" htmlFor="new-project-model">项目默认模型</label>
<label className="block text-xs font-semibold" htmlFor="new-project-model">项目默认模型</label>
<select
id="new-project-model"
aria-label="项目默认模型"
value={newProjectDefaultModel}
onChange={(event) => setNewProjectDefaultModel(event.target.value)}
disabled={creatingProject}
className="w-full rounded-md border-2 border-[#26384D] bg-white px-3 py-2 text-sm font-bold outline-none focus:ring-2 focus:ring-[#DCE6EF]"
className="w-full rounded-md border border-foreground/15 bg-white px-3 py-2 text-sm font-medium outline-none focus:ring-2 focus:ring-brand/20"
>
<option value="">使用运行时默认模型</option>
{configuredModelOptions.map((option) => (
@@ -899,7 +776,7 @@ export function Sidebar() {
</option>
))}
</select>
<p className="text-[11px] font-bold text-[#68788B]">
<p className="text-[11px] font-medium text-muted-foreground">
{configuredModelOptions.length > 0
? '模型由服务端配置提供,项目只保存选择,不保存 Key。'
: '暂时没有可选模型,创建后会使用运行时默认模型。'}
@@ -907,7 +784,7 @@ export function Sidebar() {
</div>
{createProjectError ? (
<div className="rounded-md border-2 border-[#26384D] bg-[#FCE9E5] px-3 py-2 text-xs font-black">
<div className="rounded-md border border-foreground/15 bg-accent-soft px-3 py-2 text-xs font-semibold">
{createProjectError}
</div>
) : null}
@@ -916,7 +793,7 @@ export function Sidebar() {
<Button
type="button"
variant="outline"
className="border-2 border-[#26384D] bg-white text-[#26384D]"
className="border border-foreground/15 bg-white text-foreground"
onClick={closeCreateProject}
disabled={creatingProject}
>
@@ -924,47 +801,46 @@ export function Sidebar() {
</Button>
<Button
type="submit"
className="border-2 border-[#26384D] bg-[#DCE6EF] text-[#26384D]"
className="border border-foreground/15 bg-brand-soft text-foreground"
disabled={creatingProject
|| !newProjectSelectedPath.trim()
|| !newProjectTemplateId
|| (newProjectDirectoryMode === 'create-child-directory' && !newProjectName.trim())}
>
确认创建
</Button>
</div>
</form>
</div>
</div>
</DialogContent>
</Dialog>
) : null}
{!isPaintingModule && templateEntryError ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 px-4">
{!isPaintingModule && projectEntryError ? (
<div className="glass-surface fixed inset-0 z-50 flex items-center justify-center bg-foreground/15 px-4 backdrop-blur-[3px]">
<div
role="alertdialog"
data-testid="project-template-error"
className="w-full max-w-md rounded-lg border-4 border-[#26384D] bg-[#FFFFFF] p-4 text-[#26384D] shadow-[8px_8px_0_#26384D]"
data-testid="project-config-error"
className="glass-surface w-full max-w-md rounded-2xl border border-border/80 bg-background p-5 text-foreground shadow-float"
>
<h2 className="text-xl font-black">项目配置不可用</h2>
<p className="mt-2 text-sm font-bold leading-6 text-[#68788B]">
<h2 className="text-xl font-semibold">项目配置不可用</h2>
<p className="mt-2 text-sm font-medium leading-6 text-muted-foreground">
无法进入项目,请检查 .niancode/project.json。
</p>
<pre className="mt-3 max-h-32 overflow-auto rounded-md border-2 border-[#26384D] bg-white p-2 text-xs font-bold">
{templateEntryError.message}
<pre className="mt-3 max-h-32 overflow-auto rounded-md border border-foreground/15 bg-white p-2 text-xs font-medium">
{projectEntryError.message}
</pre>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="outline"
className="border-2 border-[#26384D] bg-[#FCE9E5] text-[#26384D]"
className="border border-foreground/15 bg-accent-soft text-foreground"
onClick={requestRemoveUnavailableProject}
>
移除项目卡片
</Button>
<Button
type="button"
className="border-2 border-[#26384D] bg-[#DCE6EF] text-[#26384D]"
onClick={() => setTemplateEntryError(null)}
className="border border-foreground/15 bg-brand-soft text-foreground"
onClick={() => setProjectEntryError(null)}
>
知道了
</Button>
@@ -988,176 +864,159 @@ export function Sidebar() {
) : null}
{profileDialogOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 px-4">
<div
role="dialog"
aria-modal="true"
aria-labelledby="sidebar-profile-dialog-title"
className="w-full max-w-sm rounded-lg border-4 border-[#26384D] bg-[#FFFFFF] p-4 text-[#26384D] shadow-[8px_8px_0_#26384D]"
<Dialog
open={profileDialogOpen}
onOpenChange={(open) => {
if (!open && !profileRequired && !profileSaving) setProfileDialogOpen(false);
}}
>
<DialogContent
className="max-w-sm p-5"
onInteractOutside={(event) => {
if (profileRequired || profileSaving) event.preventDefault();
}}
>
<div className="flex items-start justify-between gap-3">
<DialogHeader className="flex-row items-start justify-between gap-3 space-y-0">
<div>
<h2 id="sidebar-profile-dialog-title" className="text-xl font-black">个人资料</h2>
<p className="mt-1 text-xs font-bold text-[#68788B]">帮助所有 Agent 更自然地了解和协作</p>
<DialogTitle className="text-xl">个人资料</DialogTitle>
<DialogDescription className="mt-1 text-xs">帮助所有 Agent 更自然地了解和协作</DialogDescription>
</div>
{!profileRequired ? <button
type="button"
aria-label="关闭个人资料"
className="flex h-9 w-9 items-center justify-center rounded-md border-2 border-[#26384D] bg-white text-[#26384D] transition-transform active:scale-[0.96]"
className="flex h-9 w-9 items-center justify-center rounded-md border border-foreground/15 bg-white text-foreground transition-transform active:scale-[0.985]"
onClick={() => setProfileDialogOpen(false)}
>
<X className="h-4 w-4" />
</button> : null}
</div>
</DialogHeader>
{profileRequired ? <div className="mt-4 rounded-lg border-2 border-[#26384D] bg-[#E9EFF5] p-3 text-sm font-black">首次使用前请先填写名字。个人资料与登录账号信息相互独立。</div> : null}
{profileRequired ? <div className="mt-4 rounded-lg border border-foreground/15 bg-surface-tertiary p-3 text-sm font-semibold">首次使用前请先填写名字。个人资料与登录账号信息相互独立。</div> : null}
<div className="mt-4 grid gap-4 text-sm font-bold">
<div><Label htmlFor="profile-display-name">名字 <span className="text-red-700">*</span></Label><Input id="profile-display-name" value={profileName} maxLength={100} onChange={(event) => setProfileName(event.target.value)} placeholder="希望 Agent 如何称呼你" className="mt-2 border-2 border-[#26384D] bg-white" /></div>
<div><Label htmlFor="profile-age">年龄(选填)</Label><Input id="profile-age" type="number" min={1} max={150} value={profileAge} onChange={(event) => setProfileAge(event.target.value)} className="mt-2 border-2 border-[#26384D] bg-white" /></div>
<div><Label htmlFor="profile-gender">性别(选填)</Label><select id="profile-gender" value={profileGender} onChange={(event) => setProfileGender(event.target.value as UserGender)} className="mt-2 h-10 w-full rounded-md border-2 border-[#26384D] bg-white px-3"><option value="undisclosed">不透露</option><option value="male">男</option><option value="female">女</option><option value="other">其他</option></select></div>
{profileError ? <p role="alert" className="text-sm font-black text-red-700">{profileError}</p> : null}
{profileSyncState?.status === 'loading' ? <p className="text-xs font-bold text-[#68788B]">正在同步云端个人资料…</p> : null}
<Button type="button" onClick={() => void handleSaveProfile()} disabled={profileSaving || profileSyncState?.status === 'loading'} className="border-2 border-[#26384D] bg-[#DCE6EF] font-black text-[#26384D] shadow-[3px_3px_0_#26384D]">{profileSaving ? '正在同步…' : '保存个人资料'}</Button>
<div className="mt-4 grid gap-4 text-sm font-medium">
<div><Label htmlFor="profile-display-name">名字 <span className="text-red-700">*</span></Label><Input id="profile-display-name" value={profileName} maxLength={100} onChange={(event) => setProfileName(event.target.value)} placeholder="希望 Agent 如何称呼你" className="mt-2 border border-foreground/15 bg-white" /></div>
<div><Label htmlFor="profile-age">年龄(选填)</Label><Input id="profile-age" type="number" min={1} max={150} value={profileAge} onChange={(event) => setProfileAge(event.target.value)} className="mt-2 border border-foreground/15 bg-white" /></div>
<div><Label htmlFor="profile-gender">性别(选填)</Label><select id="profile-gender" value={profileGender} onChange={(event) => setProfileGender(event.target.value as UserGender)} className="mt-2 h-10 w-full rounded-md border border-foreground/15 bg-white px-3"><option value="undisclosed">不透露</option><option value="male">男</option><option value="female">女</option><option value="other">其他</option></select></div>
{profileError ? <p role="alert" className="text-sm font-semibold text-red-700">{profileError}</p> : null}
{profileSyncState?.status === 'loading' ? <p className="text-xs font-medium text-muted-foreground">正在同步云端个人资料…</p> : null}
<Button type="button" onClick={() => void handleSaveProfile()} disabled={profileSaving || profileSyncState?.status === 'loading'} className="border border-foreground/15 bg-brand-soft font-semibold text-foreground shadow-soft">{profileSaving ? '正在同步…' : '保存个人资料'}</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
) : null}
<div className="mt-auto border-t-4 border-[#26384D] p-2">
<div className="mb-2 border-b border-[#26384D]/15 pb-2">
<ModuleSwitcher sidebarCollapsed={sidebarCollapsed} />
</div>
<div className={cn(
'flex items-center gap-2',
sidebarCollapsed && 'flex-col-reverse',
)}>
<button
ref={accountButtonRef}
type="button"
data-testid="sidebar-member-menu-trigger"
aria-label={`打开会员菜单:${accountName}`}
aria-haspopup="menu"
aria-expanded={accountMenuOpen}
onClick={() => setAccountMenuOpen((open) => !open)}
className={cn(
'flex min-h-[52px] min-w-0 flex-1 items-center gap-2.5 rounded-lg border-2 border-[#26384D] bg-[#FFFFFF] px-2.5 py-2 text-left text-sm font-black text-[#26384D] shadow-[3px_3px_0_#26384D] transition-[background-color,color,transform] hover:bg-[#EEF3F7] active:scale-[0.96]',
accountMenuOpen && 'bg-[#FFFFFF] text-[#26384D]',
sidebarCollapsed && 'min-h-11 w-full justify-center px-0',
)}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border-2 border-[#26384D] bg-[#FDE8E1] text-xs font-black text-[#26384D]">
{accountInitial}
</span>
{!sidebarCollapsed && (
<>
<span className="min-w-0 flex-1">
<span className="block truncate">{accountName}</span>
<span className="mt-0.5 flex items-center gap-1 text-[11px] font-bold opacity-80">
<Crown className="h-3 w-3" />
{memberLabel}
<div className="mt-auto px-2 pb-2 pt-1">
<div className="relative">
<div className={cn(
'flex items-center gap-2',
sidebarCollapsed && 'flex-col-reverse',
)}>
<button
ref={accountButtonRef}
type="button"
data-testid="sidebar-member-menu-trigger"
aria-label={`打开会员菜单:${accountName}`}
aria-haspopup="menu"
aria-expanded={accountMenuOpen}
onClick={() => setAccountMenuOpen((open) => !open)}
className={cn(
'motion-press flex min-h-9 min-w-0 flex-1 items-center gap-2 rounded-lg border-0 bg-transparent px-2 py-1 text-left text-sm font-medium text-foreground shadow-none transition-[background-color,color,transform] hover:bg-surface-subtle',
accountMenuOpen && 'bg-background text-foreground',
sidebarCollapsed && 'min-h-9 w-full justify-center px-0',
)}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-accent-soft text-xs font-semibold text-foreground">
{accountInitial}
</span>
{!sidebarCollapsed && (
<>
<span className="min-w-0 flex-1">
<span className="block truncate">{accountName}</span>
</span>
</span>
<ChevronRight className={cn('h-4 w-4 shrink-0 transition-transform', accountMenuOpen && 'rotate-90')} />
</>
)}
</button>
<SidebarUpdateButton collapsed={sidebarCollapsed} />
</div>
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0 transition-transform', accountMenuOpen && 'rotate-90')} />
</>
)}
</button>
<SidebarUpdateButton collapsed={sidebarCollapsed} />
</div>
{accountMenuOpen ? (
<div
ref={accountMenuRef}
role="menu"
data-testid="sidebar-account-menu"
className={cn(
'fixed bottom-[72px] z-50 w-64 rounded-lg border-2 border-[#26384D] bg-[#FFFFFF] p-2 text-[#26384D] shadow-[6px_6px_0_#26384D]',
sidebarCollapsed ? 'left-2' : 'left-3',
)}
>
<div className="border-b border-[#26384D]/15 px-2 py-2">
{accountMenuOpen ? (
<div
ref={accountMenuRef}
role="menu"
data-testid="sidebar-account-menu"
className={cn(
'glass-surface z-50 max-h-[calc(100vh-7rem)] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-2xl border border-border/80 p-1 text-foreground shadow-float',
sidebarCollapsed
? 'fixed bottom-[72px] left-2 w-64'
: 'absolute bottom-[calc(100%+0.5rem)] left-0 w-full',
)}
>
<div className="px-1 py-1">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border-2 border-[#26384D] bg-[#FDE8E1] text-xs font-black text-[#26384D]">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-accent-soft text-xs font-semibold text-foreground">
{accountInitial}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-black">{accountFullName}</p>
<p className="mt-0.5 text-[11px] font-bold text-[#68788B]">{membershipLabel}</p>
<p className="truncate text-sm font-semibold">{accountFullName}</p>
<p className="mt-0.5 text-[10px] font-medium text-muted-foreground">{membershipLabel}</p>
</div>
</div>
</div>
<div className="mt-2 grid gap-1">
<div className="mt-1 grid gap-0.5">
<button
type="button"
role="menuitem"
data-testid="sidebar-account-usage-menuitem"
aria-expanded={usageDrawerOpen}
aria-controls="sidebar-account-usage-drawer"
className="flex min-h-10 w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm font-bold transition-colors hover:bg-[#EEF3F7] active:scale-[0.96]"
className="flex min-h-8 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium transition-colors hover:bg-surface-subtle active:scale-[0.985]"
onClick={() => setUsageDrawerOpen((open) => !open)}
>
<BarChart3 className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block truncate">剩余用量</span>
<span
data-testid="sidebar-account-usage-summary"
className="mt-0.5 block truncate text-[11px] text-[#68788B]"
>
{tokenUsageSummary}
</span>
</span>
<ChevronRight className={cn('h-4 w-4 shrink-0 text-[#68788B] transition-transform', usageDrawerOpen && 'rotate-90')} />
<BarChart3 className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">剩余用量</span>
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform', usageDrawerOpen && 'rotate-90')} />
</button>
{usageDrawerOpen ? (
<div
id="sidebar-account-usage-drawer"
data-testid="sidebar-account-usage-drawer"
className="ml-6 rounded-md border border-[#26384D]/20 bg-white px-2 py-2 text-xs font-bold text-[#68788B]"
className="ml-5 grid gap-0.5 rounded-md bg-surface-subtle/70 px-1 py-0.5 text-xs font-medium text-muted-foreground"
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
<span>订阅等级</span>
<span className="text-[#26384D]">{memberLabel}</span>
<span className="text-foreground">{memberLabel}</span>
</div>
<div className="mt-1.5">
<div className="flex items-center justify-between gap-3">
<span>5 小时</span>
<span className="text-[#26384D]">
{formatRemainingPercent(tokenUsageState.usage?.five_hour_remaining_percent)}
</span>
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
<span>5 小时</span>
<span className="text-foreground">
{formatRemainingPercent(tokenUsageState.usage?.five_hour_remaining_percent)}
</span>
</div>
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
<span>1 周</span>
<span className="text-foreground">
{formatRemainingPercent(tokenUsageState.usage?.weekly_remaining_percent)}
</span>
</div>
{fiveHourRefreshLabel ? (
<div data-testid="sidebar-five-hour-refresh-at" className="px-1 text-[10px] font-semibold text-muted-foreground">
{fiveHourRefreshLabel}
</div>
{fiveHourRefreshLabel ? (
<div
data-testid="sidebar-five-hour-refresh-at"
className="mt-0.5 text-[11px] font-semibold text-[#68788B]"
>
{fiveHourRefreshLabel}
</div>
) : null}
</div>
<div className="mt-1.5">
<div className="flex items-center justify-between gap-3">
<span>1 周</span>
<span className="text-[#26384D]">
{formatRemainingPercent(tokenUsageState.usage?.weekly_remaining_percent)}
</span>
) : null}
{weeklyRefreshLabel ? (
<div data-testid="sidebar-weekly-refresh-at" className="px-1 text-[10px] font-semibold text-muted-foreground">
{weeklyRefreshLabel}
</div>
{weeklyRefreshLabel ? (
<div
data-testid="sidebar-weekly-refresh-at"
className="mt-0.5 text-[11px] font-semibold text-[#68788B]"
>
{weeklyRefreshLabel}
</div>
) : null}
</div>
) : null}
{tokenUsageExhausted ? (
<div className="mt-2 rounded-md border border-[#F26A3D] bg-[#F6F8FA] p-2 text-[#26384D]">
<p className="leading-4 text-[#68788B]">当前额度已用尽,升级订阅后可继续使用。</p>
<div className="mt-1 rounded-md border border-accent/50 bg-background p-1.5 text-foreground">
<p className="text-[11px] leading-4 text-muted-foreground">当前额度已用尽,升级订阅后可继续使用。</p>
<button
type="button"
data-testid="sidebar-account-upgrade-button"
className="mt-2 flex min-h-8 w-full items-center justify-center gap-1.5 rounded-md border-2 border-[#26384D] bg-[#FDE8E1] px-2 py-1.5 text-xs font-black text-[#26384D] shadow-[2px_2px_0_#26384D] transition-transform hover:bg-[#FFFFFF] active:scale-[0.96]"
className="mt-1.5 flex min-h-8 w-full items-center justify-center gap-1.5 rounded-md border border-foreground/15 bg-accent-soft px-2 py-1.5 text-xs font-semibold text-foreground shadow-soft transition-transform hover:bg-background active:scale-[0.985]"
onClick={openSubscriptionUpgrade}
>
<Crown className="h-3.5 w-3.5" />
@@ -1171,37 +1030,38 @@ export function Sidebar() {
<button
type="button"
role="menuitem"
className="flex min-h-10 w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm font-bold transition-colors hover:bg-[#EEF3F7] active:scale-[0.96]"
className="flex min-h-8 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium transition-colors hover:bg-surface-subtle active:scale-[0.985]"
onClick={openProfileDialog}
>
<UserCircle2 className="h-4 w-4 shrink-0" />
<UserCircle2 className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">个人资料</span>
</button>
<button
type="button"
role="menuitem"
data-testid="sidebar-nav-settings"
className="flex min-h-10 w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm font-bold transition-colors hover:bg-[#EEF3F7] active:scale-[0.96]"
className="flex min-h-8 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium transition-colors hover:bg-surface-subtle active:scale-[0.985]"
onClick={() => navigateFromAccountMenu('/settings')}
>
<SettingsIcon className="h-4 w-4 shrink-0" />
<SettingsIcon className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{settingsLabel}</span>
</button>
</div>
<div className="mt-2 border-t border-[#26384D]/15 pt-2">
<div className="mt-1 pt-1">
<button
type="button"
role="menuitem"
className="flex min-h-10 w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm font-bold text-[#FCE9E5] transition-colors hover:bg-[#FCE9E5] hover:text-[#26384D] active:scale-[0.96]"
className="flex min-h-8 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium text-accent-strong transition-colors hover:bg-accent-soft hover:text-foreground active:scale-[0.985]"
onClick={() => void handleAccountLogout()}
>
<LogOut className="h-4 w-4 shrink-0" />
<LogOut className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">退出登录</span>
</button>
</div>
</div>
) : null}
</div>
) : null}
</div>
</div>
</aside>
);