收口 Makelore 客户端变更
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 committed 2026-08-12 22:43:09 +08:00
1 parent 253bad8b40
commit f4113a872f
232 files changed
+4617 -20121

No files matched your search

+112 -135
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import {
@@ -16,6 +17,7 @@ import {
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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
@@ -28,6 +30,7 @@ import { fetchWorksTokenUsage, type WorksTokenUsage } from '@/lib/works-square';
import { isWorksTokenUsageExhausted } from '@/lib/works-square-token-usage';
import { openWorksSquareSubscriptionUpgrade } from '@/lib/subscription-upgrade';
import { cn } from '@/lib/utils';
import type { SidebarPeekSource } from './sidebar-peek';
import { ModuleSwitcher } from './ModuleSwitcher';
import { ImageWorkspaceSidebar } from './ImageWorkspaceSidebar';
import { SidebarUpdateButton } from './SidebarUpdateButton';
@@ -36,7 +39,6 @@ import { useAuthStore, type AuthUser } from '@/stores/auth';
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 { useProjectConfigStore } from '@/stores/project-config';
import { EMPTY_AGENT_USER_PROFILE, getProfileAccountKey, isUserProfileComplete, useUserProfileStore, type UserGender } from '@/stores/user-profile';
import type { ProjectType } from '../../../shared/project-config';
@@ -119,11 +121,12 @@ function getSubscriptionMembershipLabel(user: AuthUser | null, state: TokenUsage
}
type SidebarProps = {
workspaceLayout?: boolean;
sidebarPeekOpen?: boolean;
onSidebarPeekChange?: (open: boolean) => void;
onSidebarPeekChange?: (open: boolean, source: SidebarPeekSource) => void;
};
export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: SidebarProps = {}) {
export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSidebarPeekChange }: SidebarProps = {}) {
const { t } = useTranslation('common');
const sidebarPinnedCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const sidebarCollapsed = sidebarPinnedCollapsed && !sidebarPeekOpen;
@@ -145,9 +148,6 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
const saveUserProfile = useUserProfileStore((state) => state.saveProfile);
const syncUserProfile = useUserProfileStore((state) => state.syncProfile);
const saveUserProfileToServer = useUserProfileStore((state) => state.saveProfileToServer);
const providerAccounts = useProviderStore((state) => state.accounts);
const providerStatuses = useProviderStore((state) => state.statuses);
const providerDefaultAccountId = useProviderStore((state) => state.defaultAccountId);
const refreshProviderSnapshot = useProviderStore((state) => state.refreshProviderSnapshot);
const [projectsOpen, setProjectsOpen] = useState(true);
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
@@ -164,7 +164,6 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
const [newProjectSelectedPath, setNewProjectSelectedPath] = useState('');
const [newProjectDirectoryMode, setNewProjectDirectoryMode] = useState<ProjectDirectoryMode>('use-selected-directory');
const [newProjectType, setNewProjectType] = useState<ProjectType>('mini_game');
const [newProjectDefaultModel, setNewProjectDefaultModel] = useState('');
const [createProjectError, setCreateProjectError] = useState<string | null>(null);
const [creatingProject, setCreatingProject] = useState(false);
const [projectEntryError, setProjectEntryError] = useState<ProjectEntryError | null>(null);
@@ -176,10 +175,6 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
const location = useLocation();
const isPaintingModule = getAiModuleForPath(location.pathname) === 'painting';
const projectConfigPath = '/project-config';
const configuredModelOptions = useMemo(
() => buildConfiguredModelOptions(providerAccounts, providerStatuses, providerDefaultAccountId),
[providerAccounts, providerDefaultAccountId, providerStatuses],
);
const visibleProjects = opencodeProjects;
const selectedProjectFolderName = getFolderName(newProjectSelectedPath);
const accountName = getAuthUserDisplayName(authUser) || '未登录用户';
@@ -394,7 +389,6 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
setNewProjectSelectedPath('');
setNewProjectDirectoryMode('use-selected-directory');
setNewProjectType('mini_game');
setNewProjectDefaultModel('');
setCreateProjectError(null);
setCreateDialogOpen(true);
};
@@ -403,7 +397,6 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
if (creatingProject) return;
setCreateDialogOpen(false);
setNewProjectType('mini_game');
setNewProjectDefaultModel('');
setCreateProjectError(null);
};
@@ -466,10 +459,8 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
selectedPath,
projectType: newProjectType,
...(newProjectDirectoryMode === 'create-child-directory' ? { projectName } : {}),
...(newProjectDefaultModel ? { defaultModel: newProjectDefaultModel } : {}),
});
setCreateDialogOpen(false);
setNewProjectDefaultModel('');
await enterProject(project);
} catch (error) {
setCreateProjectError(error instanceof Error ? error.message : String(error));
@@ -521,19 +512,29 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
navigate('/login');
};
const sidebarPeekPreview = sidebarPinnedCollapsed && sidebarPeekOpen;
return (
<aside
data-testid="sidebar"
aria-hidden={sidebarCollapsed}
onPointerEnter={() => onSidebarPeekChange?.(true)}
onPointerLeave={() => onSidebarPeekChange?.(false)}
onPointerEnter={() => onSidebarPeekChange?.(true, 'sidebar')}
onPointerLeave={() => onSidebarPeekChange?.(false, 'sidebar')}
className={cn(
'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',
'sidebar-glass-surface flex min-h-0 shrink-0 flex-col overflow-hidden border-r border-border/80 text-foreground transition-[width,opacity,box-shadow] duration-300 ease-spring motion-reduce:transition-none',
sidebarCollapsed
? 'pointer-events-none w-0 border-r-0 opacity-0'
: workspaceLayout
? 'w-[256px] min-w-[256px] basis-[256px] opacity-100'
: 'w-64 opacity-100',
// Keep the collapsed preview in the same fixed layer while it opens
// and closes. This makes the hover state cover the titlebar and the
// full window height without reflowing the page underneath it.
sidebarPinnedCollapsed && 'fixed inset-y-0 left-0 z-[350]',
sidebarPeekPreview && 'sidebar-peek-surface pt-10 shadow-float',
)}
>
<div className={cn('bg-surface-sidebar px-3 py-2 text-foreground', sidebarCollapsed && 'px-2')}>
<div className={cn('bg-transparent px-3 py-2 text-foreground', sidebarCollapsed && 'px-2')}>
<div className="min-w-0 w-full">
<ModuleSwitcher sidebarCollapsed={sidebarCollapsed} compact={!sidebarCollapsed} />
</div>
@@ -575,8 +576,8 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
)}
</button>
{projectsOpen && !sidebarCollapsed ? (
<div className="mt-1 space-y-0.5">
{!sidebarCollapsed ? (
<DisclosureContent open={projectsOpen} className="mt-1" innerClassName="space-y-1.5 px-1 py-1">
{visibleProjects.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">
暂无真实项目。添加本地项目后即可开始配置。
@@ -587,49 +588,42 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
const projectConfig = projectConfigs[project.id];
const active = activeProject?.id === project.id;
return (
<div
<button
type="button"
key={project.id}
data-testid={`sidebar-course-project-${project.id}`}
aria-label={`进入项目 ${project.name}`}
aria-current={active ? 'page' : undefined}
onClick={() => void openProject(project)}
className={cn(
'motion-press w-full rounded-lg px-2 py-1.5 text-left transition-[background-color,color]',
'motion-press group/project flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left transition-[background-color,color,box-shadow,transform] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-1 active:scale-[0.985]',
active
? 'bg-brand-soft text-foreground'
: 'bg-transparent text-foreground hover:bg-surface-subtle',
? 'bg-brand-selected text-foreground shadow-soft ring-1 ring-brand/25'
: 'bg-transparent text-foreground hover:bg-surface-tertiary',
)}
>
<div className="flex items-center gap-2">
<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-5 w-5 overflow-hidden rounded-full border border-border bg-white"
>
<img
src={getAgentAvatarSrc(agent.avatarId)}
alt=""
className="h-full w-full object-cover [image-rendering:pixelated]"
/>
</span>
))}
</div>
<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="项目 Agent">
{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)}
alt=""
className="h-full w-full object-cover [image-rendering:pixelated]"
/>
</span>
))}
</div>
<div className="mt-1 flex items-center gap-1">
<button
type="button"
aria-label={`进入项目 ${project.name}`}
onClick={() => void openProject(project)}
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>
</div>
</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>
);
})}
</div>
</DisclosureContent>
) : null}
</div>
</>
@@ -804,30 +798,6 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
: '确认后会在所选位置下创建与项目名称相同的文件夹。'}
</p>
<div className="space-y-2">
<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 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) => (
<option key={option.modelRef} value={option.modelRef}>
{option.label}
</option>
))}
</select>
<p className="text-[11px] font-medium text-muted-foreground">
{configuredModelOptions.length > 0
? '模型由服务端配置提供,项目只保存选择,不保存 Key。'
: '暂时没有可选模型,创建后会使用运行时默认模型。'}
</p>
</div>
{createProjectError ? (
<div className="rounded-md border border-foreground/15 bg-accent-soft px-3 py-2 text-xs font-semibold">
{createProjectError}
@@ -859,40 +829,47 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
</Dialog>
) : null}
{!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-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-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 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 border-foreground/15 bg-accent-soft text-foreground"
onClick={requestRemoveUnavailableProject}
{!isPaintingModule && projectEntryError && typeof document !== 'undefined'
? createPortal(
<div
data-testid="project-config-error-overlay"
className="fixed inset-0 z-[1000] flex min-h-screen items-center justify-center bg-foreground/15 p-4 backdrop-blur-md sm:p-6"
>
<div
role="alertdialog"
aria-modal="true"
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 sm:p-6"
>
移除项目卡片
</Button>
<Button
type="button"
className="border border-foreground/15 bg-brand-soft text-foreground"
onClick={() => setProjectEntryError(null)}
>
知道了
</Button>
</div>
</div>
</div>
) : null}
<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 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 border-foreground/15 bg-accent-soft text-foreground"
onClick={requestRemoveUnavailableProject}
>
移除项目卡片
</Button>
<Button
type="button"
className="border border-foreground/15 bg-brand-soft text-foreground"
onClick={() => setProjectEntryError(null)}
>
知道了
</Button>
</div>
</div>
</div>,
document.body,
)
: null}
{!isPaintingModule ? (
<ConfirmDialog
@@ -978,25 +955,26 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
<span className="min-w-0 flex-1">
<span className="block truncate">{accountName}</span>
</span>
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0 transition-transform', accountMenuOpen && 'rotate-90')} />
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0 transition-transform duration-200 ease-out', accountMenuOpen && 'rotate-90')} />
</>
)}
</button>
<SidebarUpdateButton collapsed={sidebarCollapsed} />
</div>
{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',
)}
>
<DisclosureContent
ref={accountMenuRef}
open={accountMenuOpen}
role="menu"
data-testid="sidebar-account-menu"
className={cn(
'glass-surface z-50 max-w-[calc(100vw-1rem)] 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',
)}
innerClassName="max-h-[calc(100vh-7rem)] overflow-y-auto"
>
<div className="px-1 py-1">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-accent-soft text-xs font-semibold text-foreground">
@@ -1021,13 +999,14 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
>
<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')} />
<ChevronRight className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200 ease-out', usageDrawerOpen && 'rotate-90')} />
</button>
{usageDrawerOpen ? (
<div
<DisclosureContent
open={usageDrawerOpen}
id="sidebar-account-usage-drawer"
data-testid="sidebar-account-usage-drawer"
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"
className="ml-5 rounded-md bg-surface-subtle/70 px-1 py-0.5 text-xs font-medium text-muted-foreground"
innerClassName="grid gap-0.5"
>
<div className="flex min-h-6 items-center justify-between gap-3 px-1">
<span>订阅等级</span>
@@ -1070,8 +1049,7 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
</button>
</div>
) : null}
</div>
) : null}
</DisclosureContent>
<button
type="button"
role="menuitem"
@@ -1104,8 +1082,7 @@ export function Sidebar({ sidebarPeekOpen = false, onSidebarPeekChange }: Sideba
<span className="min-w-0 flex-1 truncate">退出登录</span>
</button>
</div>
</div>
) : null}
</DisclosureContent>
</div>
</div>
</aside>