feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
import { useEffect } from 'react';
import { theme } from 'antd';
import { useConversationStore, useProviderStore, useSettingsStore } from '@/stores';
import { ChatSidebar } from '@/components/chat/ChatSidebar';
import { ChatView } from '@/components/chat/ChatView';
export function ChatPage() {
const { token } = theme.useToken();
const fetchConversations = useConversationStore((s) => s.fetchConversations);
const conversationCount = useConversationStore((s) => s.conversations.length);
const fetchProviders = useProviderStore((s) => s.fetchProviders);
const providerCount = useProviderStore((s) => s.providers.length);
const chatSidebarCollapsed = useSettingsStore((s) => s.settings.chat_sidebar_collapsed ?? false);
const saveSettings = useSettingsStore((s) => s.saveSettings);
useEffect(() => {
if (conversationCount === 0) {
fetchConversations();
}
if (providerCount === 0) {
fetchProviders();
}
}, [conversationCount, fetchConversations, fetchProviders, providerCount]);
useEffect(() => {
const handleToggleChatSidebar = () => {
const current = useSettingsStore.getState().settings.chat_sidebar_collapsed ?? false;
void saveSettings({ chat_sidebar_collapsed: !current });
};
window.addEventListener('boosapi:toggle-chat-sidebar', handleToggleChatSidebar);
return () => {
window.removeEventListener('boosapi:toggle-chat-sidebar', handleToggleChatSidebar);
};
}, [saveSettings]);
return (
<div className="flex h-full" style={{ overflow: 'hidden' }}>
<div
className="h-full shrink-0"
data-testid="chat-sidebar-shell"
aria-hidden={chatSidebarCollapsed}
style={{
width: chatSidebarCollapsed ? 0 : 256,
borderRight: chatSidebarCollapsed ? '0 solid transparent' : '1px solid var(--border-color)',
backgroundColor: token.colorBgContainer,
overflow: 'hidden',
transition: 'width 0.24s cubic-bezier(0.2, 0, 0, 1), border-color 0.24s cubic-bezier(0.2, 0, 0, 1)',
willChange: 'width',
contain: 'layout paint',
}}
>
<div
data-testid="chat-sidebar-content"
style={{
width: 256,
height: '100%',
opacity: chatSidebarCollapsed ? 0 : 1,
visibility: chatSidebarCollapsed ? 'hidden' : 'visible',
pointerEvents: chatSidebarCollapsed ? 'none' : 'auto',
transition: 'opacity 0.12s ease',
}}
>
<ChatSidebar />
</div>
</div>
<div
style={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
backgroundColor: token.colorBgElevated,
}}
>
<ChatView />
</div>
</div>
);
}
+276
View File
@@ -0,0 +1,276 @@
import { Alert, App, theme } from 'antd';
import { OverlayScrollbars } from 'overlayscrollbars';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDrawingSettingsStore, useDrawingStore, useProviderStore } from '@/stores';
import type { DrawingImage } from '@/types';
import { DrawingGenerationList } from '@/components/drawing/DrawingGenerationList';
import { DrawingSettingsPanel } from '@/components/drawing/DrawingSettingsPanel';
import { DrawingComposer } from '@/components/drawing/DrawingComposer';
import { DrawingMaskEditor } from '@/components/drawing/DrawingMaskEditor';
import { getDrawingModelOptions, getDrawingProvidersForModel } from '@/lib/drawingModels';
const HISTORY_BOTTOM_THRESHOLD = 48;
function getHistoryScrollElement(root: HTMLDivElement | null, forceUpdate = false): HTMLElement | null {
if (!root) return null;
try {
const instance = OverlayScrollbars(root);
if (instance) {
if (forceUpdate) instance.update(true);
const elements = instance.elements();
return elements.scrollOffsetElement ?? elements.viewport ?? root;
}
} catch {
// Fall back to the DOM node if OverlayScrollbars is unavailable during tests or teardown.
}
return root.querySelector<HTMLElement>('[data-overlayscrollbars-viewport]') ?? root;
}
export function DrawingPage() {
const { t } = useTranslation();
const { token } = theme.useToken();
const { message, modal } = App.useApp();
const providers = useProviderStore((s) => s.providers);
const fetchProviders = useProviderStore((s) => s.fetchProviders);
const loadHistory = useDrawingStore((s) => s.loadHistory);
const error = useDrawingStore((s) => s.error);
const generations = useDrawingStore((s) => s.generations);
const selectImageForEdit = useDrawingStore((s) => s.selectImageForEdit);
const historyScrollRef = useRef<HTMLDivElement>(null);
const historyContentRef = useRef<HTMLDivElement>(null);
const historyScrollFrameRef = useRef<number | null>(null);
const followHistoryBottomRef = useRef(false);
const previousHistoryRef = useRef<{ ids: string[]; latestKey: string } | null>(null);
const [prompt, setPrompt] = useState('');
const [maskImage, setMaskImage] = useState<DrawingImage | null>(null);
const [composerHeight, setComposerHeight] = useState(176);
const settings = useDrawingSettingsStore((s) => s.settings);
const setSettings = useDrawingSettingsStore((s) => s.setSettings);
const latestGeneration = generations[generations.length - 1];
const latestGenerationScrollKey = latestGeneration
? [
latestGeneration.id,
latestGeneration.status,
latestGeneration.images.length,
latestGeneration.completed_at ?? '',
latestGeneration.error_message ?? '',
].join(':')
: '';
const drawingModelOptions = useMemo(() => getDrawingModelOptions(), []);
useEffect(() => {
if (providers.length === 0) fetchProviders();
}, [fetchProviders, providers.length]);
useEffect(() => {
loadHistory().catch((e) => message.error(String(e)));
}, [loadHistory, message]);
const scrollHistoryToBottom = useCallback(() => {
const scroller = getHistoryScrollElement(historyScrollRef.current, true);
if (!scroller) return;
scroller.scrollTop = scroller.scrollHeight;
scroller.scrollTo?.({ top: scroller.scrollHeight, behavior: 'smooth' });
}, []);
const scheduleScrollToBottom = useCallback((passes = 2) => {
if (historyScrollFrameRef.current !== null) {
cancelAnimationFrame(historyScrollFrameRef.current);
}
const run = (remainingPasses: number) => {
historyScrollFrameRef.current = requestAnimationFrame(() => {
historyScrollFrameRef.current = null;
followHistoryBottomRef.current = true;
scrollHistoryToBottom();
if (remainingPasses > 1) run(remainingPasses - 1);
});
};
run(passes);
}, [scrollHistoryToBottom]);
const scheduleScrollToBottomAfterDeletion = useCallback((force: boolean) => {
if (historyScrollFrameRef.current !== null) {
cancelAnimationFrame(historyScrollFrameRef.current);
}
const inspect = (remainingPasses: number) => {
historyScrollFrameRef.current = requestAnimationFrame(() => {
historyScrollFrameRef.current = null;
const scroller = getHistoryScrollElement(historyScrollRef.current, true);
const isNowNearBottom = scroller
? scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= HISTORY_BOTTOM_THRESHOLD
: false;
if (force || isNowNearBottom) {
scheduleScrollToBottom(Math.max(remainingPasses, 2));
return;
}
if (remainingPasses > 1) inspect(remainingPasses - 1);
});
};
inspect(3);
}, [scheduleScrollToBottom]);
const handleHistoryScroll = useCallback(() => {
const scroller = getHistoryScrollElement(historyScrollRef.current);
if (!scroller) return;
const distanceToBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
followHistoryBottomRef.current = distanceToBottom <= HISTORY_BOTTOM_THRESHOLD;
}, []);
useEffect(() => () => {
if (historyScrollFrameRef.current !== null) {
cancelAnimationFrame(historyScrollFrameRef.current);
}
}, []);
useLayoutEffect(() => {
const currentHistory = {
ids: generations.map((generation) => generation.id),
latestKey: latestGenerationScrollKey,
};
const previousHistory = previousHistoryRef.current;
previousHistoryRef.current = currentHistory;
if (!currentHistory.latestKey) return;
if (!previousHistory) {
scheduleScrollToBottom();
return;
}
const removedIndexes = previousHistory.ids
.map((id, index) => (currentHistory.ids.includes(id) ? -1 : index))
.filter((index) => index >= 0);
const deletedGeneration = removedIndexes.length > 0;
if (deletedGeneration) {
const removedLatestGeneration = removedIndexes.some((index) => index === previousHistory.ids.length - 1);
scheduleScrollToBottomAfterDeletion(removedLatestGeneration || followHistoryBottomRef.current);
return;
}
const addedGeneration = currentHistory.ids.length > previousHistory.ids.length;
if (addedGeneration || currentHistory.latestKey !== previousHistory.latestKey) {
scheduleScrollToBottom();
}
}, [generations, latestGenerationScrollKey, scheduleScrollToBottom, scheduleScrollToBottomAfterDeletion]);
useEffect(() => {
const content = historyContentRef.current;
if (!content || typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(() => {
if (followHistoryBottomRef.current) {
scheduleScrollToBottom();
}
});
observer.observe(content);
return () => observer.disconnect();
}, [scheduleScrollToBottom]);
useEffect(() => {
if (followHistoryBottomRef.current) {
scheduleScrollToBottom();
}
}, [composerHeight, scheduleScrollToBottom]);
useEffect(() => {
setSettings((current) => {
const nextModelId = drawingModelOptions.some((model) => model.value === current.modelId)
? current.modelId
: drawingModelOptions[0]?.value ?? current.modelId;
const nextProviders = getDrawingProvidersForModel(providers, nextModelId);
const nextProviderId = providers.length === 0
? current.providerId
: nextProviders.some((provider) => provider.id === current.providerId)
? current.providerId
: nextProviders[0]?.id ?? '';
if (nextModelId === current.modelId && nextProviderId === current.providerId) {
return current;
}
return {
...current,
modelId: nextModelId,
providerId: nextProviderId,
};
});
}, [drawingModelOptions, providers, setSettings]);
const handleMaskEdit = (image: DrawingImage) => {
setMaskImage(image);
};
const handleUsePrompt = useCallback((nextPrompt: string) => {
if (!prompt.trim() || prompt === nextPrompt) {
setPrompt(nextPrompt);
return;
}
modal.confirm({
title: t('drawing.replacePromptTitle', '替换当前提示词?'),
content: t('drawing.replacePromptContent', '输入框已有内容,是否使用这条历史提示词替换当前内容?'),
okText: t('common.confirm', '确认'),
cancelText: t('common.cancel', '取消'),
onOk: () => setPrompt(nextPrompt),
});
}, [modal, prompt, t]);
return (
<div className="flex h-full" style={{ background: token.colorBgLayout }}>
<DrawingSettingsPanel settings={settings} providers={providers} onChange={setSettings} />
<main className="relative min-w-0 flex-1 overflow-hidden" style={{ background: token.colorBgContainer }}>
<div
className="absolute left-0 right-0 top-0"
data-testid="drawing-history-frame"
style={{ bottom: composerHeight }}
>
<div
ref={historyScrollRef}
className="h-full overflow-y-auto"
data-testid="drawing-history-scroll"
onScroll={handleHistoryScroll}
onScrollCapture={handleHistoryScroll}
>
<div ref={historyContentRef} className={generations.length === 0 ? 'min-h-full' : undefined}>
{error && (
<div style={{ padding: '12px 24px 0' }}>
<Alert type="error" showIcon message={error} />
</div>
)}
<DrawingGenerationList
onEdit={(image) => selectImageForEdit(image)}
onMaskEdit={handleMaskEdit}
onUsePrompt={handleUsePrompt}
referenceImageMode={settings.referenceImageMode}
/>
</div>
</div>
</div>
<DrawingComposer
settings={settings}
prompt={prompt}
onPromptChange={setPrompt}
onHeightChange={setComposerHeight}
/>
</main>
<DrawingMaskEditor
open={!!maskImage}
image={maskImage}
onApply={(image, maskFile, previewUrl) => {
selectImageForEdit(image, maskFile, previewUrl);
setMaskImage(null);
}}
onClose={() => setMaskImage(null)}
/>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useState } from 'react';
import { Tabs } from 'antd';
import { useTranslation } from 'react-i18next';
import { FILE_CATEGORIES, type FileCategory } from '@/components/files/fileCategories';
import { FilesContent } from '@/components/files/FilesContent';
export function FilesPage() {
const { t } = useTranslation();
const [activeKey, setActiveKey] = useState<FileCategory>('images');
const items = FILE_CATEGORIES.map(({ id, labelKey, icon: Icon }) => ({
key: id,
label: t(labelKey),
icon: <Icon size={16} />,
children: <FilesContent key={id} activeCategory={id} />,
}));
return (
<div className="h-full flex flex-col px-2" style={{ overflow: 'hidden' }}>
<Tabs
items={items}
activeKey={activeKey}
onChange={(key) => setActiveKey(key as FileCategory)}
className="flex-1"
style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}
tabBarStyle={{ flexShrink: 0 }}
/>
<style>{`
.h-full > .ant-tabs > .ant-tabs-content-holder {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
`}</style>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs } from 'antd';
import { Gauge, Key, BarChart3, Settings, ScrollText } from 'lucide-react';
import {
GatewayOverview,
GatewayKeys,
GatewayMetrics,
GatewaySettings,
GatewayDiagnostics,
GatewayTemplates,
QuickConnectCycleIcon,
} from '@/components/gateway';
import { useGatewayStore } from '@/stores/gatewayStore';
export function GatewayPage() {
const { t } = useTranslation();
const { fetchRequestLogs } = useGatewayStore();
const [activeKey, setActiveKey] = useState('overview');
const handleViewMoreLogs = useCallback(() => {
setActiveKey('diagnostics');
void fetchRequestLogs();
}, [fetchRequestLogs]);
const items = [
{
key: 'overview',
label: t('gateway.overview'),
icon: <Gauge size={16} />,
children: <GatewayOverview onViewMoreLogs={handleViewMoreLogs} />,
},
{
key: 'keys',
label: t('gateway.keys'),
icon: <Key size={16} />,
children: <GatewayKeys />,
},
{
key: 'metrics',
label: t('gateway.metrics'),
icon: <BarChart3 size={16} />,
children: <GatewayMetrics />,
},
{
key: 'diagnostics',
label: t('gateway.logs'),
icon: <ScrollText size={16} />,
children: <GatewayDiagnostics />,
},
{
key: 'quickConnect',
label: t('gateway.quickConnect'),
icon: <QuickConnectCycleIcon size={16} />,
children: <GatewayTemplates />,
},
{
key: 'settings',
label: t('gateway.settings'),
icon: <Settings size={16} />,
children: <GatewaySettings />,
},
];
return (
<div className="h-full flex flex-col px-2" style={{ overflow: 'hidden' }}>
<Tabs
items={items}
activeKey={activeKey}
onChange={setActiveKey}
className="flex-1"
style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}
tabBarStyle={{ flexShrink: 0 }}
/>
<style>{`
.h-full > .ant-tabs > .ant-tabs-content-holder {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
`}</style>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { theme } from 'antd';
import KnowledgeSettings from '@/components/settings/KnowledgeSettings';
export function KnowledgePage() {
const { token } = theme.useToken();
return (
<div className="h-full" style={{ overflow: 'hidden', backgroundColor: token.colorBgElevated }}>
<KnowledgeSettings />
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { theme } from 'antd';
import MemorySettings from '@/components/settings/MemorySettings';
export function MemoryPage() {
const { token } = theme.useToken();
return (
<div className="h-full" style={{ overflow: 'hidden', backgroundColor: token.colorBgElevated }}>
<MemorySettings />
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { useLayoutEffect, useRef } from 'react';
import { theme } from 'antd';
import { useUIStore } from '@/stores';
import {
SettingsSidebar,
ProviderSettings,
GeneralSettings,
DisplaySettings,
ProxySettings,
ShortcutSettings,
DataManager,
AboutPage,
SearchProviderSettings,
McpServerSettings,
BackupCenter,
StorageSpaceManager,
} from '@/components/settings';
import { DefaultModelSettings } from '@/components/settings/DefaultModelSettings';
import { ConversationSettings } from '@/components/settings/ConversationSettings';
import type { SettingsSection } from '@/types';
const SECTION_COMPONENTS: Record<SettingsSection, React.ComponentType> = {
providers: ProviderSettings,
conversationSettings: ConversationSettings,
defaultModel: DefaultModelSettings,
general: GeneralSettings,
display: DisplaySettings,
proxy: ProxySettings,
shortcuts: ShortcutSettings,
data: DataManager,
storage: StorageSpaceManager,
about: AboutPage,
searchProviders: SearchProviderSettings,
mcpServers: McpServerSettings,
backup: BackupCenter,
};
export function SettingsPage() {
const { token } = theme.useToken();
const settingsSection = useUIStore((s) => s.settingsSection);
const ContentComponent = SECTION_COMPONENTS[settingsSection];
const contentScrollRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const contentScroller = contentScrollRef.current;
if (!contentScroller) return;
contentScroller.scrollTop = 0;
contentScroller.scrollLeft = 0;
}, [settingsSection]);
return (
<div className="flex h-full">
<div
className="w-56 shrink-0 h-full"
style={{ borderRight: '1px solid var(--border-color)', backgroundColor: token.colorBgContainer }}
>
<SettingsSidebar />
</div>
<div
ref={contentScrollRef}
className="min-w-0 flex-1 overflow-y-auto"
style={{ backgroundColor: token.colorBgElevated }}
>
<ContentComponent />
</div>
</div>
);
}
+783
View File
@@ -0,0 +1,783 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import {
Button, Switch, Tag, Input, Modal, Tabs, Space, Spin, Empty,
Typography, message, Card, Popconfirm, theme, Dropdown, Collapse, Select,
} from 'antd';
import {
FolderOpen, RefreshCw, Download, Trash2, Sparkles, Store, Star, Github,
ChevronRight, Layers, Radio,
} from 'lucide-react';
import { Claude } from '@lobehub/icons';
import appLogo from '@/assets/image/logo.png';
import { useTranslation } from 'react-i18next';
import { useSkillStore } from '@/stores';
import type { Skill, MarketplaceSkill } from '@/types';
import { CopyButton } from '@/components/common/CopyButton';
const INSTALL_TARGETS = [
{ key: 'boosapi', label: '~/.boosapi/skills/', desc: 'BoosAPI Desktop', icon: <Sparkles size={14} /> },
{ key: 'claude', label: '~/.claude/skills/', desc: 'Claude', icon: <FolderOpen size={14} /> },
{ key: 'agents', label: '~/.agents/skills/', desc: 'Agents', icon: <FolderOpen size={14} /> },
] as const;
const openExternalUrl = (url: string) => {
import('@tauri-apps/plugin-opener')
.then(({ openUrl }) => openUrl(url))
.catch(() => window.open(url, '_blank'));
};
const { Text, Paragraph } = Typography;
const SOURCE_ICONS: Record<string, React.ReactNode> = {
boosapi: <img src={appLogo} alt="" style={{ width: 14, height: 14, verticalAlign: 'middle' }} />,
claude: <Claude.Color size={14} />,
agents: <Radio size={14} />,
};
const ALL_SOURCE_ICON = <Layers size={14} />;
function SkillCard({
skill,
onToggle,
onDetail,
onUninstall,
onOpenDir,
t,
}: {
skill: Skill;
onToggle: (name: string, enabled: boolean) => void;
onDetail: (name: string) => void;
onUninstall: (name: string) => void;
onOpenDir: (path: string) => void;
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
return (
<Card
size="small"
className="skill-card-hover"
style={{ marginBottom: 8 }}
styles={{ body: { padding: '12px 16px' } }}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Text strong className="skill-card-title" style={{ cursor: 'pointer' }} onClick={() => onDetail(skill.name)}>
{skill.name}
</Text>
<CopyButton text={skill.name} size={12} />
<Tag>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{SOURCE_ICONS[skill.source]}
{t(`skills.source.${skill.source}`)}
</span>
</Tag>
{skill.version && (
<Text type="secondary" style={{ fontSize: 12 }}>v{skill.version}</Text>
)}
</div>
<Paragraph
type="secondary"
ellipsis={{ rows: 2 }}
style={{ marginBottom: 0, fontSize: 13, cursor: 'pointer' }}
onClick={() => onDetail(skill.name)}
>
{skill.description}
</Paragraph>
{skill.author && (
<Text type="secondary" style={{ fontSize: 12 }}>{skill.author}</Text>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<Switch
size="small"
checked={skill.enabled}
onChange={(checked) => onToggle(skill.name, checked)}
/>
<Button
type="text"
size="small"
icon={<FolderOpen size={14} />}
onClick={() => onOpenDir(skill.sourcePath)}
/>
{skill.source !== 'builtin' && (
<Popconfirm
title={t('skills.uninstallConfirm', { name: skill.name })}
onConfirm={() => onUninstall(skill.name)}
okText={t('skills.uninstall')}
cancelText={t('common.cancel')}
>
<Button
type="text"
size="small"
danger
icon={<Trash2 size={14} />}
/>
</Popconfirm>
)}
</div>
</div>
</Card>
);
}
function MarketplaceCard({
skill,
onInstall,
onDetail,
installing,
t,
source,
}: {
skill: MarketplaceSkill;
onInstall: (repo: string, target: string) => void;
onDetail: (repo: string) => void;
installing: string | null;
t: (key: string) => string;
source: string;
}) {
const githubUrl = `https://github.com/${skill.repo}`;
return (
<Card
size="small"
className="skill-card-hover"
style={{ marginBottom: 8 }}
styles={{ body: { padding: '12px 16px' } }}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Text strong className="skill-card-title" style={{ cursor: 'pointer' }} onClick={() => onDetail(skill.repo)}>
{skill.name}
</Text>
<CopyButton text={skill.name} size={12} />
{source === 'github' ? (
<Text type="secondary" style={{ fontSize: 12, display: 'inline-flex', alignItems: 'center', gap: 2 }}>
<Star size={12} style={{ color: '#faad14' }} /> {skill.stars.toLocaleString()}
</Text>
) : (
<Text type="secondary" style={{ fontSize: 12, display: 'inline-flex', alignItems: 'center', gap: 2 }}>
<Download size={12} /> {skill.installs.toLocaleString()}
</Text>
)}
</div>
{skill.description ? (
<Text
type="secondary"
style={{ fontSize: 12, display: 'block', marginBottom: 2, cursor: 'pointer' }}
onClick={() => onDetail(skill.repo)}
>
{skill.description}
</Text>
) : null}
<Text type="secondary" style={{ fontSize: 12 }}>{skill.repo}</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Button
size="small"
type="text"
icon={<Github size={14} />}
onClick={() => openExternalUrl(githubUrl)}
>
GitHub
</Button>
{skill.installed ? (
<Button size="small" disabled>
{t('skills.installed')}
</Button>
) : (
<Dropdown
menu={{
items: INSTALL_TARGETS.map((target) => ({
key: target.key,
icon: target.icon,
label: `${target.desc} (${target.label})`,
})),
onClick: ({ key }) => onInstall(skill.repo, key),
}}
trigger={['click']}
disabled={installing === skill.repo}
>
<Button
size="small"
type="primary"
loading={installing === skill.repo}
icon={<Download size={14} />}
>
{t('skills.install')}
</Button>
</Dropdown>
)}
</div>
</div>
</Card>
);
}
export function SkillsPage() {
const { t } = useTranslation();
const { token } = theme.useToken();
const [messageApi, contextHolder] = message.useMessage();
const {
skills, marketplaceSkills, loading, marketplaceLoading, selectedSkill,
loadSkills, getSkill, toggleSkill, installSkill, uninstallSkill,
uninstallSkillGroup,
openSkillDir, searchMarketplace, clearSelectedSkill,
} = useSkillStore();
const [installUrl, setInstallUrl] = useState('');
const [installing, setInstalling] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [marketplaceSource, setMarketplaceSource] = useState<'skills.sh' | 'github'>('skills.sh');
const [marketplaceQuery, setMarketplaceQuery] = useState('');
const marketplaceLoaded = useRef(false);
const [marketplaceDetailOpen, setMarketplaceDetailOpen] = useState(false);
const [marketplaceDetailContent, setMarketplaceDetailContent] = useState<{ name: string; repo: string; content: string } | null>(null);
const [marketplaceDetailLoading, setMarketplaceDetailLoading] = useState(false);
const [sourceFilter, setSourceFilter] = useState<'all' | 'boosapi' | 'claude' | 'agents'>('all');
useEffect(() => {
loadSkills();
}, [loadSkills]);
// Re-search when source changes (if marketplace was already searched)
useEffect(() => {
if (marketplaceLoaded.current) {
searchMarketplace(marketplaceQuery, marketplaceSource);
}
}, [marketplaceSource]);
const handleTabChange = useCallback((key: string) => {
if (key === 'marketplace' && !marketplaceLoaded.current) {
marketplaceLoaded.current = true;
searchMarketplace('', marketplaceSource);
}
}, [searchMarketplace, marketplaceSource]);
const handleInstallFromUrl = useCallback(async (target: string) => {
if (!installUrl.trim()) return;
setInstalling(installUrl);
try {
const name = await installSkill(installUrl.trim(), target);
messageApi.success(t('skills.installSuccess', { name }));
setInstallUrl('');
} catch (e) {
messageApi.error(String(e));
} finally {
setInstalling(null);
}
}, [installUrl, installSkill, messageApi, t]);
const handleInstallFromMarketplace = useCallback(async (repo: string, target: string) => {
setInstalling(repo);
try {
const name = await installSkill(repo, target);
messageApi.success(t('skills.installSuccess', { name }));
} catch (e) {
messageApi.error(String(e));
} finally {
setInstalling(null);
}
}, [installSkill, messageApi, t]);
const handleToggle = useCallback((name: string, enabled: boolean) => {
toggleSkill(name, enabled);
}, [toggleSkill]);
const handleDetail = useCallback(async (name: string) => {
await getSkill(name);
setDetailOpen(true);
}, [getSkill]);
const handleMarketplaceDetail = useCallback(async (repo: string) => {
const skill = marketplaceSkills.find(s => s.repo === repo);
if (!skill) return;
setMarketplaceDetailOpen(true);
setMarketplaceDetailLoading(true);
setMarketplaceDetailContent({ name: skill.name, repo: skill.repo, content: '' });
try {
const res = await fetch(`https://raw.githubusercontent.com/${repo}/main/SKILL.md`);
const content = res.ok ? await res.text() : '(SKILL.md not found)';
setMarketplaceDetailContent({ name: skill.name, repo: skill.repo, content });
} catch {
setMarketplaceDetailContent({ name: skill.name, repo: skill.repo, content: '(Failed to fetch SKILL.md)' });
} finally {
setMarketplaceDetailLoading(false);
}
}, [marketplaceSkills]);
const handleUninstall = useCallback(async (name: string) => {
try {
await uninstallSkill(name);
messageApi.success(t('skills.uninstallSuccess', { name }));
} catch (e) {
messageApi.error(String(e));
}
}, [uninstallSkill, messageApi, t]);
const handleOpenSkillDir = useCallback(async (path: string) => {
try {
await openSkillDir(path);
} catch (e) {
messageApi.error(String(e));
}
}, [openSkillDir, messageApi]);
const handleGroupToggle = useCallback((groupSkills: Skill[], enabled: boolean) => {
for (const skill of groupSkills) {
toggleSkill(skill.name, enabled);
}
}, [toggleSkill]);
const handleUninstallGroup = useCallback(async (group: string) => {
try {
await uninstallSkillGroup(group);
messageApi.success(t('skills.uninstallSuccess', { name: group }));
} catch (e) {
messageApi.error(String(e));
}
}, [uninstallSkillGroup, messageApi, t]);
const filteredSkills = useMemo(() => {
if (sourceFilter === 'all') return skills;
return skills.filter(s => s.source === sourceFilter);
}, [skills, sourceFilter]);
const groupedSkills = useMemo(() => {
const groups = new Map<string, Skill[]>();
const ungrouped: Skill[] = [];
for (const skill of filteredSkills) {
if (skill.group) {
const arr = groups.get(skill.group) || [];
arr.push(skill);
groups.set(skill.group, arr);
} else {
ungrouped.push(skill);
}
}
return { groups, ungrouped };
}, [filteredSkills]);
const handleOpenGroupDir = useCallback(async (group: string) => {
// Find the first skill in the group and open its parent's parent dir
const groupSkills = groupedSkills.groups.get(group);
if (groupSkills && groupSkills.length > 0) {
const firstSkillPath = groupSkills[0].sourcePath;
// sourcePath points to SKILL.md; go up two levels to the group dir
const parts = firstSkillPath.split('/');
const groupDir = parts.slice(0, -2).join('/');
try {
await openSkillDir(groupDir || firstSkillPath);
} catch (e) {
messageApi.error(String(e));
}
}
}, [groupedSkills.groups, openSkillDir, messageApi]);
const mySkillsContent = (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '0 4px', flexShrink: 0 }}>
<Space.Compact style={{ width: '100%', marginBottom: 8 }}>
<Input
placeholder={t('skills.installUrlPlaceholder')}
value={installUrl}
onChange={(e) => setInstallUrl(e.target.value)}
onPressEnter={() => handleInstallFromUrl('boosapi')}
/>
<Dropdown
menu={{
items: INSTALL_TARGETS.map((target) => ({
key: target.key,
icon: target.icon,
label: `${target.desc} (${target.label})`,
})),
onClick: ({ key }) => handleInstallFromUrl(key),
}}
trigger={['click']}
disabled={!installUrl.trim()}
>
<Button
type="primary"
loading={installing === installUrl && !!installUrl}
disabled={!installUrl.trim()}
>
{t('skills.installFromUrl')}
</Button>
</Dropdown>
<Button
icon={<RefreshCw size={14} />}
onClick={() => loadSkills()}
/>
</Space.Compact>
<Tabs
size="small"
activeKey={sourceFilter}
onChange={(k) => setSourceFilter(k as 'all' | 'boosapi' | 'claude' | 'agents')}
items={[
{ key: 'all', label: <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>{ALL_SOURCE_ICON}{t('skills.sourceAll')}</span> },
{ key: 'boosapi', label: <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>{SOURCE_ICONS.boosapi}BoosAPI Desktop</span> },
{ key: 'claude', label: <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>{SOURCE_ICONS.claude}Claude</span> },
{ key: 'agents', label: <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>{SOURCE_ICONS.agents}Agents</span> },
]}
style={{ marginBottom: 8 }}
/>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '0 4px' }}>
{sourceFilter !== 'all' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 8px', marginBottom: 8, backgroundColor: token.colorBgContainer, borderRadius: 6 }}>
<FolderOpen size={14} style={{ color: token.colorTextSecondary, flexShrink: 0 }} />
<Text type="secondary" style={{ fontSize: 12, flex: 1 }} ellipsis>
{INSTALL_TARGETS.find(t => t.key === sourceFilter)?.label}
</Text>
<Button
type="text"
size="small"
icon={<FolderOpen size={14} />}
onClick={async () => {
try {
const target = INSTALL_TARGETS.find(t => t.key === sourceFilter);
if (!target) return;
const { homeDir } = await import('@tauri-apps/api/path');
const home = await homeDir();
const fullPath = target.label.replace('~/', home.endsWith('/') ? home : home + '/');
const { revealItemInDir } = await import('@tauri-apps/plugin-opener');
await revealItemInDir(fullPath);
} catch { /* ignore */ }
}}
>
{t('skills.openDir')}
</Button>
</div>
)}
{loading ? (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin />
</div>
) : filteredSkills.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div>
<div>{t('skills.empty')}</div>
<Text type="secondary" style={{ fontSize: 12 }}>{t('skills.emptyDesc')}</Text>
</div>
}
/>
) : (
<>
{groupedSkills.ungrouped.map((skill) => (
<SkillCard
key={skill.name}
skill={skill}
onToggle={handleToggle}
onDetail={handleDetail}
onUninstall={handleUninstall}
onOpenDir={handleOpenSkillDir}
t={t}
/>
))}
{Array.from(groupedSkills.groups.entries()).map(([group, groupSkills]) => {
const allEnabled = groupSkills.every((s) => s.enabled);
const someEnabled = groupSkills.some((s) => s.enabled);
return (
<Collapse
key={group}
defaultActiveKey={[]}
style={{ marginTop: 8 }}
expandIcon={({ isActive }) => (
<ChevronRight
size={14}
style={{
transform: isActive ? 'rotate(90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s',
}}
/>
)}
items={[{
key: group,
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, lineHeight: 1 }}>
<Text strong style={{ lineHeight: '22px' }}>{group}</Text>
<Tag style={{ margin: 0 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{SOURCE_ICONS[groupSkills[0]?.source]}
{t(`skills.source.${groupSkills[0]?.source}`)}
</span>
</Tag>
<Tag style={{ margin: 0 }}>{t('skills.groupSkillCount', { count: groupSkills.length })}</Tag>
</div>
),
extra: (
<Space size={4} onClick={(e) => e.stopPropagation()}>
<Switch
size="small"
checked={allEnabled}
style={someEnabled && !allEnabled ? { backgroundColor: '#faad14' } : undefined}
onChange={(checked) => { handleGroupToggle(groupSkills, checked); }}
/>
<Button
type="text"
size="small"
icon={<FolderOpen size={14} />}
onClick={(e) => { e.stopPropagation(); handleOpenGroupDir(group); }}
/>
<Popconfirm
title={t('skills.uninstallGroupConfirm', { name: group })}
onConfirm={() => handleUninstallGroup(group)}
okText={t('skills.uninstall')}
cancelText={t('common.cancel')}
>
<Button
type="text"
size="small"
danger
icon={<Trash2 size={14} />}
onClick={(e) => e.stopPropagation()}
/>
</Popconfirm>
</Space>
),
children: (
<div style={{ padding: '4px 0' }}>
{groupSkills.map((skill) => (
<SkillCard
key={skill.name}
skill={skill}
onToggle={handleToggle}
onDetail={handleDetail}
onUninstall={handleUninstall}
onOpenDir={handleOpenSkillDir}
t={t}
/>
))}
</div>
),
}]}
/>
);
})}
</>
)}
</div>
</div>
);
const marketplaceContent = (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '0 4px', flexShrink: 0 }}>
<Space.Compact style={{ width: '100%', marginBottom: 12 }}>
<Select
value={marketplaceSource}
onChange={(v) => setMarketplaceSource(v)}
style={{ width: 120, flexShrink: 0 }}
options={[
{ value: 'skills.sh', label: 'skills.sh' },
{ value: 'github', label: 'GitHub' },
]}
/>
<Input.Search
placeholder={t('skills.searchMarketplace')}
loading={marketplaceLoading}
onSearch={(q) => {
setMarketplaceQuery(q);
marketplaceLoaded.current = true;
searchMarketplace(q, marketplaceSource);
}}
enterButton
/>
</Space.Compact>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '0 4px' }}>
{marketplaceLoading ? (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin />
</div>
) : marketplaceSkills.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('skills.noResults')}
/>
) : (
marketplaceSkills.map((skill) => (
<MarketplaceCard
key={skill.repo}
skill={skill}
onInstall={handleInstallFromMarketplace}
onDetail={handleMarketplaceDetail}
installing={installing}
t={t}
source={marketplaceSource}
/>
))
)}
</div>
</div>
);
return (
<>
{contextHolder}
<div className="h-full flex flex-col" style={{ overflow: 'hidden', backgroundColor: token.colorBgElevated }}>
<Tabs
className="skills-page-tabs"
defaultActiveKey="my"
onChange={handleTabChange}
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
tabBarStyle={{ padding: '0 16px', flexShrink: 0 }}
items={[
{
key: 'my',
label: <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><Sparkles size={14} />{t('skills.mySkills')}</span>,
children: mySkillsContent,
},
{
key: 'marketplace',
label: <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><Store size={14} />{t('skills.marketplace')}</span>,
children: marketplaceContent,
},
]}
/>
<style>{`
.skills-page-tabs > .ant-tabs-content-holder {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0 12px;
}
.skills-page-tabs > .ant-tabs-content-holder > .ant-tabs-content {
flex: 1;
min-height: 0;
}
.skills-page-tabs > .ant-tabs-content-holder > .ant-tabs-content > .ant-tabs-tabpane-active {
height: 100%;
display: flex;
flex-direction: column;
}
.skill-card-hover {
transition: border-color 0.2s;
}
.skill-card-hover:hover {
border-color: ${token.colorPrimary} !important;
}
.skill-card-hover:hover .skill-card-title {
color: ${token.colorPrimary} !important;
}
`}</style>
</div>
<Modal
title={t('skills.detail')}
open={detailOpen}
onCancel={() => { setDetailOpen(false); clearSelectedSkill(); }}
footer={null}
width={640}
>
{selectedSkill && (
<div style={{ userSelect: 'text' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Typography.Title level={4} style={{ margin: 0 }}>{selectedSkill.info.name}</Typography.Title>
<CopyButton text={selectedSkill.info.name} size={14} />
<Tag>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{SOURCE_ICONS[selectedSkill.info.source]}
{t(`skills.source.${selectedSkill.info.source}`)}
</span>
</Tag>
</div>
<Paragraph type="secondary">{selectedSkill.info.description}</Paragraph>
{selectedSkill.manifest && (
<div style={{ marginBottom: 12 }}>
{selectedSkill.manifest.sourceRef && (
<Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
Source: {selectedSkill.manifest.sourceRef}
</Text>
)}
<Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
Installed: {selectedSkill.manifest.installedAt}
</Text>
</div>
)}
<div style={{ position: 'relative' }}>
<CopyButton
text={selectedSkill.content}
size={14}
style={{ position: 'absolute', top: 8, right: 8, zIndex: 1 }}
/>
<div
style={{
background: token.colorBgContainer,
borderRadius: token.borderRadius,
padding: 16,
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
fontSize: 13,
maxHeight: 400,
overflow: 'auto',
userSelect: 'text',
}}
>
{selectedSkill.content}
</div>
</div>
{selectedSkill.files.length > 0 && (
<div style={{ marginTop: 12 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Files: {selectedSkill.files.join(', ')}
</Text>
</div>
)}
</div>
)}
</Modal>
<Modal
title={t('skills.detail')}
open={marketplaceDetailOpen}
onCancel={() => { setMarketplaceDetailOpen(false); setMarketplaceDetailContent(null); }}
footer={null}
width={640}
>
{marketplaceDetailContent && (
<div style={{ userSelect: 'text' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Typography.Title level={4} style={{ margin: 0 }}>{marketplaceDetailContent.name}</Typography.Title>
<CopyButton text={marketplaceDetailContent.name} size={14} />
</div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>{marketplaceDetailContent.repo}</Text>
{marketplaceDetailLoading ? (
<div style={{ textAlign: 'center', padding: 32 }}><Spin /></div>
) : (
<div style={{ position: 'relative' }}>
<CopyButton
text={marketplaceDetailContent.content}
size={14}
style={{ position: 'absolute', top: 8, right: 8, zIndex: 1 }}
/>
<div
style={{
background: token.colorBgContainer,
borderRadius: token.borderRadius,
padding: 16,
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
fontSize: 13,
maxHeight: 400,
overflow: 'auto',
userSelect: 'text',
}}
>
{marketplaceDetailContent.content}
</div>
</div>
)}
</div>
)}
</Modal>
</>
);
}
@@ -0,0 +1,120 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ChatPage } from '../ChatPage';
const fetchConversations = vi.fn();
const fetchProviders = vi.fn();
const saveSettings = vi.fn();
const conversationState = {
conversations: [] as Array<{ id: string }>,
fetchConversations,
};
const providerState = {
providers: [] as Array<{ id: string }>,
fetchProviders,
};
const settingsState = {
settings: {
chat_sidebar_collapsed: false,
},
saveSettings,
};
const chatSidebarProps: Array<Record<string, unknown>> = [];
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => ({
'chat.expandSidebar': '展开左侧对话栏',
'chat.collapseSidebar': '折叠左侧对话栏',
}[key] ?? key),
}),
}));
vi.mock('antd', () => ({
Button: ({ icon, onClick, 'aria-label': ariaLabel }: any) => (
<button type="button" aria-label={ariaLabel} onClick={onClick}>
{icon}
</button>
),
Tooltip: ({ children }: any) => <>{children}</>,
theme: {
useToken: () => ({
token: {
colorBgContainer: '#111',
colorBgElevated: '#222',
},
}),
},
}));
vi.mock('@/stores', () => ({
useConversationStore: (selector: (state: typeof conversationState) => unknown) => selector(conversationState),
useProviderStore: (selector: (state: typeof providerState) => unknown) => selector(providerState),
useSettingsStore: (selector: (state: typeof settingsState) => unknown) => selector(settingsState),
}));
vi.mock('@/components/chat/ChatSidebar', () => ({
ChatSidebar: (props: Record<string, unknown>) => {
chatSidebarProps.push(props);
return <div>sidebar</div>;
},
}));
vi.mock('@/components/chat/ChatView', () => ({
ChatView: () => <div>chat-view</div>,
}));
describe('ChatPage', () => {
beforeEach(() => {
vi.clearAllMocks();
conversationState.conversations = [];
providerState.providers = [];
settingsState.settings.chat_sidebar_collapsed = false;
chatSidebarProps.length = 0;
});
it('fetches conversations and providers only when the stores are empty', () => {
render(<ChatPage />);
expect(fetchConversations).toHaveBeenCalledTimes(1);
expect(fetchProviders).toHaveBeenCalledTimes(1);
});
it('skips refetching when conversations and providers are already loaded', () => {
conversationState.conversations = [{ id: 'conv-1' }];
providerState.providers = [{ id: 'provider-1' }];
render(<ChatPage />);
expect(fetchConversations).not.toHaveBeenCalled();
expect(fetchProviders).not.toHaveBeenCalled();
});
it('renders the full chat sidebar by default without passing a page-level collapse callback', () => {
render(<ChatPage />);
expect(screen.getByText('sidebar')).toBeInTheDocument();
expect(chatSidebarProps[chatSidebarProps.length - 1]?.onCollapse).toBeUndefined();
});
it('keeps the chat sidebar mounted inside a hidden zero-width shell when collapsed', () => {
settingsState.settings.chat_sidebar_collapsed = true;
render(<ChatPage />);
expect(screen.getByText('sidebar')).toBeInTheDocument();
expect(screen.getByTestId('chat-sidebar-shell')).toHaveStyle({
width: '0px',
overflow: 'hidden',
});
expect(screen.getByTestId('chat-sidebar-content')).toHaveStyle({
opacity: '0',
visibility: 'hidden',
pointerEvents: 'none',
});
expect(screen.queryByRole('button', { name: '展开左侧对话栏' })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,768 @@
import { act, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ContentArea } from '@/components/layout/ContentArea';
import { useDrawingSettingsStore } from '@/stores/drawingSettingsStore';
import { useDrawingStore } from '@/stores/drawingStore';
import { useProviderStore } from '@/stores/providerStore';
import type { DrawingGeneration, DrawingImage } from '@/types';
vi.mock('@/pages/ChatPage', () => ({ ChatPage: () => <div>chat</div> }));
vi.mock('@/pages/KnowledgePage', () => ({ KnowledgePage: () => <div>knowledge</div> }));
vi.mock('@/pages/MemoryPage', () => ({ MemoryPage: () => <div>memory</div> }));
vi.mock('@/pages/GatewayPage', () => ({ GatewayPage: () => <div>gateway</div> }));
vi.mock('@/pages/FilesPage', () => ({ FilesPage: () => <div>files</div> }));
vi.mock('@/pages/SettingsPage', () => ({ SettingsPage: () => <div>settings</div> }));
vi.mock('@/pages/SkillsPage', () => ({ SkillsPage: () => <div>skills</div> }));
vi.mock('@/lib/providerIcons', () => ({
SmartProviderIcon: () => <span>provider-icon</span>,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
}),
}));
vi.mock('antd', async () => {
const actual = await vi.importActual<typeof import('antd')>('antd');
return {
...actual,
theme: {
...actual.theme,
useToken: () => ({
token: {
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorBgLayout: '#0f172a',
colorBorderSecondary: '#e5e7eb',
colorFillAlter: '#f6f8fa',
colorFillSecondary: '#f3f4f6',
colorPrimary: '#1677ff',
colorPrimaryBg: '#e6f4ff',
colorText: '#111827',
colorTextBase: '#111827',
colorTextSecondary: '#6b7280',
},
}),
},
};
});
function imageFixture(overrides: Partial<DrawingImage> = {}): DrawingImage {
return {
id: 'image-1',
generation_id: 'generation-1',
stored_file_id: 'file-1',
storage_path: 'images/drawing.png',
mime_type: 'image/png',
width: 1024,
height: 1024,
revised_prompt: null,
created_at: 1,
...overrides,
};
}
function generationFixture(id: string, createdAt: number, images: DrawingImage[] = []): DrawingGeneration {
return {
id,
parent_generation_id: null,
provider_id: 'provider-1',
key_id: 'key-1',
model_id: 'gpt-image-2',
api_kind: 'image_api',
action: 'generate',
prompt: id,
parameters_json: JSON.stringify({ n: 1, size: '1024x1024' }),
reference_file_ids_json: '[]',
source_image_ids_json: '[]',
mask_file_id: null,
status: 'succeeded',
error_message: null,
response_id: null,
usage_json: null,
created_at: createdAt,
completed_at: createdAt,
images,
};
}
function createAnimationFrameQueue() {
const frames = new Map<number, FrameRequestCallback>();
let nextFrameId = 1;
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
const frameId = nextFrameId;
nextFrameId += 1;
frames.set(frameId, callback);
return frameId;
});
const cancelAnimationFrameSpy = vi
.spyOn(window, 'cancelAnimationFrame')
.mockImplementation((frameId) => {
frames.delete(frameId);
});
const flushNext = () => {
const nextFrame = frames.entries().next().value;
if (!nextFrame) return;
const [frameId, callback] = nextFrame;
frames.delete(frameId);
callback(0);
};
return {
flushNext,
flushAll: () => {
while (frames.size > 0) flushNext();
},
restore: () => {
requestAnimationFrameSpy.mockRestore();
cancelAnimationFrameSpy.mockRestore();
},
};
}
describe('DrawingPage routing', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
beforeEach(() => {
useDrawingStore.setState({
generations: [],
references: [],
loading: false,
submitting: false,
error: null,
editSourceImage: null,
editMaskFileId: null,
editMaskFile: null,
editPreviewUrl: null,
});
useDrawingSettingsStore.getState().resetSettings();
useProviderStore.setState({
providers: [],
loading: false,
error: null,
fetchProviders: vi.fn(async () => {}),
});
});
it('renders the drawing page from ContentArea', () => {
const { container } = render(<ContentArea activePage="drawing" />);
expect(screen.queryByText('历史记录')).toBeNull();
expect(screen.queryByText('绘画设置')).toBeNull();
expect(screen.getByTestId('drawing-generation-list')).toBeDefined();
expect(screen.getByTestId('drawing-composer')).toBeDefined();
expect(screen.getAllByText('Auto').length).toBeGreaterThanOrEqual(1);
expect(container.firstElementChild).toHaveStyle({ background: '#0f172a' });
expect(screen.queryByRole('button', { name: '参考图' })).toBeNull();
const composer = screen.getByTestId('drawing-composer');
expect(composer.style.backgroundColor).toBe('rgb(255, 255, 255)');
expect(composer.style.border).toBe('1px solid var(--border-color)');
expect(composer.style.borderRadius).toBe('16px');
expect(composer.querySelector('textarea')).toHaveClass('boosapi-input-textarea');
const historyFrame = screen.getByTestId('drawing-history-frame');
const historyScroller = screen.getByTestId('drawing-history-scroll');
expect(historyFrame).toHaveClass('absolute');
expect(historyFrame).toHaveStyle({ bottom: '176px' });
expect(historyScroller).toHaveClass('h-full');
expect(historyScroller.style.paddingBottom).toBe('');
});
it('scrolls the history area to the bottom when a new generation appears', () => {
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
callback(0);
return 1;
});
const scrollTo = vi.fn();
render(<ContentArea activePage="drawing" />);
const scroller = screen.getByTestId('drawing-history-scroll');
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 900 });
Object.defineProperty(scroller, 'scrollTo', { configurable: true, value: scrollTo });
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('older', 1),
generationFixture('newer', 2),
],
});
});
expect(scrollTo).toHaveBeenCalledWith({ top: 900, behavior: 'smooth' });
requestAnimationFrameSpy.mockRestore();
});
it('does not stretch the history content wrapper when records are visible', () => {
render(<ContentArea activePage="drawing" />);
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('正在生成的历史图', 1, []),
],
});
});
expect(screen.getByTestId('drawing-generation-list').parentElement).not.toHaveClass('min-h-full');
});
it('scrolls the history area to the bottom when the latest generation receives images', () => {
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
callback(0);
return 1;
});
const scrollTo = vi.fn();
render(<ContentArea activePage="drawing" />);
const scroller = screen.getByTestId('drawing-history-scroll');
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 900 });
Object.defineProperty(scroller, 'scrollTo', { configurable: true, value: scrollTo });
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('older', 1),
{
...generationFixture('newer', 2),
status: 'running',
completed_at: null,
images: [],
},
],
});
});
scrollTo.mockClear();
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1200 });
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('older', 1),
generationFixture('newer', 2, [imageFixture()]),
],
});
});
expect(scrollTo).toHaveBeenCalledWith({ top: 1200, behavior: 'smooth' });
requestAnimationFrameSpy.mockRestore();
});
it('keeps scrolling to the bottom when the history content height changes', () => {
const resizeCallbacks: ResizeObserverCallback[] = [];
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
callback(0);
return 1;
});
vi.stubGlobal('ResizeObserver', vi.fn(function ResizeObserverMock(callback: ResizeObserverCallback) {
resizeCallbacks.push(callback);
return {
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
};
}));
const scrollTo = vi.fn();
render(<ContentArea activePage="drawing" />);
const scroller = screen.getByTestId('drawing-history-scroll');
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 900 });
Object.defineProperty(scroller, 'scrollTo', { configurable: true, value: scrollTo });
act(() => {
useDrawingStore.setState({
generations: [
{
...generationFixture('newer', 1),
status: 'running',
completed_at: null,
images: [],
},
],
});
});
scrollTo.mockClear();
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1300 });
act(() => {
resizeCallbacks.forEach((callback) => callback([], {} as ResizeObserver));
});
expect(scrollTo).toHaveBeenCalledWith({ top: 1300, behavior: 'smooth' });
requestAnimationFrameSpy.mockRestore();
vi.unstubAllGlobals();
});
it('scrolls to the bottom when a generation is deleted while already at the bottom', () => {
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
callback(0);
return 1;
});
const scrollTo = vi.fn();
render(<ContentArea activePage="drawing" />);
const scroller = screen.getByTestId('drawing-history-scroll');
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1500 });
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 600 });
Object.defineProperty(scroller, 'scrollTo', { configurable: true, value: scrollTo });
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('older', 1),
generationFixture('deleted-later', 2),
generationFixture('latest', 3),
],
});
});
scrollTo.mockClear();
scroller.scrollTop = 900;
fireEvent.scroll(scroller);
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 });
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('older', 1),
generationFixture('latest', 3),
],
});
});
expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth' });
requestAnimationFrameSpy.mockRestore();
});
it('does not scroll to the bottom when a generation is deleted while reading the middle', () => {
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
callback(0);
return 1;
});
const scrollTo = vi.fn();
render(<ContentArea activePage="drawing" />);
const scroller = screen.getByTestId('drawing-history-scroll');
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1500 });
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 600 });
Object.defineProperty(scroller, 'scrollTo', { configurable: true, value: scrollTo });
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('older', 1),
generationFixture('deleted-later', 2),
generationFixture('latest', 3),
],
});
});
scrollTo.mockClear();
scroller.scrollTop = 300;
fireEvent.scroll(scroller);
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 });
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('older', 1),
generationFixture('latest', 3),
],
});
});
expect(scrollTo).not.toHaveBeenCalled();
requestAnimationFrameSpy.mockRestore();
});
it('scrolls to the bottom when the latest generation is deleted', () => {
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
callback(0);
return 1;
});
const scrollTo = vi.fn();
render(<ContentArea activePage="drawing" />);
const scroller = screen.getByTestId('drawing-history-scroll');
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1800 });
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 600 });
Object.defineProperty(scroller, 'scrollTo', { configurable: true, value: scrollTo });
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('older', 1),
generationFixture('middle', 2),
generationFixture('latest-deleted', 3),
],
});
});
scrollTo.mockClear();
scroller.scrollTop = 500;
fireEvent.scroll(scroller);
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1200 });
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('older', 1),
generationFixture('middle', 2),
],
});
});
expect(scrollTo).toHaveBeenCalledWith({ top: 1200, behavior: 'smooth' });
requestAnimationFrameSpy.mockRestore();
});
it('keeps scrolling to the bottom after latest deletion layout settles', () => {
const animationFrames = createAnimationFrameQueue();
const scrollTo = vi.fn();
let scrollHeight = 1800;
render(<ContentArea activePage="drawing" />);
const scroller = screen.getByTestId('drawing-history-scroll');
Object.defineProperty(scroller, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 600 });
Object.defineProperty(scroller, 'scrollTo', { configurable: true, value: scrollTo });
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('older', 1),
generationFixture('middle', 2),
generationFixture('latest-deleted', 3),
],
});
});
animationFrames.flushAll();
scrollTo.mockClear();
scroller.scrollTop = 500;
fireEvent.scroll(scroller);
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('older', 1),
generationFixture('middle', 2),
],
});
});
animationFrames.flushNext();
scrollHeight = 1200;
animationFrames.flushAll();
expect(scrollTo).toHaveBeenCalledWith({ top: 1200, behavior: 'smooth' });
animationFrames.restore();
});
it('scrolls to the bottom when deletion leaves too little content below the viewport', () => {
const requestAnimationFrameSpy = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
callback(0);
return 1;
});
const scrollTo = vi.fn();
render(<ContentArea activePage="drawing" />);
const scroller = screen.getByTestId('drawing-history-scroll');
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1500 });
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 600 });
Object.defineProperty(scroller, 'scrollTo', { configurable: true, value: scrollTo });
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('older', 1),
generationFixture('deleted-later', 2),
generationFixture('latest', 3),
],
});
});
scrollTo.mockClear();
scroller.scrollTop = 420;
fireEvent.scroll(scroller);
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 });
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('older', 1),
generationFixture('latest', 3),
],
});
});
expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth' });
requestAnimationFrameSpy.mockRestore();
});
it('scrolls to the bottom when deletion later leaves too little content below the viewport', () => {
const animationFrames = createAnimationFrameQueue();
const scrollTo = vi.fn();
let scrollHeight = 1500;
render(<ContentArea activePage="drawing" />);
const scroller = screen.getByTestId('drawing-history-scroll');
Object.defineProperty(scroller, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 600 });
Object.defineProperty(scroller, 'scrollTo', { configurable: true, value: scrollTo });
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('older', 1),
generationFixture('deleted-later', 2),
generationFixture('latest', 3),
],
});
});
animationFrames.flushAll();
scrollTo.mockClear();
scroller.scrollTop = 420;
fireEvent.scroll(scroller);
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('older', 1),
generationFixture('latest', 3),
],
});
});
animationFrames.flushNext();
scrollHeight = 1000;
animationFrames.flushAll();
expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth' });
animationFrames.restore();
});
it('fills the composer from a clicked history prompt', () => {
render(<ContentArea activePage="drawing" />);
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('历史提示词', 1),
],
});
});
fireEvent.click(screen.getByRole('button', { name: '使用提示词' }));
expect(screen.getByPlaceholderText('输入你想生成的画面')).toHaveValue('历史提示词');
});
it('uploads pasted images as drawing references and prevents the default paste', async () => {
const uploadReferenceImage = vi.fn(async () => ({
id: 'ref-1',
original_name: 'pasted.png',
mime_type: 'image/png',
size_bytes: 3,
storage_path: 'images/pasted.png',
}));
useDrawingStore.setState({ uploadReferenceImage });
render(<ContentArea activePage="drawing" />);
const textarea = screen.getByPlaceholderText('输入你想生成的画面');
const file = new File(['png'], 'pasted.png', { type: 'image/png' });
const pasteEvent = createEvent.paste(textarea, {
clipboardData: {
items: [{
kind: 'file',
type: 'image/png',
getAsFile: () => file,
}],
},
});
const preventDefault = vi.spyOn(pasteEvent, 'preventDefault');
fireEvent(textarea, pasteEvent);
await waitFor(() => {
expect(uploadReferenceImage).toHaveBeenCalledWith(file);
});
expect(preventDefault).toHaveBeenCalled();
});
it('does not intercept text-only paste in the drawing composer', () => {
const uploadReferenceImage = vi.fn();
useDrawingStore.setState({ uploadReferenceImage });
render(<ContentArea activePage="drawing" />);
const textarea = screen.getByPlaceholderText('输入你想生成的画面');
const pasteEvent = createEvent.paste(textarea, {
clipboardData: {
items: [{
kind: 'string',
type: 'text/plain',
getAsFile: () => null,
}],
},
});
const preventDefault = vi.spyOn(pasteEvent, 'preventDefault');
fireEvent(textarea, pasteEvent);
expect(uploadReferenceImage).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
});
it('keeps drawing settings when switching away from the drawing page and back', () => {
const { rerender } = render(<ContentArea activePage="drawing" />);
act(() => {
useDrawingSettingsStore.getState().patchSettings({
size: '2048x2048',
quality: 'high',
n: 4,
});
});
rerender(<ContentArea activePage="chat" />);
rerender(<ContentArea activePage="drawing" />);
expect(screen.getByText('2048x2048')).toBeDefined();
expect(screen.getByText('High')).toBeDefined();
expect(screen.getByRole('spinbutton')).toHaveValue('4');
});
it('renders reference image transport in advanced settings with the official default', () => {
render(<ContentArea activePage="drawing" />);
fireEvent.click(screen.getByRole('button', { name: /高级设置/ }));
expect(screen.getByText('参考图发送方式')).toBeDefined();
expect(screen.getByText('Base64')).toBeDefined();
const text = document.body.textContent ?? '';
expect(text.indexOf('高级设置')).toBeLessThan(text.indexOf('参考图发送方式'));
});
it('does not clear a saved provider while providers are still loading', async () => {
const fetchProviders = vi.fn(() => new Promise<void>(() => {}));
useProviderStore.setState({ providers: [], fetchProviders });
useDrawingSettingsStore.getState().patchSettings({ providerId: 'provider-1' });
render(<ContentArea activePage="drawing" />);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(useDrawingSettingsStore.getState().settings.providerId).toBe('provider-1');
});
it('resizes the composer textarea by dragging the top handle upward', async () => {
render(<ContentArea activePage="drawing" />);
const handle = screen.getByTestId('drawing-composer-resize-handle');
const textarea = screen.getByPlaceholderText('输入你想生成的画面');
expect(textarea).toHaveStyle({ height: '72px' });
act(() => {
fireEvent.pointerDown(handle, { clientY: 500 });
});
act(() => {
fireEvent.pointerMove(window, { clientY: 420 });
});
await waitFor(() => {
expect(textarea).toHaveStyle({ height: '152px' });
});
act(() => {
fireEvent.pointerUp(window);
});
});
it('opens mask editor without entering composer edit mode until the mask is submitted', async () => {
const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => ({
clearRect: vi.fn(),
fillRect: vi.fn(),
beginPath: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
drawImage: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([0, 0, 0, 107]) })),
createImageData: vi.fn(() => ({ data: new Uint8ClampedArray(4) })),
putImageData: vi.fn(),
set fillStyle(_value: string) {},
set globalCompositeOperation(_value: string) {},
set globalAlpha(_value: number) {},
} as unknown as CanvasRenderingContext2D));
const toDataUrlSpy = vi
.spyOn(HTMLCanvasElement.prototype, 'toDataURL')
.mockReturnValue('data:image/png;base64,mask-data');
render(<ContentArea activePage="drawing" />);
act(() => {
useDrawingStore.setState({
generations: [
generationFixture('可区域编辑的历史图', 1, [imageFixture()]),
],
});
});
fireEvent.click(screen.getByRole('button', { name: '区域编辑' }));
expect(screen.getByRole('dialog')).toBeDefined();
expect(screen.getAllByText('区域编辑').length).toBeGreaterThanOrEqual(1);
expect(screen.queryByText('编辑模式')).toBeNull();
expect(screen.queryByText('区域编辑模式')).toBeNull();
expect(useDrawingStore.getState().editSourceImage).toBeNull();
expect(useDrawingStore.getState().editMaskFileId).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '提交区域编辑' }));
await waitFor(() => {
expect(screen.getByText('区域编辑模式')).toBeDefined();
});
expect(useDrawingStore.getState().editSourceImage?.id).toBe('image-1');
expect(useDrawingStore.getState().editMaskFileId).toBeTruthy();
expect(
useDrawingStore.getState().editPreviewUrl === null
|| useDrawingStore.getState().editPreviewUrl?.startsWith('data:'),
).toBe(true);
getContextSpy.mockRestore();
toDataUrlSpy.mockRestore();
});
});
@@ -0,0 +1,50 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GatewayPage } from '../GatewayPage';
const fetchRequestLogs = vi.fn();
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('@/stores/gatewayStore', () => ({
useGatewayStore: () => ({
fetchRequestLogs,
}),
}));
vi.mock('@/components/gateway', () => ({
GatewayOverview: ({ onViewMoreLogs }: { onViewMoreLogs?: () => void }) => (
<button type="button" onClick={onViewMoreLogs}>
gateway.viewMoreLogs
</button>
),
GatewayKeys: () => <div>gateway-keys-content</div>,
GatewayMetrics: () => <div>gateway-metrics-content</div>,
GatewaySettings: () => <div>gateway-settings-content</div>,
GatewayDiagnostics: () => <div>gateway-diagnostics-content</div>,
GatewayTemplates: () => <div>gateway-templates-content</div>,
QuickConnectCycleIcon: () => null,
}));
describe('GatewayPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('switches to the logs tab and refreshes logs when overview view more is clicked', async () => {
render(<GatewayPage />);
await userEvent.click(screen.getByRole('button', { name: 'gateway.viewMoreLogs' }));
await waitFor(() => {
expect(fetchRequestLogs).toHaveBeenCalledTimes(1);
});
expect(screen.getByText('gateway-diagnostics-content')).toBeInTheDocument();
});
});
@@ -0,0 +1,65 @@
import { act, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useUIStore } from '@/stores';
import { SettingsPage } from '../SettingsPage';
vi.mock('antd', () => ({
theme: {
useToken: () => ({
token: {
colorBgContainer: '#ffffff',
colorBgElevated: '#f8f8f8',
},
}),
},
}));
vi.mock('@/components/settings', () => ({
SettingsSidebar: () => <nav>settings-sidebar</nav>,
ProviderSettings: () => <div>providers-content</div>,
GeneralSettings: () => <div>general-content</div>,
DisplaySettings: () => <div>display-content</div>,
ProxySettings: () => <div>proxy-content</div>,
ShortcutSettings: () => <div>shortcuts-content</div>,
DataManager: () => <div>data-content</div>,
AboutPage: () => <div>about-content</div>,
SearchProviderSettings: () => <div>search-providers-content</div>,
McpServerSettings: () => <div>mcp-servers-content</div>,
BackupCenter: () => <div>backup-content</div>,
StorageSpaceManager: () => <div>storage-content</div>,
}));
vi.mock('@/components/settings/DefaultModelSettings', () => ({
DefaultModelSettings: () => <div>default-model-content</div>,
}));
vi.mock('@/components/settings/ConversationSettings', () => ({
ConversationSettings: () => <div>conversation-settings-content</div>,
}));
describe('SettingsPage', () => {
beforeEach(() => {
act(() => {
useUIStore.setState({
activePage: 'settings',
previousPage: 'chat',
settingsSection: 'general',
selectedProviderId: null,
});
});
});
it('resets the content scroll position when switching settings sections', () => {
render(<SettingsPage />);
const contentScroller = screen.getByText('general-content').parentElement as HTMLElement;
contentScroller.scrollTop = 480;
act(() => {
useUIStore.getState().setSettingsSection('display');
});
expect(screen.getByText('display-content')).toBeInTheDocument();
expect(contentScroller.scrollTop).toBe(0);
});
});