forked from boos/BoosAPI
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user