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,277 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { ConfigProvider, App as AntdApp, Layout, theme } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
import { TitleBar } from '@/components/layout/TitleBar';
|
||||
import { ContentArea } from '@/components/layout/ContentArea';
|
||||
import CommandPalette from '@/components/layout/CommandPalette';
|
||||
import { GlobalCopyMenu } from '@/components/layout/GlobalCopyMenu';
|
||||
import { useCommandPalette } from '@/hooks/useCommandPalette';
|
||||
import { useUIStore, useSettingsStore, useConversationStore } from '@/stores';
|
||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||
import { useGlobalShortcutManager } from '@/hooks/useGlobalShortcutManager';
|
||||
import { useResolvedDarkMode } from '@/hooks/useResolvedDarkMode';
|
||||
import { useGlobalOverlayScrollbars } from '@/hooks/useGlobalOverlayScrollbars';
|
||||
import { useUpdateChecker } from '@/hooks/useUpdateChecker';
|
||||
import { useProviderDeepLink } from '@/hooks/useProviderDeepLink';
|
||||
import { useShadcnTheme } from '@/theme/shadcnTheme';
|
||||
import { isTauri, invoke, listen } from '@/lib/invoke';
|
||||
import { preloadChatRenderers } from '@/lib/preloadChatRenderers';
|
||||
import { enableD2, setDefaultI18nMap } from 'markstream-react';
|
||||
import './i18n';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
const { useToken } = theme;
|
||||
const DEFAULT_CHAT_FONT_FAMILY = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
|
||||
/** Show the main window (it starts hidden to avoid white flash). */
|
||||
async function showWindow() {
|
||||
try {
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow');
|
||||
const window = getCurrentWebviewWindow();
|
||||
await window.show();
|
||||
await window.setFocus();
|
||||
} catch (e) {
|
||||
console.warn('Failed to show window:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function AppInner() {
|
||||
const { token } = useToken();
|
||||
const { t } = useTranslation();
|
||||
const { modal, message } = AntdApp.useApp();
|
||||
const activePage = useUIStore((s) => s.activePage);
|
||||
const { open: cmdOpen, setOpen: setCmdOpen } = useCommandPalette();
|
||||
const isInSettings = activePage === 'settings';
|
||||
useProviderDeepLink({ modal, message });
|
||||
|
||||
// Handle app close confirmation from backend
|
||||
const handleCloseRequested = useCallback(() => {
|
||||
modal.confirm({
|
||||
title: t('desktop.closeConfirmTitle'),
|
||||
content: t('desktop.closeConfirmContent'),
|
||||
okText: t('desktop.closeConfirmOk'),
|
||||
cancelText: t('desktop.closeConfirmCancel'),
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => invoke('force_quit'),
|
||||
});
|
||||
}, [modal, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
const unlisten = listen('app-close-requested', handleCloseRequested);
|
||||
return () => { unlisten.then((fn) => fn()); };
|
||||
}, [handleCloseRequested]);
|
||||
|
||||
// Sync Ant Design tokens to CSS custom properties for global usage
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--border-color', token.colorBorderSecondary);
|
||||
root.style.setProperty('--color-bg-container', token.colorBgContainer);
|
||||
root.style.setProperty('--color-bg-elevated', token.colorBgElevated);
|
||||
root.style.setProperty('--color-text', token.colorText);
|
||||
root.style.setProperty('--color-text-secondary', token.colorTextSecondary);
|
||||
root.style.setProperty('--color-primary', token.colorPrimary);
|
||||
root.style.setProperty('--color-fill-alter', token.colorFillAlter);
|
||||
// Markdown renderer (markstream-react) CSS variables
|
||||
root.style.setProperty('--table-border', token.colorBorderSecondary);
|
||||
root.style.setProperty('--hr-border-color', token.colorBorderSecondary);
|
||||
root.style.setProperty('--blockquote-border-color', token.colorBorderSecondary);
|
||||
}, [token]);
|
||||
|
||||
// Global stream event listeners — persist across page navigation
|
||||
const startStreamListening = useConversationStore((s) => s.startStreamListening);
|
||||
const stopStreamListening = useConversationStore((s) => s.stopStreamListening);
|
||||
useEffect(() => {
|
||||
startStreamListening();
|
||||
return () => stopStreamListening();
|
||||
}, [startStreamListening, stopStreamListening]);
|
||||
|
||||
// Auto-check for updates on startup and periodically
|
||||
const { checkForUpdate } = useUpdateChecker();
|
||||
const updateCheckInterval = useSettingsStore((s) => s.settings.update_check_interval ?? 60);
|
||||
const updateIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
// Initial check after 3s delay
|
||||
const timer = setTimeout(() => checkForUpdate({ silent: true }), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri() || !updateCheckInterval) return;
|
||||
if (updateIntervalRef.current) clearInterval(updateIntervalRef.current);
|
||||
const intervalMs = Math.max(updateCheckInterval, 1) * 60 * 1000;
|
||||
updateIntervalRef.current = setInterval(() => checkForUpdate({ silent: true }), intervalMs);
|
||||
return () => {
|
||||
if (updateIntervalRef.current) clearInterval(updateIntervalRef.current);
|
||||
};
|
||||
}, [updateCheckInterval, checkForUpdate]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen" style={{ backgroundColor: token.colorBgContainer }}>
|
||||
<TitleBar />
|
||||
<CommandPalette open={cmdOpen} onClose={() => setCmdOpen(false)} />
|
||||
<GlobalCopyMenu />
|
||||
<Layout className="flex-1 overflow-hidden" style={{ backgroundColor: 'transparent' }}>
|
||||
{!isInSettings && (
|
||||
<Sider
|
||||
width={48}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
borderRight: '1px solid var(--border-color)',
|
||||
}}
|
||||
>
|
||||
<Sidebar />
|
||||
</Sider>
|
||||
)}
|
||||
<Content className="overflow-hidden">
|
||||
<ContentArea activePage={activePage} />
|
||||
</Content>
|
||||
</Layout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppRoot() {
|
||||
const { i18n } = useTranslation();
|
||||
const themeMode = useSettingsStore((s) => s.settings.theme_mode);
|
||||
const primaryColor = useSettingsStore((s) => s.settings.primary_color);
|
||||
const fontSize = useSettingsStore((s) => s.settings.font_size);
|
||||
const fontWeight = useSettingsStore((s) => s.settings.font_weight);
|
||||
const fontFamily = useSettingsStore((s) => s.settings.font_family);
|
||||
const codeFontFamily = useSettingsStore((s) => s.settings.code_font_family);
|
||||
const chatFontSize = useSettingsStore((s) => s.settings.chat_font_size);
|
||||
const chatLineHeight = useSettingsStore((s) => s.settings.chat_line_height);
|
||||
const chatFontFamily = useSettingsStore((s) => s.settings.chat_font_family);
|
||||
const chatFontWeight = useSettingsStore((s) => s.settings.chat_font_weight);
|
||||
const borderRadius = useSettingsStore((s) => s.settings.border_radius);
|
||||
const language = useSettingsStore((s) => s.settings.language);
|
||||
const isDark = useResolvedDarkMode(themeMode);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = isDark ? 'dark' : 'light';
|
||||
}, [isDark]);
|
||||
|
||||
useEffect(() => {
|
||||
enableD2(() => import('@terrastruct/d2'));
|
||||
void preloadChatRenderers();
|
||||
}, []);
|
||||
|
||||
useKeyboardShortcuts();
|
||||
useGlobalShortcutManager();
|
||||
useGlobalOverlayScrollbars();
|
||||
|
||||
// Load persisted settings from backend on startup, then apply native settings
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
await useSettingsStore.getState().fetchSettings();
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch settings:', e);
|
||||
}
|
||||
|
||||
if (!isTauri()) return;
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
|
||||
// Apply native window settings
|
||||
try {
|
||||
await invoke('apply_startup_settings', {
|
||||
alwaysOnTop: settings.always_on_top ?? false,
|
||||
closeToTray: settings.minimize_to_tray ?? false,
|
||||
releaseWebviewOnTray: settings.release_webview_on_tray ?? false,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to apply native settings:', e);
|
||||
}
|
||||
|
||||
// Autostart
|
||||
try {
|
||||
const { enable, disable } = await import('@tauri-apps/plugin-autostart');
|
||||
if (settings.auto_start) {
|
||||
await enable();
|
||||
} else {
|
||||
await disable();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to set autostart:', e);
|
||||
}
|
||||
|
||||
// Show window after initialization (window starts hidden to avoid white flash)
|
||||
await showWindow();
|
||||
};
|
||||
init();
|
||||
}, []);
|
||||
|
||||
// Sync i18n language with settings store
|
||||
useEffect(() => {
|
||||
if (i18n.language !== language) {
|
||||
i18n.changeLanguage(language);
|
||||
}
|
||||
}, [i18n, language]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = i18n.getFixedT(i18n.language);
|
||||
setDefaultI18nMap({
|
||||
'common.close': t('common.close'),
|
||||
'common.collapse': t('common.collapse'),
|
||||
'common.copied': t('common.copied'),
|
||||
'common.copy': t('common.copy'),
|
||||
'common.decrease': t('common.decrease'),
|
||||
'common.expand': t('common.expand'),
|
||||
'common.export': t('common.export'),
|
||||
'common.increase': t('common.increase'),
|
||||
'common.minimize': t('common.minimize'),
|
||||
'common.open': t('common.open'),
|
||||
'common.preview': t('common.preview'),
|
||||
'common.reset': t('common.reset'),
|
||||
'common.resetZoom': t('common.resetZoom'),
|
||||
'common.source': t('common.source'),
|
||||
'common.zoomIn': t('common.zoomIn'),
|
||||
'common.zoomOut': t('common.zoomOut'),
|
||||
'image.loadError': t('image.loadError'),
|
||||
'image.loading': t('image.loading'),
|
||||
});
|
||||
}, [i18n, i18n.language]);
|
||||
|
||||
// Sync font settings to CSS custom properties
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--font-weight', String(fontWeight));
|
||||
if (fontFamily) {
|
||||
root.style.setProperty('--font-family', fontFamily);
|
||||
document.body.style.fontFamily = fontFamily;
|
||||
} else {
|
||||
root.style.removeProperty('--font-family');
|
||||
document.body.style.removeProperty('font-family');
|
||||
}
|
||||
if (codeFontFamily) {
|
||||
root.style.setProperty('--code-font-family', codeFontFamily);
|
||||
} else {
|
||||
root.style.removeProperty('--code-font-family');
|
||||
}
|
||||
root.style.setProperty('--chat-font-size', `${chatFontSize ?? 15}px`);
|
||||
root.style.setProperty('--chat-line-height', String(chatLineHeight ?? 1.7));
|
||||
root.style.setProperty('--chat-font-family', chatFontFamily || DEFAULT_CHAT_FONT_FAMILY);
|
||||
root.style.setProperty('--chat-font-weight', String(chatFontWeight ?? 400));
|
||||
}, [fontWeight, fontFamily, codeFontFamily, chatFontSize, chatLineHeight, chatFontFamily, chatFontWeight]);
|
||||
|
||||
const themeConfig = useShadcnTheme(isDark, primaryColor, fontSize, borderRadius, fontFamily || undefined, codeFontFamily || undefined);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={i18n.language === 'zh-CN' ? zhCN : undefined}
|
||||
theme={themeConfig}
|
||||
modal={{ centered: true, styles: { mask: { backdropFilter: 'blur(4px)' } } }}
|
||||
>
|
||||
<AntdApp>
|
||||
<AppInner />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppRoot;
|
||||
@@ -0,0 +1,188 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const enableD2 = vi.fn();
|
||||
const preloadChatRenderers = vi.fn();
|
||||
const setDefaultI18nMap = vi.fn();
|
||||
|
||||
const settingsState = {
|
||||
settings: {
|
||||
theme_mode: 'dark',
|
||||
primary_color: '#17A93D',
|
||||
font_size: 14,
|
||||
font_weight: 400,
|
||||
font_family: '',
|
||||
code_font_family: '',
|
||||
chat_font_size: 16,
|
||||
chat_line_height: 1.8,
|
||||
chat_font_family: 'Inter',
|
||||
chat_font_weight: 500,
|
||||
border_radius: 8,
|
||||
language: 'zh-CN',
|
||||
always_on_top: false,
|
||||
close_to_tray: true,
|
||||
auto_start: false,
|
||||
global_shortcuts_enabled: false,
|
||||
shortcut_registration_logs_enabled: false,
|
||||
shortcut_trigger_toast_enabled: false,
|
||||
global_shortcut: '',
|
||||
},
|
||||
fetchSettings: vi.fn().mockResolvedValue(undefined),
|
||||
setGlobalShortcutStatus: vi.fn(),
|
||||
};
|
||||
|
||||
const uiState = {
|
||||
activePage: 'chat',
|
||||
enterSettings: vi.fn(),
|
||||
setSettingsSection: vi.fn(),
|
||||
setSelectedProviderId: vi.fn(),
|
||||
};
|
||||
|
||||
const providerState = {
|
||||
importProviderFromDeepLink: vi.fn(),
|
||||
fetchProviders: vi.fn(),
|
||||
};
|
||||
|
||||
const conversationState = {
|
||||
startStreamListening: vi.fn(),
|
||||
stopStreamListening: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
ConfigProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
App: Object.assign(
|
||||
({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
{
|
||||
useApp: () => ({
|
||||
modal: {},
|
||||
message: {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
Layout: Object.assign(
|
||||
({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
{
|
||||
Sider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Content: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
},
|
||||
),
|
||||
theme: {
|
||||
useToken: () => ({
|
||||
token: {
|
||||
colorBorderSecondary: '#444',
|
||||
colorBgContainer: '#111',
|
||||
colorBgElevated: '#1a1a1a',
|
||||
colorText: '#f5f5f5',
|
||||
colorTextSecondary: '#999',
|
||||
colorPrimary: '#1677ff',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('antd/locale/zh_CN', () => ({
|
||||
default: {},
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: () => {},
|
||||
},
|
||||
useTranslation: () => ({
|
||||
i18n: {
|
||||
language: 'zh-CN',
|
||||
getFixedT: () => (_key: string) => _key,
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/Sidebar', () => ({
|
||||
Sidebar: () => <div>sidebar</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/TitleBar', () => ({
|
||||
TitleBar: () => <div>titlebar</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/ContentArea', () => ({
|
||||
ContentArea: () => <div>content</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/CommandPalette', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useCommandPalette', () => ({
|
||||
useCommandPalette: () => ({
|
||||
open: false,
|
||||
setOpen: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useUIStore: (selector: (state: typeof uiState) => unknown) => selector(uiState),
|
||||
useProviderStore: (selector: (state: typeof providerState) => unknown) => selector(providerState),
|
||||
useConversationStore: (selector: (state: typeof conversationState) => unknown) => selector(conversationState),
|
||||
useSettingsStore: Object.assign(
|
||||
(selector: (state: typeof settingsState) => unknown) => selector(settingsState),
|
||||
{
|
||||
getState: () => settingsState,
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useKeyboardShortcuts', () => ({
|
||||
useKeyboardShortcuts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useResolvedDarkMode', () => ({
|
||||
useResolvedDarkMode: () => true,
|
||||
}));
|
||||
|
||||
vi.mock('@/theme/shadcnTheme', () => ({
|
||||
useShadcnTheme: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/invoke', () => ({
|
||||
isTauri: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/preloadChatRenderers', () => ({
|
||||
preloadChatRenderers,
|
||||
}));
|
||||
|
||||
vi.mock('markstream-react', () => ({
|
||||
enableD2,
|
||||
setDefaultI18nMap,
|
||||
}));
|
||||
|
||||
describe('AppRoot D2 setup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.documentElement.style.cssText = '';
|
||||
});
|
||||
|
||||
it('enables the markstream D2 loader during startup', async () => {
|
||||
const { default: AppRoot } = await import('../App');
|
||||
|
||||
render(<AppRoot />);
|
||||
|
||||
expect(enableD2).toHaveBeenCalledTimes(1);
|
||||
expect(preloadChatRenderers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('syncs chat typography settings to CSS variables', async () => {
|
||||
const { default: AppRoot } = await import('../App');
|
||||
|
||||
render(<AppRoot />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.style.getPropertyValue('--chat-font-size')).toBe('16px');
|
||||
expect(document.documentElement.style.getPropertyValue('--chat-line-height')).toBe('1.8');
|
||||
expect(document.documentElement.style.getPropertyValue('--chat-font-family')).toBe('Inter');
|
||||
expect(document.documentElement.style.getPropertyValue('--chat-font-weight')).toBe('500');
|
||||
});
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
@@ -0,0 +1,41 @@
|
||||
import { Card, Empty, Tag, Typography } from 'antd';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ArtifactPanelProps {
|
||||
artifact?: {
|
||||
id: string;
|
||||
title: string;
|
||||
kind: string;
|
||||
content: string;
|
||||
format: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ArtifactPanel({ artifact }: ArtifactPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!artifact) {
|
||||
return <Empty description={t('chat.artifacts.empty')} style={{ marginTop: 32 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<FileText size={16} />
|
||||
<span>{artifact.title}</span>
|
||||
<Tag>{artifact.kind}</Tag>
|
||||
</div>
|
||||
}
|
||||
size="small"
|
||||
>
|
||||
<Typography.Paragraph ellipsis={{ rows: 6, expandable: true, symbol: '...' }}>
|
||||
{artifact.content}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('chat.artifacts.comingSoon')}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, Input, Button, Space, Typography, Checkbox, theme } from 'antd';
|
||||
import { MessageCircleQuestion, CheckCircle2 } from 'lucide-react';
|
||||
import { useAgentStore } from '@/stores';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface AskUserCardProps {
|
||||
askId: string;
|
||||
conversationId: string;
|
||||
question: string;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
const AskUserCard: React.FC<AskUserCardProps> = ({ askId, question, options }) => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const [answer, setAnswer] = useState('');
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const respondAskUser = useAgentStore((s) => s.respondAskUser);
|
||||
|
||||
const hasOptions = options && options.length > 0;
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (hasOptions) return selectedOptions.length > 0 || answer.trim().length > 0;
|
||||
return answer.trim().length > 0;
|
||||
}, [hasOptions, selectedOptions, answer]);
|
||||
|
||||
const handleToggleOption = (opt: string) => {
|
||||
setSelectedOptions((prev) =>
|
||||
prev.includes(opt) ? prev.filter((o) => o !== opt) : [...prev, opt],
|
||||
);
|
||||
};
|
||||
|
||||
const buildAnswer = (): string => {
|
||||
const parts: string[] = [];
|
||||
if (selectedOptions.length > 0) {
|
||||
parts.push(selectedOptions.join(', '));
|
||||
}
|
||||
if (answer.trim()) {
|
||||
parts.push(answer.trim());
|
||||
}
|
||||
return parts.join('\n');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const finalAnswer = buildAnswer();
|
||||
if (!finalAnswer || submitting || submitted) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await respondAskUser(askId, finalAnswer);
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const questionLines = question.split('\n');
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
marginTop: 8,
|
||||
borderColor: token.colorPrimary,
|
||||
opacity: submitted ? 0.7 : 1,
|
||||
transition: 'opacity 0.3s',
|
||||
}}
|
||||
styles={{ body: { padding: '12px 16px' } }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={8}>
|
||||
<Space size={8} align="start">
|
||||
<MessageCircleQuestion
|
||||
size={16}
|
||||
style={{ color: token.colorPrimary, flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
<Text style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{questionLines.map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <br />}
|
||||
{line}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
{hasOptions && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, paddingLeft: 24 }}>
|
||||
{options!.map((opt) => (
|
||||
<Checkbox
|
||||
key={opt}
|
||||
checked={selectedOptions.includes(opt)}
|
||||
disabled={submitting || submitted}
|
||||
onChange={() => handleToggleOption(opt)}
|
||||
style={{ marginLeft: 0 }}
|
||||
>
|
||||
{opt}
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TextArea
|
||||
value={answer}
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
placeholder={
|
||||
hasOptions
|
||||
? t('agent.askUserSupplementPlaceholder', 'Additional input (optional)...')
|
||||
: t('agent.askUserPlaceholder', 'Type your answer...')
|
||||
}
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
disabled={submitting || submitted}
|
||||
style={{ borderColor: token.colorPrimary }}
|
||||
onPressEnter={(e) => {
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
{submitted ? (
|
||||
<Space size={4} style={{ color: token.colorSuccess }}>
|
||||
<CheckCircle2 size={14} />
|
||||
<Text style={{ color: token.colorSuccess, fontSize: 13 }}>
|
||||
{t('agent.askUserSubmitted', 'Submitted')}
|
||||
</Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{t('agent.askUserSubmit', 'Submit')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AskUserCard;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Card, Row, Col, Typography, Tag, Empty } from 'antd';
|
||||
import { GitBranch } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface BranchMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
model?: string;
|
||||
created_at?: number;
|
||||
}
|
||||
|
||||
interface BranchComparePanelProps {
|
||||
leftMessage?: BranchMessage;
|
||||
rightMessage?: BranchMessage;
|
||||
}
|
||||
|
||||
function formatTimestamp(ts?: number): string {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function MessageCard({ message, side }: { message?: BranchMessage; side: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!message) {
|
||||
return (
|
||||
<Card size="small" style={{ height: '100%' }}>
|
||||
<Empty description={t('common.noData')} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Tag>{side}</Tag>
|
||||
{message.model && <Typography.Text>{message.model}</Typography.Text>}
|
||||
{message.created_at && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatTimestamp(message.created_at)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>
|
||||
{message.content}
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function BranchComparePanel({ leftMessage, rightMessage }: BranchComparePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!leftMessage && !rightMessage) {
|
||||
return <Empty description={t('chat.branch.compare')} style={{ marginTop: 32 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<GitBranch size={16} />
|
||||
<Typography.Text>{t('chat.branch.compare')}</Typography.Text>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<MessageCard message={leftMessage} side={t('chat.branch.left')} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<MessageCard message={rightMessage} side={t('chat.branch.right')} />
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Modal, Input, Avatar, theme, Divider, Typography } from 'antd';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconEditor } from '@/components/shared/IconEditor';
|
||||
import { ModelSelect, parseModelValue } from '@/components/shared/ModelSelect';
|
||||
import { ModelParamSliders } from '@/components/common/ModelParamSliders';
|
||||
import { useProviderStore, useSettingsStore } from '@/stores';
|
||||
import { findModelByIds } from '@/lib/modelCapabilities';
|
||||
import { resolveModelParamDefaults } from '@/lib/modelParams';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export interface CategoryEditFormData {
|
||||
name: string;
|
||||
icon_type: string | null;
|
||||
icon_value: string | null;
|
||||
system_prompt: string | null;
|
||||
default_provider_id: string | null;
|
||||
default_model_id: string | null;
|
||||
default_temperature: number | null;
|
||||
default_max_tokens: number | null;
|
||||
default_top_p: number | null;
|
||||
default_frequency_penalty: number | null;
|
||||
}
|
||||
|
||||
interface CategoryEditModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onOk: (data: CategoryEditFormData) => void;
|
||||
initialName?: string;
|
||||
initialIconType?: string | null;
|
||||
initialIconValue?: string | null;
|
||||
initialSystemPrompt?: string | null;
|
||||
initialDefaultProviderId?: string | null;
|
||||
initialDefaultModelId?: string | null;
|
||||
initialDefaultTemperature?: number | null;
|
||||
initialDefaultMaxTokens?: number | null;
|
||||
initialDefaultTopP?: number | null;
|
||||
initialDefaultFrequencyPenalty?: number | null;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function CategoryEditModal({
|
||||
open,
|
||||
onClose,
|
||||
onOk,
|
||||
initialName = '',
|
||||
initialIconType = null,
|
||||
initialIconValue = null,
|
||||
initialSystemPrompt = null,
|
||||
initialDefaultProviderId = null,
|
||||
initialDefaultModelId = null,
|
||||
initialDefaultTemperature = null,
|
||||
initialDefaultMaxTokens = null,
|
||||
initialDefaultTopP = null,
|
||||
initialDefaultFrequencyPenalty = null,
|
||||
title,
|
||||
}: CategoryEditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const providers = useProviderStore((s) => s.providers);
|
||||
const [name, setName] = useState(initialName);
|
||||
const [iconType, setIconType] = useState<string | null>(initialIconType);
|
||||
const [iconValue, setIconValue] = useState<string | null>(initialIconValue);
|
||||
const [systemPrompt, setSystemPrompt] = useState(initialSystemPrompt ?? '');
|
||||
const [defaultProviderId, setDefaultProviderId] = useState<string | null>(initialDefaultProviderId);
|
||||
const [defaultModelId, setDefaultModelId] = useState<string | null>(initialDefaultModelId);
|
||||
const [defaultTemperature, setDefaultTemperature] = useState<number | null>(initialDefaultTemperature);
|
||||
const [defaultMaxTokens, setDefaultMaxTokens] = useState<number | null>(initialDefaultMaxTokens);
|
||||
const [defaultTopP, setDefaultTopP] = useState<number | null>(initialDefaultTopP);
|
||||
const [defaultFrequencyPenalty, setDefaultFrequencyPenalty] = useState<number | null>(initialDefaultFrequencyPenalty);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initialName);
|
||||
setIconType(initialIconType ?? null);
|
||||
setIconValue(initialIconValue ?? null);
|
||||
setSystemPrompt(initialSystemPrompt ?? '');
|
||||
setDefaultProviderId(initialDefaultProviderId ?? null);
|
||||
setDefaultModelId(initialDefaultModelId ?? null);
|
||||
setDefaultTemperature(initialDefaultTemperature ?? null);
|
||||
setDefaultMaxTokens(initialDefaultMaxTokens ?? null);
|
||||
setDefaultTopP(initialDefaultTopP ?? null);
|
||||
setDefaultFrequencyPenalty(initialDefaultFrequencyPenalty ?? null);
|
||||
}
|
||||
}, [
|
||||
open,
|
||||
initialName,
|
||||
initialIconType,
|
||||
initialIconValue,
|
||||
initialSystemPrompt,
|
||||
initialDefaultProviderId,
|
||||
initialDefaultModelId,
|
||||
initialDefaultTemperature,
|
||||
initialDefaultMaxTokens,
|
||||
initialDefaultTopP,
|
||||
initialDefaultFrequencyPenalty,
|
||||
]);
|
||||
|
||||
const selectedModelValue = defaultProviderId && defaultModelId
|
||||
? `${defaultProviderId}::${defaultModelId}`
|
||||
: undefined;
|
||||
const modelParamDefaults = useMemo(() => {
|
||||
const model = findModelByIds(providers, defaultProviderId, defaultModelId);
|
||||
return resolveModelParamDefaults(model, settings);
|
||||
}, [providers, defaultProviderId, defaultModelId, settings]);
|
||||
|
||||
const handleDefaultModelChange = (value: string | undefined) => {
|
||||
const parsed = parseModelValue(value);
|
||||
setDefaultProviderId(parsed?.providerId ?? null);
|
||||
setDefaultModelId(parsed?.modelId ?? null);
|
||||
};
|
||||
|
||||
const handleOk = () => {
|
||||
if (!name.trim()) return;
|
||||
onOk({
|
||||
name: name.trim(),
|
||||
icon_type: iconType,
|
||||
icon_value: iconValue,
|
||||
system_prompt: systemPrompt.trim() || null,
|
||||
default_provider_id: defaultProviderId,
|
||||
default_model_id: defaultModelId,
|
||||
default_temperature: defaultTemperature,
|
||||
default_max_tokens: defaultMaxTokens,
|
||||
default_top_p: defaultTopP,
|
||||
default_frequency_penalty: defaultFrequencyPenalty,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title ?? t('chat.createCategory')}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={handleOk}
|
||||
okButtonProps={{ disabled: !name.trim() }}
|
||||
destroyOnClose
|
||||
width={560}
|
||||
mask={{ enabled: true, blur: true }}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3 py-3">
|
||||
<IconEditor
|
||||
iconType={iconType}
|
||||
iconValue={iconValue}
|
||||
onChange={(type, value) => { setIconType(type); setIconValue(value); }}
|
||||
size={40}
|
||||
defaultIcon={
|
||||
<Avatar
|
||||
size={40}
|
||||
icon={<FolderOpen size={18} />}
|
||||
style={{ cursor: 'pointer', backgroundColor: token.colorFillSecondary, color: token.colorTextSecondary }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder={t('chat.categoryNamePlaceholder')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onPressEnter={handleOk}
|
||||
autoFocus
|
||||
style={{ maxWidth: 340 }}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
placeholder={t('chat.categorySystemPromptPlaceholder', 'System Prompt(分类下的对话将继承此提示词)')}
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
autoSize={{ minRows: 5, maxRows: 10 }}
|
||||
style={{ maxWidth: 340 }}
|
||||
/>
|
||||
|
||||
<Divider style={{ margin: '4px 0 0' }} />
|
||||
|
||||
<div style={{ width: '100%', maxWidth: 420 }}>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
{t('settings.defaultConversationModel')}
|
||||
</Typography.Text>
|
||||
<ModelSelect
|
||||
value={selectedModelValue}
|
||||
onChange={handleDefaultModelChange}
|
||||
placeholder={t('settings.useActiveModel')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ width: '100%', maxWidth: 420 }}>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
{t('settings.modelParams')}
|
||||
</Typography.Text>
|
||||
<ModelParamSliders
|
||||
values={{
|
||||
temperature: defaultTemperature,
|
||||
topP: defaultTopP,
|
||||
maxTokens: defaultMaxTokens,
|
||||
frequencyPenalty: defaultFrequencyPenalty,
|
||||
}}
|
||||
onChange={(values) => {
|
||||
if ('temperature' in values) setDefaultTemperature(values.temperature ?? null);
|
||||
if ('topP' in values) setDefaultTopP(values.topP ?? null);
|
||||
if ('maxTokens' in values) setDefaultMaxTokens(values.maxTokens ?? null);
|
||||
if ('frequencyPenalty' in values) setDefaultFrequencyPenalty(values.frequencyPenalty ?? null);
|
||||
}}
|
||||
defaults={{
|
||||
temperature: modelParamDefaults.temperature,
|
||||
topP: modelParamDefaults.topP,
|
||||
maxTokens: modelParamDefaults.maxTokens,
|
||||
frequencyPenalty: modelParamDefaults.frequencyPenalty,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, App, Button, Image as AntImage, Spin, Tooltip, Typography, theme } from 'antd';
|
||||
import { Check, Copy, Download } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
copyChatImage,
|
||||
getDefaultImageFilename,
|
||||
saveChatImage,
|
||||
} from '@/lib/chatImageActions';
|
||||
|
||||
type ChatImageNodeData = {
|
||||
type: 'image' | 'img';
|
||||
src?: string;
|
||||
alt?: string | null;
|
||||
title?: string | null;
|
||||
raw?: string;
|
||||
attrs?:
|
||||
| Record<string, string | boolean>
|
||||
| [string, string][]
|
||||
| Array<{ name: string; value: string | boolean }>;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
type MarkdownImageProps = {
|
||||
node: ChatImageNodeData;
|
||||
};
|
||||
|
||||
type HtmlImageProps = {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type ChatImageNodeProps = MarkdownImageProps | HtmlImageProps;
|
||||
|
||||
function isMarkdownImageProps(props: ChatImageNodeProps): props is MarkdownImageProps {
|
||||
return 'node' in props;
|
||||
}
|
||||
|
||||
function getNodeAttr(node: ChatImageNodeData, name: string) {
|
||||
const attrs = node.attrs;
|
||||
if (!attrs) return undefined;
|
||||
|
||||
if (Array.isArray(attrs)) {
|
||||
for (const attr of attrs) {
|
||||
if (Array.isArray(attr)) {
|
||||
const [attrName, value] = attr;
|
||||
if (attrName === name) return value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attr.name === name && typeof attr.value === 'string') {
|
||||
return attr.value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = attrs[name];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeImageProps(props: ChatImageNodeProps) {
|
||||
if (isMarkdownImageProps(props)) {
|
||||
const src = props.node.src ?? getNodeAttr(props.node, 'src') ?? '';
|
||||
const alt = props.node.alt ?? getNodeAttr(props.node, 'alt') ?? '';
|
||||
const title = props.node.title ?? getNodeAttr(props.node, 'title') ?? alt;
|
||||
return {
|
||||
src,
|
||||
alt,
|
||||
title,
|
||||
raw: props.node.raw ?? '',
|
||||
loading: Boolean(props.node.loading),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
src: props.src ?? '',
|
||||
alt: props.alt ?? '',
|
||||
title: props.title ?? props.alt ?? '',
|
||||
raw: props.src ?? '',
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function ChatImageNode(props: ChatImageNodeProps) {
|
||||
const { token } = theme.useToken();
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
const { src, alt, title, raw, loading } = normalizeImageProps(props);
|
||||
const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>(() => (
|
||||
src || loading ? 'loading' : 'error'
|
||||
));
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setStatus(src || loading ? 'loading' : 'error');
|
||||
setCopied(false);
|
||||
}, [src, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const timer = window.setTimeout(() => setCopied(false), 1600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [copied]);
|
||||
|
||||
const filename = useMemo(() => getDefaultImageFilename(src, alt), [alt, src]);
|
||||
const sourceLabel = src || raw || t('chat.imageMissingSource', '图片链接为空');
|
||||
const altText = alt || title || t('chat.imagePreview', '图片预览');
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!src || status !== 'loaded') return;
|
||||
setCopying(true);
|
||||
try {
|
||||
await copyChatImage(src);
|
||||
setCopied(true);
|
||||
message.success(t('chat.imageCopySuccess', '图片已复制'));
|
||||
} catch (error) {
|
||||
console.error('copy chat image failed:', error);
|
||||
message.error(t('chat.imageCopyFailed', '复制图片失败'));
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
}, [message, src, status, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!src || status !== 'loaded') return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await saveChatImage(src, filename);
|
||||
if (saved) {
|
||||
message.success(t('chat.imageSaveSuccess', '图片已保存'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('save chat image failed:', error);
|
||||
message.error(t('chat.imageSaveFailed', '保存图片失败'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [filename, message, src, status, t]);
|
||||
|
||||
const shellStyle: React.CSSProperties = {
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
maxWidth: '100%',
|
||||
verticalAlign: 'top',
|
||||
margin: '6px 0',
|
||||
};
|
||||
|
||||
const loadingStyle: React.CSSProperties = {
|
||||
minWidth: 220,
|
||||
minHeight: 132,
|
||||
maxWidth: '100%',
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRadius: token.borderRadius,
|
||||
background: token.colorFillQuaternary,
|
||||
color: token.colorTextSecondary,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
};
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<span style={shellStyle}>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('chat.imageLoadFailed', '图片加载失败')}
|
||||
description={(
|
||||
<Typography.Text
|
||||
copyable={src ? { text: src } : false}
|
||||
style={{
|
||||
display: 'block',
|
||||
maxWidth: 520,
|
||||
maxHeight: 120,
|
||||
overflow: 'auto',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{sourceLabel}
|
||||
</Typography.Text>
|
||||
)}
|
||||
style={{ maxWidth: 560, textAlign: 'left' }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span style={shellStyle}>
|
||||
{status === 'loading' && (
|
||||
<span style={loadingStyle}>
|
||||
<Spin size="small" />
|
||||
<Typography.Text type="secondary">
|
||||
{t('chat.imageLoading', '图片加载中...')}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{status === 'loading' && src && (
|
||||
<img
|
||||
src={src}
|
||||
alt={altText}
|
||||
onLoad={() => setStatus('loaded')}
|
||||
onError={() => setStatus('error')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === 'loaded' && (
|
||||
<>
|
||||
<AntImage
|
||||
src={src}
|
||||
alt={altText}
|
||||
title={title || undefined}
|
||||
style={{
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: 520,
|
||||
borderRadius: token.borderRadius,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
preview={{ mask: { blur: true }, scaleStep: 0.5 }}
|
||||
onError={() => setStatus('error')}
|
||||
/>
|
||||
<span
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
display: 'inline-flex',
|
||||
gap: 4,
|
||||
padding: 3,
|
||||
borderRadius: token.borderRadiusSM,
|
||||
background: token.colorBgElevated,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<Tooltip title={copied ? t('common.copied') : t('chat.copyImage', '复制图片')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label={t('chat.copyImage', '复制图片')}
|
||||
icon={copied ? <Check size={14} style={{ color: token.colorSuccess }} /> : <Copy size={14} />}
|
||||
loading={copying}
|
||||
onClick={handleCopy}
|
||||
style={{ width: 26, height: 26, padding: 0 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('chat.saveImage', '保存图片')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label={t('chat.saveImage', '保存图片')}
|
||||
icon={<Download size={14} />}
|
||||
loading={saving}
|
||||
onClick={handleSave}
|
||||
style={{ width: 26, height: 26, padding: 0 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Tabs, Empty, List, Descriptions, Tag, Typography, theme } from 'antd';
|
||||
import { Search, Wrench, Paperclip, Info, FileText } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useConversationStore, useArtifactStore } from '@/stores';
|
||||
|
||||
interface ChatInspectorProps {
|
||||
visible: boolean;
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
conversationId: string | null;
|
||||
}
|
||||
|
||||
export function ChatInspector({
|
||||
visible,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
conversationId,
|
||||
}: ChatInspectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const conversation = useConversationStore((s) =>
|
||||
s.conversations.find((c) => c.id === s.activeConversationId),
|
||||
);
|
||||
const workspaceSnapshot = useConversationStore((s) => s.workspaceSnapshot);
|
||||
const messages = useConversationStore((s) => s.messages);
|
||||
const { artifacts } = useArtifactStore();
|
||||
|
||||
const contextSources = useMemo(() => {
|
||||
if (!workspaceSnapshot) return [];
|
||||
const sources: { type: string; title: string }[] = [];
|
||||
if (workspaceSnapshot.knowledgeBinding?.knowledgeBaseIds?.length) {
|
||||
workspaceSnapshot.knowledgeBinding.knowledgeBaseIds.forEach((id) =>
|
||||
sources.push({ type: 'knowledge', title: id }),
|
||||
);
|
||||
}
|
||||
if (workspaceSnapshot.searchPolicy?.enabled) {
|
||||
sources.push({ type: 'search', title: workspaceSnapshot.searchPolicy.searchProviderId ?? 'search' });
|
||||
}
|
||||
if (workspaceSnapshot.memoryPolicy?.enabled) {
|
||||
sources.push({ type: 'memory', title: workspaceSnapshot.memoryPolicy.namespaceId ?? 'memory' });
|
||||
}
|
||||
if (workspaceSnapshot.toolBinding?.serverIds?.length) {
|
||||
workspaceSnapshot.toolBinding.serverIds.forEach((id) =>
|
||||
sources.push({ type: 'tool', title: id }),
|
||||
);
|
||||
}
|
||||
return sources;
|
||||
}, [workspaceSnapshot]);
|
||||
|
||||
const toolCalls = useMemo(() => {
|
||||
return messages
|
||||
.filter((m) => m.role === 'assistant' && m.content)
|
||||
.flatMap((m) => {
|
||||
const calls: { name: string; messageId: string }[] = [];
|
||||
const regex = /tool_call|function_call|<tool>(.*?)<\/tool>/g;
|
||||
if (regex.test(m.content)) {
|
||||
calls.push({ name: 'tool_call', messageId: m.id });
|
||||
}
|
||||
return calls;
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const conversationArtifacts = useMemo(() => {
|
||||
if (!conversationId) return [];
|
||||
return artifacts.filter((a) => a.conversationId === conversationId);
|
||||
}, [artifacts, conversationId]);
|
||||
|
||||
const tabItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'sources',
|
||||
label: t('chat.inspector.sources'),
|
||||
icon: <Search size={14} />,
|
||||
children: contextSources.length > 0 ? (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={contextSources}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={<Typography.Text>{item.title}</Typography.Text>}
|
||||
description={<Tag>{item.type}</Tag>}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
description={conversationId ? t('common.noData') : t('common.noData')}
|
||||
style={{ marginTop: 48 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tools',
|
||||
label: t('chat.inspector.tools'),
|
||||
icon: <Wrench size={14} />,
|
||||
children: toolCalls.length > 0 ? (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={toolCalls}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<Typography.Text code>{item.name}</Typography.Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
description={t('chat.inspector.tools')}
|
||||
style={{ marginTop: 48 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attachments',
|
||||
label: t('chat.inspector.attachments'),
|
||||
icon: <Paperclip size={14} />,
|
||||
children: (() => {
|
||||
const attachments = messages.flatMap((m) => m.attachments ?? []);
|
||||
return attachments.length > 0 ? (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={attachments}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<Typography.Text ellipsis>{item.file_name}</Typography.Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
description={t('chat.inspector.attachments')}
|
||||
style={{ marginTop: 48 }}
|
||||
/>
|
||||
);
|
||||
})(),
|
||||
},
|
||||
{
|
||||
key: 'session',
|
||||
label: t('chat.inspector.session'),
|
||||
icon: <Info size={14} />,
|
||||
children: conversation ? (
|
||||
<Descriptions column={1} size="small" style={{ padding: '8px 0' }}>
|
||||
<Descriptions.Item label={t('chat.inspector.session')}>
|
||||
<Typography.Text copyable={{ text: conversation.id }}>
|
||||
{conversation.id.slice(0, 8)}…
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('gateway.defaultProvider')}>
|
||||
{conversation.provider_id || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('gateway.defaultModel')}>
|
||||
{conversation.model_id || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('gateway.created')}>
|
||||
{new Date(conversation.created_at).toLocaleString()}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('chat.inspector.tools')}>
|
||||
{conversation.message_count}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : (
|
||||
<Empty
|
||||
description={t('common.noData')}
|
||||
style={{ marginTop: 48 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'artifacts',
|
||||
label: t('chat.inspector.artifacts'),
|
||||
icon: <FileText size={14} />,
|
||||
children: conversationArtifacts.length > 0 ? (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={conversationArtifacts}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={<Typography.Text>{item.title}</Typography.Text>}
|
||||
description={<Tag>{item.kind}</Tag>}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
description={conversationId ? t('common.noData') : t('common.noData')}
|
||||
style={{ marginTop: 48 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, conversationId, contextSources, toolCalls, messages, conversation, conversationArtifacts],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: visible ? 360 : 0,
|
||||
minWidth: visible ? 360 : 0,
|
||||
overflow: 'hidden',
|
||||
transition: 'width 0.2s ease, min-width 0.2s ease',
|
||||
borderLeft: visible ? '1px solid var(--border-color)' : 'none',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{visible && (
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={onTabChange}
|
||||
items={tabItems}
|
||||
size="small"
|
||||
style={{ flex: 1, padding: '0 12px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
type ChatMessageRenderBoundaryProps = {
|
||||
children: React.ReactNode;
|
||||
fallback: React.ReactNode;
|
||||
};
|
||||
|
||||
type ChatMessageRenderBoundaryState = {
|
||||
hasError: boolean;
|
||||
};
|
||||
|
||||
export class ChatMessageRenderBoundary extends React.Component<
|
||||
ChatMessageRenderBoundaryProps,
|
||||
ChatMessageRenderBoundaryState
|
||||
> {
|
||||
state: ChatMessageRenderBoundaryState = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): ChatMessageRenderBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown) {
|
||||
console.error('Failed to render chat message:', error);
|
||||
}
|
||||
|
||||
componentDidUpdate(previousProps: ChatMessageRenderBoundaryProps) {
|
||||
if (this.state.hasError && previousProps.children !== this.props.children) {
|
||||
this.setState({ hasError: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import React, { useMemo, useState, useRef, useEffect, createContext, useContext, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Avatar, Typography, theme } from 'antd';
|
||||
import { ChevronDown, User } from 'lucide-react';
|
||||
import { ModelIcon } from '@lobehub/icons';
|
||||
import { useConversationStore, useProviderStore, useSettingsStore } from '@/stores';
|
||||
import { useUserProfileStore } from '@/stores/userProfileStore';
|
||||
import { useResolvedAvatarSrc } from '@/hooks/useResolvedAvatarSrc';
|
||||
import { stripBoosapiTags } from '@/lib/chatMarkdown';
|
||||
import type { Message } from '@/types';
|
||||
|
||||
// ── Scroll context — provided by ChatView ──
|
||||
|
||||
type ScrollToFn = (messageId: string) => void;
|
||||
interface MinimapContextValue {
|
||||
scrollTo: ScrollToFn;
|
||||
scrollBoxRef: React.RefObject<HTMLElement | null>;
|
||||
/** Set by programmatic scroll — suppresses detection updates */
|
||||
scrollLockRef: React.MutableRefObject<number>;
|
||||
/** Forced active ID set by click — overrides detection during lock */
|
||||
forcedActiveRef: React.MutableRefObject<string | null>;
|
||||
}
|
||||
const MinimapScrollContext = createContext<MinimapContextValue | null>(null);
|
||||
|
||||
export function MinimapScrollProvider({
|
||||
children,
|
||||
scrollTo,
|
||||
scrollBoxRef,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
scrollTo: ScrollToFn;
|
||||
scrollBoxRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const scrollLockRef = useRef(0);
|
||||
const forcedActiveRef = useRef<string | null>(null);
|
||||
const wrappedScrollTo = useCallback<ScrollToFn>((messageId) => {
|
||||
// Lock detection for 800ms after programmatic scroll
|
||||
scrollLockRef.current = Date.now() + 800;
|
||||
forcedActiveRef.current = messageId;
|
||||
scrollTo(messageId);
|
||||
}, [scrollTo]);
|
||||
const value = useMemo(() => ({ scrollTo: wrappedScrollTo, scrollBoxRef, scrollLockRef, forcedActiveRef }), [wrappedScrollTo, scrollBoxRef]);
|
||||
return <MinimapScrollContext.Provider value={value}>{children}</MinimapScrollContext.Provider>;
|
||||
}
|
||||
|
||||
function useMinimapContext(): MinimapContextValue {
|
||||
const ctx = useContext(MinimapScrollContext);
|
||||
return ctx ?? { scrollTo: () => {}, scrollBoxRef: { current: null }, scrollLockRef: { current: 0 }, forcedActiveRef: { current: null } };
|
||||
}
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface MinimapEntry {
|
||||
index: number;
|
||||
msg: Message;
|
||||
role: 'user' | 'assistant';
|
||||
summary: string;
|
||||
modelId?: string | null;
|
||||
providerId?: string | null;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function summarize(content: string, maxLen: number, role: 'user' | 'assistant'): string {
|
||||
const stripped = stripBoosapiTags(content, { stripThink: role === 'assistant' })
|
||||
.replace(/```[\s\S]*?```/g, '[code]')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim();
|
||||
return stripped.length > maxLen ? stripped.slice(0, maxLen) + '…' : stripped;
|
||||
}
|
||||
|
||||
function useEntries(): MinimapEntry[] {
|
||||
const messages = useConversationStore((s) => s.messages);
|
||||
const hasOlderMessages = useConversationStore((s) => s.hasOlderMessages);
|
||||
const loadingOlder = useConversationStore((s) => s.loadingOlder);
|
||||
|
||||
// Eagerly load all older messages so the minimap shows the full conversation
|
||||
useEffect(() => {
|
||||
if (hasOlderMessages && !loadingOlder) {
|
||||
useConversationStore.getState().loadOlderMessages();
|
||||
}
|
||||
}, [hasOlderMessages, loadingOlder, messages]);
|
||||
|
||||
return useMemo(() => {
|
||||
const active = messages.filter((m) => m.is_active !== false);
|
||||
const entries: MinimapEntry[] = [];
|
||||
// Track assistant dedup: parentKey → index in entries array
|
||||
// Keep the LAST assistant per parent (matches ChatView's assistantByParentId behavior)
|
||||
const parentToIdx = new Map<string, number>();
|
||||
let idx = 0;
|
||||
|
||||
for (const msg of active) {
|
||||
if (msg.role === 'user') {
|
||||
entries.push({
|
||||
index: idx++,
|
||||
msg,
|
||||
role: 'user',
|
||||
summary: summarize(msg.content, 30, 'user'),
|
||||
});
|
||||
} else if (msg.role === 'assistant') {
|
||||
const parentKey = msg.parent_message_id || msg.id;
|
||||
const existing = parentToIdx.get(parentKey);
|
||||
const entry: MinimapEntry = {
|
||||
index: existing !== undefined ? entries[existing].index : idx++,
|
||||
msg,
|
||||
role: 'assistant',
|
||||
summary: summarize(msg.content, 30, 'assistant'),
|
||||
modelId: msg.model_id,
|
||||
providerId: msg.provider_id,
|
||||
};
|
||||
if (existing !== undefined) {
|
||||
entries[existing] = entry;
|
||||
} else {
|
||||
parentToIdx.set(parentKey, entries.length);
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}, [messages]);
|
||||
}
|
||||
|
||||
/** Find the bubble wrapper element for a data-boosapi-msg marker */
|
||||
function findBubbleEl(marker: Element, scrollBox: HTMLElement): Element {
|
||||
let el: Element = marker;
|
||||
// Walk up until we find an element whose parent is the scroll box or its first child container
|
||||
for (;;) {
|
||||
const parent: Element | null = el.parentElement;
|
||||
if (!parent || parent === scrollBox) return el;
|
||||
if (parent.parentElement === scrollBox) return el;
|
||||
el = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/** Track which message is currently most visible in the viewport */
|
||||
function useActiveMessageId(entries: MinimapEntry[]): string | null {
|
||||
const { scrollBoxRef, scrollLockRef, forcedActiveRef } = useMinimapContext();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
// Set default to last entry initially
|
||||
useEffect(() => {
|
||||
if (entries.length > 0 && !activeId) {
|
||||
setActiveId(entries[entries.length - 1].msg.id);
|
||||
}
|
||||
}, [entries, activeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
const updateActive = () => {
|
||||
// During programmatic scroll lock, use the forced active ID
|
||||
if (Date.now() < scrollLockRef.current) {
|
||||
if (forcedActiveRef.current) {
|
||||
setActiveId(forcedActiveRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Clear forced active once lock expires
|
||||
forcedActiveRef.current = null;
|
||||
const scrollBox = scrollBoxRef.current;
|
||||
if (!scrollBox) return;
|
||||
const boxRect = scrollBox.getBoundingClientRect();
|
||||
|
||||
// Collect rects for first and last entries to detect scroll extremes
|
||||
const firstMarker = scrollBox.querySelector(`[data-boosapi-msg="${entries[0].msg.id}"]`);
|
||||
const lastMarker = scrollBox.querySelector(`[data-boosapi-msg="${entries[entries.length - 1].msg.id}"]`);
|
||||
|
||||
// Edge case: scrolled to top — first entry's top is at or below viewport top
|
||||
if (firstMarker) {
|
||||
const firstEl = findBubbleEl(firstMarker, scrollBox);
|
||||
const firstRect = firstEl.getBoundingClientRect();
|
||||
if (firstRect.top >= boxRect.top - 5) {
|
||||
setActiveId(entries[0].msg.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Edge case: scrolled to bottom — last entry's bottom is at or above viewport bottom
|
||||
if (lastMarker) {
|
||||
const lastEl = findBubbleEl(lastMarker, scrollBox);
|
||||
const lastRect = lastEl.getBoundingClientRect();
|
||||
if (lastRect.bottom <= boxRect.bottom + 5) {
|
||||
setActiveId(entries[entries.length - 1].msg.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Normal: find entry whose bubble is closest to detection line (25% from top)
|
||||
const detectY = boxRect.top + boxRect.height * 0.25;
|
||||
let best: { id: string; dist: number } | null = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
const marker = scrollBox.querySelector(`[data-boosapi-msg="${entry.msg.id}"]`);
|
||||
if (!marker) continue;
|
||||
const el = findBubbleEl(marker, scrollBox);
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.bottom < boxRect.top || rect.top > boxRect.bottom) continue;
|
||||
const dist = Math.abs(rect.top - detectY);
|
||||
if (!best || dist < best.dist) {
|
||||
best = { id: entry.msg.id, dist };
|
||||
}
|
||||
}
|
||||
if (best) setActiveId(best.id);
|
||||
};
|
||||
|
||||
// Wait for scroll box to be available, then attach listener
|
||||
let scrollBox: HTMLElement | null = null;
|
||||
let retryTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const attach = () => {
|
||||
scrollBox = scrollBoxRef.current;
|
||||
if (!scrollBox) {
|
||||
retryTimer = setTimeout(attach, 200);
|
||||
return;
|
||||
}
|
||||
scrollBox.addEventListener('scroll', updateActive, { passive: true });
|
||||
updateActive();
|
||||
};
|
||||
|
||||
attach();
|
||||
|
||||
return () => {
|
||||
clearTimeout(retryTimer);
|
||||
scrollBox?.removeEventListener('scroll', updateActive);
|
||||
};
|
||||
}, [scrollBoxRef, entries]);
|
||||
|
||||
return activeId;
|
||||
}
|
||||
|
||||
function useModelName(modelId?: string | null, providerId?: string | null): string {
|
||||
const providers = useProviderStore((s) => s.providers);
|
||||
return useMemo(() => {
|
||||
if (!modelId) return '';
|
||||
for (const p of providers) {
|
||||
if (providerId && p.id !== providerId) continue;
|
||||
const model = p.models?.find((m) => m.model_id === modelId);
|
||||
if (model) return model.name || model.model_id;
|
||||
}
|
||||
const parts = modelId.split('/');
|
||||
return parts[parts.length - 1];
|
||||
}, [modelId, providerId, providers]);
|
||||
}
|
||||
|
||||
function ModelName({ modelId, providerId }: { modelId?: string | null; providerId?: string | null }) {
|
||||
const name = useModelName(modelId, providerId);
|
||||
return <>{name}</>;
|
||||
}
|
||||
|
||||
/** Renders the user avatar matching the chat bubble style */
|
||||
function UserAvatarIcon({ size }: { size: number }) {
|
||||
const { token } = theme.useToken();
|
||||
const profile = useUserProfileStore((s) => s.profile);
|
||||
const resolvedSrc = useResolvedAvatarSrc(profile.avatarType, profile.avatarValue);
|
||||
|
||||
if (profile.avatarType === 'emoji' && profile.avatarValue) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: Math.round(size * 0.55),
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{profile.avatarValue}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if ((profile.avatarType === 'url' || profile.avatarType === 'file') && profile.avatarValue) {
|
||||
const src = profile.avatarType === 'file' ? resolvedSrc : profile.avatarValue;
|
||||
return <Avatar size={size} src={src} />;
|
||||
}
|
||||
return <Avatar size={size} icon={<User size={Math.round(size * 0.5)} />} style={{ backgroundColor: token.colorPrimary }} />;
|
||||
}
|
||||
|
||||
// ── Plan C: FAQ Index ──
|
||||
|
||||
function FaqIndex({ entries }: { entries: MinimapEntry[] }) {
|
||||
const { token } = theme.useToken();
|
||||
const activeId = useActiveMessageId(entries);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 4,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
width: 260,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Scrollable dots column */}
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
maxHeight: '100%',
|
||||
overflowY: 'auto',
|
||||
scrollbarWidth: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<FaqItem key={entry.msg.id} entry={entry} isActive={activeId === entry.msg.id} token={token} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FaqItem({ entry, isActive, token }: {
|
||||
entry: MinimapEntry;
|
||||
isActive: boolean;
|
||||
token: ReturnType<typeof theme.useToken>['token'];
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { scrollTo } = useMinimapContext();
|
||||
const dotRef = useRef<HTMLDivElement>(null);
|
||||
const isUser = entry.role === 'user';
|
||||
|
||||
// Calculate fixed position for hover card based on dot's position
|
||||
const getCardStyle = (): React.CSSProperties => {
|
||||
if (!dotRef.current) return { display: 'none' };
|
||||
const rect = dotRef.current.getBoundingClientRect();
|
||||
return {
|
||||
position: 'fixed',
|
||||
right: window.innerWidth - rect.left + 6,
|
||||
top: rect.top + rect.height / 2,
|
||||
transform: 'translateY(-50%)',
|
||||
background: token.colorBgElevated,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRadius: token.borderRadius,
|
||||
padding: '4px 8px',
|
||||
minWidth: 140,
|
||||
maxWidth: 220,
|
||||
zIndex: 1000,
|
||||
cursor: 'pointer',
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
pointerEvents: 'auto' as const,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'relative' }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* Dot */}
|
||||
<div
|
||||
ref={dotRef}
|
||||
onClick={() => scrollTo(entry.msg.id)}
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 8,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
color: isActive ? '#fff' : token.colorTextSecondary,
|
||||
backgroundColor: isActive ? token.colorPrimary : token.colorFillQuaternary,
|
||||
border: `1px solid ${isActive ? token.colorPrimary : token.colorBorderSecondary}`,
|
||||
transition: 'all 0.2s',
|
||||
margin: '0 auto',
|
||||
overflow: 'hidden',
|
||||
...(hovered && !isActive ? {
|
||||
backgroundColor: token.colorPrimaryBg,
|
||||
borderColor: token.colorPrimary,
|
||||
color: token.colorPrimary,
|
||||
} : {}),
|
||||
}}
|
||||
>
|
||||
{isUser ? (
|
||||
<UserAvatarIcon size={14} />
|
||||
) : entry.modelId ? (
|
||||
<ModelIcon model={entry.modelId} size={12} type="avatar" />
|
||||
) : (
|
||||
entry.index + 1
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hover card — fixed position to avoid clipping */}
|
||||
{hovered && createPortal(
|
||||
<div
|
||||
onClick={() => scrollTo(entry.msg.id)}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={getCardStyle()}
|
||||
>
|
||||
<div style={{ fontSize: 10, color: isUser ? token.colorTextSecondary : token.colorPrimary }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
||||
{!isUser && entry.modelId && <ModelIcon model={entry.modelId} size={10} type="avatar" />}
|
||||
{isUser ? 'Q' : <ModelName modelId={entry.modelId} providerId={entry.providerId} />}
|
||||
</span>
|
||||
</div>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
ellipsis
|
||||
style={{ fontSize: 10, display: 'block', marginTop: 1 }}
|
||||
>
|
||||
{entry.summary}
|
||||
</Typography.Text>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plan D: Sticky Header ──
|
||||
|
||||
function StickyHeader({ entries }: { entries: MinimapEntry[] }) {
|
||||
const { token } = theme.useToken();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const activeId = useActiveMessageId(entries);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const activeIdx = entries.findIndex((e) => e.msg.id === activeId);
|
||||
const current = entries[Math.max(0, activeIdx)];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseEnter={() => setExpanded(true)}
|
||||
onMouseLeave={() => setExpanded(false)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
backdropFilter: 'blur(12px)',
|
||||
background: `${token.colorBgContainer}e6`,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '6px 16px',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span style={{ color: token.colorPrimary, fontWeight: 600, flexShrink: 0 }}>
|
||||
{(activeIdx >= 0 ? activeIdx : 0) + 1} / {entries.length}
|
||||
</span>
|
||||
{current.role === 'assistant' && current.modelId && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||
<ModelIcon model={current.modelId} size={14} type="avatar" />
|
||||
<StickyModelName modelId={current.modelId} providerId={current.providerId} />
|
||||
</span>
|
||||
)}
|
||||
{current.role === 'user' && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||
<UserAvatarIcon size={14} />
|
||||
</span>
|
||||
)}
|
||||
<Typography.Text
|
||||
ellipsis
|
||||
style={{ flex: 1, minWidth: 0, fontSize: 12, color: token.colorTextSecondary }}
|
||||
>
|
||||
{current.summary}
|
||||
</Typography.Text>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
style={{
|
||||
color: token.colorTextSecondary,
|
||||
transition: 'transform 0.2s',
|
||||
transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<StickyDropdownItem
|
||||
key={entry.msg.id}
|
||||
entry={entry}
|
||||
isActive={entry.msg.id === activeId}
|
||||
token={token}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StickyModelName({ modelId, providerId }: { modelId?: string | null; providerId?: string | null }) {
|
||||
const name = useModelName(modelId, providerId);
|
||||
const { token } = theme.useToken();
|
||||
return <span style={{ fontSize: 12, color: token.colorTextSecondary }}>{name}</span>;
|
||||
}
|
||||
|
||||
function StickyDropdownItem({ entry, isActive, token }: {
|
||||
entry: MinimapEntry;
|
||||
isActive: boolean;
|
||||
token: ReturnType<typeof theme.useToken>['token'];
|
||||
}) {
|
||||
const { scrollTo } = useMinimapContext();
|
||||
const isUser = entry.role === 'user';
|
||||
return (
|
||||
<div
|
||||
onClick={() => scrollTo(entry.msg.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '6px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
transition: 'background 0.15s',
|
||||
backgroundColor: isActive ? token.colorPrimaryBg : 'transparent',
|
||||
borderLeft: isActive ? `2px solid ${token.colorPrimary}` : '2px solid transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = token.colorFillQuaternary; }}
|
||||
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = isActive ? token.colorPrimaryBg : 'transparent'; }}
|
||||
>
|
||||
<span style={{ width: 24, textAlign: 'right', fontSize: 11, color: token.colorTextQuaternary, flexShrink: 0 }}>
|
||||
#{entry.index + 1}
|
||||
</span>
|
||||
<span style={{ flexShrink: 0 }}>
|
||||
{isUser ? (
|
||||
<UserAvatarIcon size={16} />
|
||||
) : entry.modelId ? (
|
||||
<ModelIcon model={entry.modelId} size={16} type="avatar" />
|
||||
) : (
|
||||
<Avatar size={16} style={{ backgroundColor: token.colorPrimary, fontSize: 10 }}>AI</Avatar>
|
||||
)}
|
||||
</span>
|
||||
<Typography.Text
|
||||
ellipsis
|
||||
style={{ flex: 1, minWidth: 0, fontSize: 12, color: token.colorTextSecondary }}
|
||||
>
|
||||
{entry.summary}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ──
|
||||
|
||||
export function ChatMinimap() {
|
||||
const enabled = useSettingsStore((s) => s.settings.chat_minimap_enabled);
|
||||
const style = useSettingsStore((s) => s.settings.chat_minimap_style ?? 'faq');
|
||||
const entries = useEntries();
|
||||
|
||||
if (!enabled || entries.length < 2) return null;
|
||||
|
||||
switch (style) {
|
||||
case 'faq':
|
||||
return <FaqIndex entries={entries} />;
|
||||
case 'sticky':
|
||||
return <StickyHeader entries={entries} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useEffect, useRef, useState, useCallback, memo, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
|
||||
const SCROLLBAR_HIDE_DELAY = 1200;
|
||||
|
||||
interface ThumbState {
|
||||
top: number;
|
||||
height: number;
|
||||
opacity: number;
|
||||
canScroll: boolean;
|
||||
}
|
||||
|
||||
interface ScrollMetrics {
|
||||
top: number;
|
||||
height: number;
|
||||
maxScroll: number;
|
||||
isReversed: boolean;
|
||||
}
|
||||
|
||||
interface ChatScrollIndicatorProps {
|
||||
onUserScrollIntent?: () => void;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function measureThumb(el: HTMLElement): ScrollMetrics | null {
|
||||
const { scrollTop, scrollHeight, clientHeight } = el;
|
||||
if (scrollHeight <= clientHeight + 1) return null;
|
||||
|
||||
const maxScroll = scrollHeight - clientHeight;
|
||||
const height = Math.max((clientHeight / scrollHeight) * clientHeight, 24);
|
||||
const isReversed = getComputedStyle(el).flexDirection === 'column-reverse';
|
||||
const rawProgress = isReversed ? 1 + scrollTop / maxScroll : scrollTop / maxScroll;
|
||||
const progress = clamp(rawProgress, 0, 1);
|
||||
const top = progress * (clientHeight - height);
|
||||
|
||||
return { top, height, maxScroll, isReversed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight, draggable scroll position indicator for the chat
|
||||
* message area (antd Bubble.List).
|
||||
*
|
||||
* antd's Bubble.List uses `flex-direction: column-reverse` for auto-scroll,
|
||||
* which inverts the scroll coordinate system (scrollTop=0 = bottom of content).
|
||||
* OverlayScrollbars cannot handle this, so we render a small custom indicator
|
||||
* that correctly accounts for the reversed layout.
|
||||
*
|
||||
* The indicator auto-shows on hover/scroll and fades out after inactivity.
|
||||
*/
|
||||
function ChatScrollIndicatorInner({ onUserScrollIntent }: ChatScrollIndicatorProps) {
|
||||
const [thumb, setThumb] = useState<ThumbState>({ top: 0, height: 0, opacity: 0, canScroll: false });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const elRef = useRef<HTMLElement | null>(null);
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragOffsetRef = useRef(0);
|
||||
const draggingRef = useRef(false);
|
||||
const hoveringRef = useRef(false);
|
||||
|
||||
const clearHideTimer = useCallback(() => {
|
||||
clearTimeout(hideTimer.current);
|
||||
}, []);
|
||||
|
||||
const scheduleHide = useCallback(() => {
|
||||
if (hoveringRef.current || draggingRef.current) return;
|
||||
clearHideTimer();
|
||||
hideTimer.current = setTimeout(() => {
|
||||
setThumb((state) => ({ ...state, opacity: 0 }));
|
||||
}, SCROLLBAR_HIDE_DELAY);
|
||||
}, [clearHideTimer]);
|
||||
|
||||
const updateThumb = useCallback((visible = hoveringRef.current || draggingRef.current) => {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const metrics = measureThumb(el);
|
||||
if (!metrics) {
|
||||
setThumb((state) => (state.canScroll ? { ...state, opacity: 0, canScroll: false } : state));
|
||||
return;
|
||||
}
|
||||
|
||||
setThumb({
|
||||
top: metrics.top,
|
||||
height: metrics.height,
|
||||
opacity: visible ? 1 : 0,
|
||||
canScroll: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
updateThumb(true);
|
||||
scheduleHide();
|
||||
}, [scheduleHide, updateThumb]);
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
hoveringRef.current = true;
|
||||
clearHideTimer();
|
||||
updateThumb(true);
|
||||
}, [clearHideTimer, updateThumb]);
|
||||
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
hoveringRef.current = false;
|
||||
scheduleHide();
|
||||
}, [scheduleHide]);
|
||||
|
||||
const scrollToPointer = useCallback((clientY: number, thumbOffset: number) => {
|
||||
const el = elRef.current;
|
||||
const track = trackRef.current;
|
||||
if (!el || !track) return;
|
||||
|
||||
const metrics = measureThumb(el);
|
||||
if (!metrics) return;
|
||||
|
||||
const trackRect = track.getBoundingClientRect();
|
||||
const maxThumbTop = Math.max(0, trackRect.height - metrics.height);
|
||||
const nextTop = clamp(clientY - trackRect.top - thumbOffset, 0, maxThumbTop);
|
||||
const progress = maxThumbTop === 0 ? 1 : nextTop / maxThumbTop;
|
||||
el.scrollTop = metrics.isReversed
|
||||
? (progress - 1) * metrics.maxScroll
|
||||
: progress * metrics.maxScroll;
|
||||
updateThumb(true);
|
||||
}, [updateThumb]);
|
||||
|
||||
const beginDrag = useCallback(() => {
|
||||
onUserScrollIntent?.();
|
||||
draggingRef.current = true;
|
||||
setDragging(true);
|
||||
clearHideTimer();
|
||||
updateThumb(true);
|
||||
}, [clearHideTimer, onUserScrollIntent, updateThumb]);
|
||||
|
||||
const handleThumbPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!thumb.canScroll) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
dragOffsetRef.current = event.clientY - rect.top;
|
||||
beginDrag();
|
||||
}, [beginDrag, thumb.canScroll]);
|
||||
|
||||
const handleTrackPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!thumb.canScroll || event.target !== event.currentTarget) return;
|
||||
event.preventDefault();
|
||||
dragOffsetRef.current = thumb.height / 2;
|
||||
beginDrag();
|
||||
scrollToPointer(event.clientY, dragOffsetRef.current);
|
||||
}, [beginDrag, scrollToPointer, thumb.canScroll, thumb.height]);
|
||||
|
||||
useEffect(() => {
|
||||
const attach = () => {
|
||||
const el = document.querySelector<HTMLElement>('.ant-bubble-list-scroll-box');
|
||||
if (!el || el === elRef.current) return;
|
||||
elRef.current?.removeEventListener('scroll', handleScroll);
|
||||
elRef.current?.removeEventListener('pointerenter', handlePointerEnter);
|
||||
elRef.current?.removeEventListener('pointerleave', handlePointerLeave);
|
||||
elRef.current = el;
|
||||
el.addEventListener('scroll', handleScroll, { passive: true });
|
||||
el.addEventListener('pointerenter', handlePointerEnter);
|
||||
el.addEventListener('pointerleave', handlePointerLeave);
|
||||
handleScroll();
|
||||
};
|
||||
|
||||
const raf = requestAnimationFrame(attach);
|
||||
const observer = new MutationObserver(attach);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
observer.disconnect();
|
||||
elRef.current?.removeEventListener('scroll', handleScroll);
|
||||
elRef.current?.removeEventListener('pointerenter', handlePointerEnter);
|
||||
elRef.current?.removeEventListener('pointerleave', handlePointerLeave);
|
||||
clearTimeout(hideTimer.current);
|
||||
};
|
||||
}, [handlePointerEnter, handlePointerLeave, handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
onUserScrollIntent?.();
|
||||
event.preventDefault();
|
||||
scrollToPointer(event.clientY, dragOffsetRef.current);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
draggingRef.current = false;
|
||||
setDragging(false);
|
||||
updateThumb(hoveringRef.current);
|
||||
scheduleHide();
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
window.addEventListener('pointercancel', handlePointerUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointercancel', handlePointerUp);
|
||||
};
|
||||
}, [dragging, onUserScrollIntent, scheduleHide, scrollToPointer, updateThumb]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={trackRef}
|
||||
className={`chat-scroll-indicator-track${dragging ? ' is-dragging' : ''}`}
|
||||
onPointerDown={handleTrackPointerDown}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 10,
|
||||
pointerEvents: thumb.canScroll ? 'auto' : 'none',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="chat-scroll-indicator"
|
||||
onPointerDown={handleThumbPointerDown}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 2,
|
||||
top: thumb.top,
|
||||
width: 5,
|
||||
height: thumb.height,
|
||||
borderRadius: 3,
|
||||
opacity: thumb.opacity,
|
||||
transition: dragging ? 'background-color 0.15s ease' : 'opacity 0.2s ease, background-color 0.15s ease',
|
||||
cursor: 'grab',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ChatScrollIndicator = memo(ChatScrollIndicatorInner);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
import { List, Typography, Tag, Empty } from 'antd';
|
||||
import { Link } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Citation {
|
||||
id: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
snippet: string;
|
||||
source: 'search' | 'knowledge' | 'memory';
|
||||
}
|
||||
|
||||
interface CitationPanelProps {
|
||||
citations: Citation[];
|
||||
}
|
||||
|
||||
const sourceColorMap: Record<Citation['source'], string> = {
|
||||
search: 'blue',
|
||||
knowledge: 'green',
|
||||
memory: 'purple',
|
||||
};
|
||||
|
||||
export function CitationPanel({ citations }: CitationPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (citations.length === 0) {
|
||||
return <Empty description={t('common.noData')} style={{ marginTop: 32 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
dataSource={citations}
|
||||
renderItem={(item) => (
|
||||
<List.Item key={item.id}>
|
||||
<div style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<Typography.Text>{item.title}</Typography.Text>
|
||||
<Tag color={sourceColorMap[item.source]}>{item.source}</Tag>
|
||||
</div>
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{ marginBottom: 4, fontSize: 13 }}
|
||||
>
|
||||
{item.snippet}
|
||||
</Typography.Paragraph>
|
||||
{item.url && (
|
||||
<a href={item.url} target="_blank" rel="noopener noreferrer">
|
||||
<Link size={14} style={{ marginRight: 4 }} />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.url}
|
||||
</Typography.Text>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { useMemo, useCallback, useState } from 'react';
|
||||
import { Dropdown, Tooltip, theme } from 'antd';
|
||||
import { Copy, Check, ChevronRight, Maximize2, Minimize2, Minus, Plus, RotateCcw, Eye, Palette } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CodeBlockActionContext } from 'markstream-react';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { useSettingsStore } from '@/stores';
|
||||
import { useResolvedDarkMode } from '@/hooks/useResolvedDarkMode';
|
||||
import { SHIKI_LIGHT_THEMES, SHIKI_DARK_THEMES, formatThemeName } from '@/constants/codeThemes';
|
||||
|
||||
interface Props {
|
||||
ctx: CodeBlockActionContext;
|
||||
}
|
||||
|
||||
export const CodeBlockHeaderActions: React.FC<Props> = ({ ctx }) => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const saveSettings = useSettingsStore((s) => s.saveSettings);
|
||||
const isDark = useResolvedDarkMode(settings.theme_mode);
|
||||
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
||||
|
||||
const currentTheme = isDark ? (settings.code_theme || 'poimandres') : (settings.code_theme_light || 'github-light');
|
||||
const themeList = isDark ? SHIKI_DARK_THEMES : SHIKI_LIGHT_THEMES;
|
||||
const settingsKey = isDark ? 'code_theme' : 'code_theme_light';
|
||||
|
||||
const themeMenuItems: MenuProps['items'] = useMemo(() =>
|
||||
themeList.map((id) => ({
|
||||
key: id,
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{id === currentTheme && <Check size={12} style={{ color: token.colorPrimary, flexShrink: 0 }} />}
|
||||
<span style={{ marginLeft: id === currentTheme ? 0 : 18 }}>{formatThemeName(id)}</span>
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
[themeList, currentTheme, token.colorPrimary],
|
||||
);
|
||||
|
||||
const handleThemeSelect = useCallback<NonNullable<MenuProps['onClick']>>(
|
||||
({ key }) => { saveSettings({ [settingsKey]: key }); },
|
||||
[saveSettings, settingsKey],
|
||||
);
|
||||
|
||||
const getBtnStyle = useCallback((idx: number, disabled?: boolean): React.CSSProperties => ({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 24,
|
||||
height: 24,
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
borderRadius: token.borderRadiusSM,
|
||||
background: !disabled && hoveredIdx === idx ? (token.colorFillSecondary || 'rgba(255,255,255,0.1)') : 'transparent',
|
||||
color: !disabled && hoveredIdx === idx ? token.colorText : 'inherit',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'color 0.2s, background 0.2s',
|
||||
opacity: disabled ? 0.3 : (hoveredIdx === idx ? 1 : 0.7),
|
||||
}), [hoveredIdx, token]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{/* Collapse/Expand */}
|
||||
<Tooltip title={ctx.collapsed ? t('common.expand') : t('common.collapse')} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn"
|
||||
style={getBtnStyle(0)}
|
||||
onClick={ctx.toggleCollapse}
|
||||
onMouseEnter={() => setHoveredIdx(0)} onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
style={{
|
||||
transform: ctx.collapsed ? 'rotate(0deg)' : 'rotate(90deg)',
|
||||
transition: 'transform 0.2s',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Font Decrease */}
|
||||
<Tooltip title={t('common.decrease', { defaultValue: 'Decrease' })} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn"
|
||||
style={getBtnStyle(1, ctx.fontSize <= 10)}
|
||||
disabled={ctx.fontSize <= 10}
|
||||
onClick={ctx.decreaseFontSize}
|
||||
onMouseEnter={() => setHoveredIdx(1)} onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Font Reset */}
|
||||
<Tooltip title={t('common.reset', { defaultValue: 'Reset' })} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn"
|
||||
style={getBtnStyle(2, ctx.fontSize === ctx.defaultFontSize)}
|
||||
disabled={ctx.fontSize === ctx.defaultFontSize}
|
||||
onClick={ctx.resetFontSize}
|
||||
onMouseEnter={() => setHoveredIdx(2)} onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Font Increase */}
|
||||
<Tooltip title={t('common.increase', { defaultValue: 'Increase' })} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn"
|
||||
style={getBtnStyle(3, ctx.fontSize >= 36)}
|
||||
disabled={ctx.fontSize >= 36}
|
||||
onClick={ctx.increaseFontSize}
|
||||
onMouseEnter={() => setHoveredIdx(3)} onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Copy */}
|
||||
<Tooltip title={ctx.copied ? t('common.copied') : t('common.copy')} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn"
|
||||
style={getBtnStyle(4)}
|
||||
onClick={() => ctx.copy()}
|
||||
onMouseEnter={() => setHoveredIdx(4)} onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
{ctx.copied
|
||||
? <Check size={14} style={{ color: token.colorSuccess }} />
|
||||
: <Copy size={14} />
|
||||
}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<Tooltip title={ctx.expanded ? t('common.collapse') : t('settings.fullscreen')} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn"
|
||||
style={getBtnStyle(5)}
|
||||
onClick={ctx.toggleExpand}
|
||||
onMouseEnter={() => setHoveredIdx(5)} onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
{ctx.expanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Theme Picker */}
|
||||
<Dropdown
|
||||
menu={{ items: themeMenuItems, onClick: handleThemeSelect, style: { maxHeight: 320, overflowY: 'auto' } }}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Tooltip title={t('settings.codeTheme', { defaultValue: 'Code Theme' })} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn"
|
||||
style={getBtnStyle(6)}
|
||||
onMouseEnter={() => setHoveredIdx(6)} onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
<Palette size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
|
||||
{/* Preview (only for HTML/SVG) */}
|
||||
{ctx.isPreviewable && (
|
||||
<Tooltip title={t('common.preview')} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
className="code-action-btn"
|
||||
style={getBtnStyle(7)}
|
||||
onClick={ctx.previewCode}
|
||||
onMouseEnter={() => setHoveredIdx(7)} onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
<Eye size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Modal, theme } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CodeBlockPreviewPayload } from 'markstream-react';
|
||||
|
||||
interface Props {
|
||||
payload: CodeBlockPreviewPayload | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const CodeBlockPreviewModal: React.FC<Props> = ({ payload, open, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const srcdoc = useMemo(() => {
|
||||
if (!payload) return '';
|
||||
const base = payload.node.code ?? '';
|
||||
const lowered = base.trim().toLowerCase();
|
||||
if (lowered.startsWith('<!doctype') || lowered.startsWith('<html') || lowered.startsWith('<body'))
|
||||
return base;
|
||||
|
||||
const bg = token.colorBgElevated;
|
||||
const fg = token.colorText;
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background-color: ${bg};
|
||||
color: ${fg};
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'SF Pro Text', ui-sans-serif, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${base}
|
||||
</body>
|
||||
</html>`;
|
||||
}, [payload, token.colorBgElevated, token.colorText]);
|
||||
|
||||
const title = (() => {
|
||||
const raw = payload?.artifactTitle;
|
||||
if (!raw) return t('common.preview');
|
||||
const type = payload?.artifactType;
|
||||
if (type === 'text/html') return `HTML ${t('common.preview')}`;
|
||||
if (type === 'image/svg+xml') return `SVG ${t('common.preview')}`;
|
||||
return raw;
|
||||
})();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width="80vw"
|
||||
style={{ top: 40 }}
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
borderRadius: token.borderRadiusLG,
|
||||
},
|
||||
}}
|
||||
destroyOnClose
|
||||
>
|
||||
{payload && (
|
||||
<iframe
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
src="about:blank"
|
||||
srcDoc={srcdoc}
|
||||
title={title}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'calc(80vh - 80px)',
|
||||
border: 'none',
|
||||
display: 'block',
|
||||
borderRadius: token.borderRadiusLG,
|
||||
backgroundColor: token.colorBgElevated,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Tag, Space, Tooltip, theme } from 'antd';
|
||||
import { Bot, Search, Wrench, BookOpen, Lightbulb } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ContextBarProps {
|
||||
modelName?: string;
|
||||
searchEnabled?: boolean;
|
||||
toolCount?: number;
|
||||
knowledgeCount?: number;
|
||||
memoryEnabled?: boolean;
|
||||
onChipClick?: (type: string) => void;
|
||||
}
|
||||
|
||||
export function ContextBar({
|
||||
modelName,
|
||||
searchEnabled = false,
|
||||
toolCount = 0,
|
||||
knowledgeCount = 0,
|
||||
memoryEnabled = false,
|
||||
onChipClick,
|
||||
}: ContextBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const handleClick = useCallback(
|
||||
(type: string) => {
|
||||
onChipClick?.(type);
|
||||
},
|
||||
[onChipClick],
|
||||
);
|
||||
|
||||
const chips = useMemo(
|
||||
() => [
|
||||
...(modelName
|
||||
? [
|
||||
{
|
||||
key: 'model',
|
||||
icon: <Bot size={14} />,
|
||||
label: modelName,
|
||||
color: 'purple' as const,
|
||||
tooltip: t('chat.context.model'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'search',
|
||||
icon: <Search size={14} />,
|
||||
label: searchEnabled ? t('chat.context.enabled') : t('chat.context.disabled'),
|
||||
color: (searchEnabled ? 'green' : 'default') as string,
|
||||
tooltip: t('chat.context.search'),
|
||||
},
|
||||
{
|
||||
key: 'tools',
|
||||
icon: <Wrench size={14} />,
|
||||
label: t('chat.context.count', { count: toolCount }),
|
||||
color: (toolCount > 0 ? 'blue' : 'default') as string,
|
||||
tooltip: t('chat.context.tools'),
|
||||
},
|
||||
{
|
||||
key: 'knowledge',
|
||||
icon: <BookOpen size={14} />,
|
||||
label: t('chat.context.count', { count: knowledgeCount }),
|
||||
color: (knowledgeCount > 0 ? 'blue' : 'default') as string,
|
||||
tooltip: t('chat.context.knowledge'),
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
icon: <Lightbulb size={14} />,
|
||||
label: memoryEnabled ? t('chat.context.enabled') : t('chat.context.disabled'),
|
||||
color: (memoryEnabled ? 'green' : 'default') as string,
|
||||
tooltip: t('chat.context.memory'),
|
||||
},
|
||||
],
|
||||
[modelName, searchEnabled, toolCount, knowledgeCount, memoryEnabled, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 16px',
|
||||
borderBottom: '1px solid var(--border-color)',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
<Space size={[4, 4]} wrap>
|
||||
{chips.map((chip) => (
|
||||
<Tooltip key={chip.key} title={chip.tooltip}>
|
||||
<Tag
|
||||
icon={chip.icon}
|
||||
color={chip.color}
|
||||
style={{ cursor: onChipClick ? 'pointer' : 'default', margin: 0 }}
|
||||
onClick={() => handleClick(chip.key)}
|
||||
>
|
||||
{chip.label}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { memo } from 'react';
|
||||
import { theme } from 'antd';
|
||||
import { ModelIcon, modelMappings } from '@lobehub/icons';
|
||||
import { Brain } from 'lucide-react';
|
||||
|
||||
type ConversationModelIconProps = {
|
||||
model: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
const FALLBACK_MODEL_IDS = new Set(['unknown-model', 'unknown', 'default', 'kelivo']);
|
||||
|
||||
function normalizeModelName(model: string): string {
|
||||
return model.trim().toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function hasKnownModelIcon(model: string): boolean {
|
||||
const normalized = normalizeModelName(model);
|
||||
return modelMappings.some((mapping: { keywords: string[] }) =>
|
||||
mapping.keywords.some((keyword) => {
|
||||
try {
|
||||
return new RegExp(keyword, 'i').test(normalized);
|
||||
} catch {
|
||||
return normalized.includes(keyword.toLowerCase());
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function shouldUseFallbackIcon(model: string): boolean {
|
||||
const normalized = normalizeModelName(model);
|
||||
return !normalized || FALLBACK_MODEL_IDS.has(normalized) || !hasKnownModelIcon(model);
|
||||
}
|
||||
|
||||
export const ConversationModelIcon = memo(function ConversationModelIcon({
|
||||
model,
|
||||
size,
|
||||
}: ConversationModelIconProps) {
|
||||
const { token } = theme.useToken();
|
||||
if (shouldUseFallbackIcon(model)) {
|
||||
return (
|
||||
<span
|
||||
className="boosapi-conversation-model-icon"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
minWidth: size,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
lineHeight: 0,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
color: token.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
<Brain size={Math.round(size * 0.58)} strokeWidth={1.8} style={{ display: 'block' }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="boosapi-conversation-model-icon"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
minWidth: size,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
lineHeight: 0,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<ModelIcon model={model} size={size} type="avatar" />
|
||||
</span>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Input, Slider, Button, Tooltip, Card, theme } from 'antd';
|
||||
import { Info, Undo2, Bot } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useConversationStore, useProviderStore, useSettingsStore } from '@/stores';
|
||||
import { CONV_ICON_KEY, type ConvIconType, type ConvIcon } from '@/lib/convIcon';
|
||||
import { IconEditor } from '@/components/shared/IconEditor';
|
||||
import { ModelParamSliders } from '@/components/common/ModelParamSliders';
|
||||
import { findModelByIds } from '@/lib/modelCapabilities';
|
||||
import { resolveModelParamDefaults } from '@/lib/modelParams';
|
||||
import { ConversationModelIcon } from './ConversationModelIcon';
|
||||
import type { MenuProps } from 'antd';
|
||||
|
||||
interface ConversationSettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CONTEXT_LIMIT_KEY = (id: string) => `boosapi_context_limit_${id}`;
|
||||
|
||||
export function ConversationSettingsModal({ open, onClose }: ConversationSettingsModalProps) {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const conversations = useConversationStore((s) => s.conversations);
|
||||
const activeConversationId = useConversationStore((s) => s.activeConversationId);
|
||||
const updateConversation = useConversationStore((s) => s.updateConversation);
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const providers = useProviderStore((s) => s.providers);
|
||||
|
||||
const conversation = conversations.find((c) => c.id === activeConversationId);
|
||||
const modelParamDefaults = React.useMemo(() => {
|
||||
const model = findModelByIds(providers, conversation?.provider_id, conversation?.model_id);
|
||||
return resolveModelParamDefaults(model, settings);
|
||||
}, [providers, conversation?.provider_id, conversation?.model_id, settings]);
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('');
|
||||
const [systemPrompt, setSystemPrompt] = useState('');
|
||||
const [contextLimit, setContextLimit] = useState(50);
|
||||
const [temperature, setTemperature] = useState<number | null>(null);
|
||||
const [topP, setTopP] = useState<number | null>(null);
|
||||
const [maxTokens, setMaxTokens] = useState<number | null>(null);
|
||||
const [frequencyPenalty, setFrequencyPenalty] = useState<number | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Icon state
|
||||
const [iconType, setIconType] = useState<ConvIconType>('model');
|
||||
const [iconValue, setIconValue] = useState('');
|
||||
|
||||
// Initialize form when modal opens
|
||||
useEffect(() => {
|
||||
if (open && conversation) {
|
||||
setTitle(conversation.title);
|
||||
setSystemPrompt(conversation.system_prompt ?? '');
|
||||
setTemperature(conversation.temperature ?? null);
|
||||
setTopP(conversation.top_p ?? null);
|
||||
setMaxTokens(conversation.max_tokens ?? null);
|
||||
setFrequencyPenalty(conversation.frequency_penalty ?? null);
|
||||
|
||||
const stored = localStorage.getItem(CONTEXT_LIMIT_KEY(conversation.id));
|
||||
setContextLimit(stored ? Number(stored) : 50);
|
||||
|
||||
// Load icon
|
||||
const iconStored = localStorage.getItem(CONV_ICON_KEY(conversation.id));
|
||||
if (iconStored) {
|
||||
try {
|
||||
const parsed: ConvIcon = JSON.parse(iconStored);
|
||||
setIconType(parsed.type);
|
||||
setIconValue(parsed.value);
|
||||
} catch {
|
||||
setIconType('model');
|
||||
setIconValue('');
|
||||
}
|
||||
} else {
|
||||
setIconType('model');
|
||||
setIconValue('');
|
||||
}
|
||||
}
|
||||
}, [open, conversation]);
|
||||
|
||||
if (!conversation) return null;
|
||||
|
||||
|
||||
const handleReset = () => {
|
||||
setTemperature(null);
|
||||
setTopP(null);
|
||||
setMaxTokens(null);
|
||||
setFrequencyPenalty(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateConversation(conversation.id, {
|
||||
title,
|
||||
system_prompt: systemPrompt,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
top_p: topP,
|
||||
frequency_penalty: frequencyPenalty,
|
||||
});
|
||||
localStorage.setItem(CONTEXT_LIMIT_KEY(conversation.id), String(contextLimit));
|
||||
// Save icon
|
||||
if (iconType === 'model') {
|
||||
localStorage.removeItem(CONV_ICON_KEY(conversation.id));
|
||||
} else {
|
||||
localStorage.setItem(CONV_ICON_KEY(conversation.id), JSON.stringify({ type: iconType, value: iconValue }));
|
||||
}
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const useModelIconMenuItem: MenuProps['items'] = [
|
||||
{
|
||||
key: 'use_model',
|
||||
icon: <Bot size={14} />,
|
||||
label: t('settings.useModelIcon'),
|
||||
onClick: () => { setIconType('model'); setIconValue(''); },
|
||||
},
|
||||
];
|
||||
|
||||
const sliderRowStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 13,
|
||||
color: token.colorText,
|
||||
marginBottom: 6,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.conversationSettings')}
|
||||
open={open}
|
||||
mask={{ enabled: true, blur: true }}
|
||||
onCancel={onClose}
|
||||
width={520}
|
||||
destroyOnHidden
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button type="primary" onClick={handleSave} loading={saving}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div data-os-scrollbar style={{ maxHeight: '70vh', overflowY: 'auto', paddingRight: 4 }}>
|
||||
{/* Avatar with IconEditor */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', margin: '8px 0 16px' }}>
|
||||
<IconEditor
|
||||
iconType={iconType === 'model' ? null : iconType}
|
||||
iconValue={iconType === 'model' ? null : iconValue}
|
||||
onChange={(type, value) => {
|
||||
if (type && value) {
|
||||
setIconType(type as ConvIconType);
|
||||
setIconValue(value);
|
||||
} else {
|
||||
setIconType('model');
|
||||
setIconValue('');
|
||||
}
|
||||
}}
|
||||
size={64}
|
||||
defaultIcon={<ConversationModelIcon model={conversation.model_id} size={64} />}
|
||||
prependMenuItems={useModelIconMenuItem}
|
||||
showClear={iconType !== 'model'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={labelStyle}>{t('common.name')}</div>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* System Prompt */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={labelStyle}>{t('settings.systemPromptLabel')}</div>
|
||||
<Input.TextArea
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={t('settings.systemPromptPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model Settings Card */}
|
||||
<Card
|
||||
title={t('settings.modelSettings')}
|
||||
size="small"
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<Undo2 size={14} />}
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t('common.reset')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
|
||||
{/* Context Message Limit */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={labelStyle}>
|
||||
{t('settings.contextMessageLimit')}
|
||||
<Tooltip title={t('settings.contextMessageLimitTooltip')}>
|
||||
<Info size={14} style={{ color: token.colorTextSecondary, cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
<span style={{ marginLeft: 'auto', color: token.colorTextSecondary, fontSize: 12 }}>
|
||||
{contextLimit >= 50 ? t('common.unlimited') : contextLimit}
|
||||
</span>
|
||||
</div>
|
||||
<div style={sliderRowStyle}>
|
||||
<Slider
|
||||
style={{ flex: 1 }}
|
||||
min={1}
|
||||
max={50}
|
||||
value={contextLimit}
|
||||
onChange={setContextLimit}
|
||||
marks={{ 1: '1', 10: '10', 25: '25', 50: '50' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Temperature / Top P / Max Tokens / Frequency Penalty */}
|
||||
<ModelParamSliders
|
||||
values={{
|
||||
temperature,
|
||||
topP,
|
||||
maxTokens,
|
||||
frequencyPenalty,
|
||||
}}
|
||||
onChange={(v) => {
|
||||
if ('temperature' in v) setTemperature(v.temperature!);
|
||||
if ('topP' in v) setTopP(v.topP!);
|
||||
if ('maxTokens' in v) setMaxTokens(v.maxTokens!);
|
||||
if ('frequencyPenalty' in v) setFrequencyPenalty(v.frequencyPenalty!);
|
||||
}}
|
||||
defaults={{
|
||||
temperature: modelParamDefaults.temperature,
|
||||
topP: modelParamDefaults.topP,
|
||||
maxTokens: modelParamDefaults.maxTokens,
|
||||
frequencyPenalty: modelParamDefaults.frequencyPenalty,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Segmented, theme } from 'antd';
|
||||
import { Eye, Code2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
showSource: boolean;
|
||||
onSwitchMode: (mode: 'preview' | 'source') => void;
|
||||
}
|
||||
|
||||
export const DiagramModeToggle: React.FC<Props> = ({ showSource, onSwitchMode }) => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
return (
|
||||
<Segmented
|
||||
size="small"
|
||||
value={showSource ? 'source' : 'preview'}
|
||||
onChange={(value) => onSwitchMode(value as 'preview' | 'source')}
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<Eye size={12} />
|
||||
{t('common.preview')}
|
||||
</span>
|
||||
),
|
||||
value: 'preview',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<Code2 size={12} />
|
||||
{t('common.source')}
|
||||
</span>
|
||||
),
|
||||
value: 'source',
|
||||
},
|
||||
]}
|
||||
style={{ fontSize: token.fontSizeSM }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import type { NodeComponentProps } from 'markstream-react';
|
||||
import { sanitizeHtmlContent } from 'stream-markdown-parser';
|
||||
import { getHtmlRenderInnerContent } from '@/lib/chatHtmlRender';
|
||||
|
||||
type HtmlRenderNodeData = {
|
||||
type: 'html-render';
|
||||
content?: string;
|
||||
raw?: string;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
type HtmlRenderNodeProps =
|
||||
| NodeComponentProps<HtmlRenderNodeData>
|
||||
| { node: HtmlRenderNodeData; isDark?: boolean; ctx?: { isDark?: boolean } };
|
||||
|
||||
const HTML_RENDER_CACHE_LIMIT = 80;
|
||||
const htmlRenderCache = new Map<string, string>();
|
||||
const STYLE_TAG_RE = /<style\b[^>]*>[\s\S]*?<\/style\s*>/gi;
|
||||
const STYLE_ATTR_RE = /\sstyle=("([^"]*)"|'([^']*)')/gi;
|
||||
|
||||
type Rgb = {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
};
|
||||
|
||||
function getIsDark(props: HtmlRenderNodeProps) {
|
||||
return Boolean(props.isDark ?? props.ctx?.isDark);
|
||||
}
|
||||
|
||||
function stripStyleTags(html: string) {
|
||||
return html.replace(STYLE_TAG_RE, '');
|
||||
}
|
||||
|
||||
function rememberCachedHtml(key: string, value: string) {
|
||||
if (htmlRenderCache.size >= HTML_RENDER_CACHE_LIMIT) {
|
||||
const firstKey = htmlRenderCache.keys().next().value;
|
||||
if (firstKey) htmlRenderCache.delete(firstKey);
|
||||
}
|
||||
htmlRenderCache.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseHexColor(value: string): Rgb | null {
|
||||
const hex = value.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
|
||||
if (!hex) return null;
|
||||
const full = hex.length === 3
|
||||
? hex.split('').map((ch) => `${ch}${ch}`).join('')
|
||||
: hex;
|
||||
return {
|
||||
r: Number.parseInt(full.slice(0, 2), 16),
|
||||
g: Number.parseInt(full.slice(2, 4), 16),
|
||||
b: Number.parseInt(full.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function parseRgbColor(value: string): Rgb | null {
|
||||
const match = value.trim().match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/i);
|
||||
if (!match) return null;
|
||||
return {
|
||||
r: Math.min(255, Number(match[1])),
|
||||
g: Math.min(255, Number(match[2])),
|
||||
b: Math.min(255, Number(match[3])),
|
||||
};
|
||||
}
|
||||
|
||||
function parseColor(value: string): Rgb | null {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'white') return { r: 255, g: 255, b: 255 };
|
||||
if (normalized === 'black') return { r: 0, g: 0, b: 0 };
|
||||
if (normalized === 'whitesmoke') return { r: 245, g: 245, b: 245 };
|
||||
if (normalized === 'gainsboro') return { r: 220, g: 220, b: 220 };
|
||||
if (normalized === 'lightgray' || normalized === 'lightgrey') return { r: 211, g: 211, b: 211 };
|
||||
return parseHexColor(normalized) ?? parseRgbColor(normalized);
|
||||
}
|
||||
|
||||
function luminance(color: Rgb) {
|
||||
const channel = (value: number) => {
|
||||
const normalized = value / 255;
|
||||
return normalized <= 0.03928
|
||||
? normalized / 12.92
|
||||
: ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
|
||||
}
|
||||
|
||||
function isNeutral(color: Rgb) {
|
||||
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b) <= 32;
|
||||
}
|
||||
|
||||
function isLightNeutral(value: string) {
|
||||
const color = parseColor(value);
|
||||
return Boolean(color && isNeutral(color) && luminance(color) >= 0.72);
|
||||
}
|
||||
|
||||
function isDarkNeutral(value: string) {
|
||||
const color = parseColor(value);
|
||||
return Boolean(color && isNeutral(color) && luminance(color) <= 0.18);
|
||||
}
|
||||
|
||||
function replaceNeutralColors(value: string, replacement: string) {
|
||||
return value.replace(/#[0-9a-f]{3,6}\b|rgba?\([^)]+\)|\b(?:white|whitesmoke|gainsboro|lightgr[ae]y|black)\b/gi, (match) => (
|
||||
isLightNeutral(match) || isDarkNeutral(match) ? replacement : match
|
||||
));
|
||||
}
|
||||
|
||||
function adaptDarkStyle(style: string) {
|
||||
return style
|
||||
.split(';')
|
||||
.map((part) => {
|
||||
const separatorIndex = part.indexOf(':');
|
||||
if (separatorIndex === -1) return part.trim();
|
||||
|
||||
const property = part.slice(0, separatorIndex).trim();
|
||||
const lowerProperty = property.toLowerCase();
|
||||
const value = part.slice(separatorIndex + 1).trim();
|
||||
if (!property || !value) return '';
|
||||
|
||||
if (lowerProperty === 'color' && isDarkNeutral(value)) {
|
||||
return `${property}:var(--boosapi-html-fg)`;
|
||||
}
|
||||
if ((lowerProperty === 'background' || lowerProperty === 'background-color') && isLightNeutral(value)) {
|
||||
return `${property}:rgba(255,255,255,0.06)`;
|
||||
}
|
||||
if (lowerProperty.includes('border')) {
|
||||
return `${property}:${replaceNeutralColors(value, 'rgba(255,255,255,0.18)')}`;
|
||||
}
|
||||
|
||||
return `${property}:${value}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
function escapeStyleAttribute(value: string, quote: '"' | "'") {
|
||||
return quote === '"'
|
||||
? value.replace(/"/g, '"')
|
||||
: value.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function adaptHtmlForDarkMode(html: string) {
|
||||
return html.replace(STYLE_ATTR_RE, (_match, quoted: string, doubleQuoted?: string, singleQuoted?: string) => {
|
||||
const quote = quoted.startsWith('"') ? '"' : "'";
|
||||
const style = doubleQuoted ?? singleQuoted ?? '';
|
||||
return ` style=${quote}${escapeStyleAttribute(adaptDarkStyle(style), quote)}${quote}`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderSafeHtml(html: string, isDark: boolean) {
|
||||
const key = `${isDark ? 'dark' : 'light'}\u0000${html}`;
|
||||
const cached = htmlRenderCache.get(key);
|
||||
if (cached != null) return cached;
|
||||
|
||||
const sanitized = stripStyleTags(sanitizeHtmlContent(stripStyleTags(html)));
|
||||
const themed = isDark ? adaptHtmlForDarkMode(sanitized) : sanitized;
|
||||
return rememberCachedHtml(key, themed);
|
||||
}
|
||||
|
||||
function createContainerStyle(isDark: boolean): CSSProperties & Record<string, string> {
|
||||
return {
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto',
|
||||
contain: 'layout paint style',
|
||||
colorScheme: isDark ? 'dark' : 'light',
|
||||
'--boosapi-html-fg': isDark ? '#f5f5f5' : 'inherit',
|
||||
'--boosapi-html-bg': isDark ? 'rgba(255,255,255,0.06)' : 'transparent',
|
||||
'--boosapi-html-muted': isDark ? 'rgba(255,255,255,0.62)' : 'rgba(0,0,0,0.58)',
|
||||
'--boosapi-html-border': isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.14)',
|
||||
'--boosapi-html-accent': isDark ? '#8ab4f8' : '#1677ff',
|
||||
color: 'var(--boosapi-html-fg)',
|
||||
background: 'transparent',
|
||||
};
|
||||
}
|
||||
|
||||
function getNode(props: HtmlRenderNodeProps) {
|
||||
return props.node;
|
||||
}
|
||||
|
||||
export function HtmlRenderNode(props: HtmlRenderNodeProps) {
|
||||
const node = getNode(props);
|
||||
const isDark = getIsDark(props);
|
||||
const isLoading = Boolean(node.loading);
|
||||
const html = useMemo(() => getHtmlRenderInnerContent(node), [node]);
|
||||
const renderKey = `${isDark ? 'dark' : 'light'}\u0000${html}`;
|
||||
const [safeHtml, setSafeHtml] = useState(() => renderSafeHtml(html, isDark));
|
||||
const latestRenderRef = useRef({ html, isDark, key: renderKey });
|
||||
const renderedKeyRef = useRef(renderKey);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
latestRenderRef.current = { html, isDark, key: renderKey };
|
||||
|
||||
const cancelPendingFrame = () => {
|
||||
if (frameRef.current !== null && typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(frameRef.current);
|
||||
}
|
||||
frameRef.current = null;
|
||||
};
|
||||
|
||||
if (!isLoading) {
|
||||
cancelPendingFrame();
|
||||
if (renderedKeyRef.current !== renderKey) {
|
||||
renderedKeyRef.current = renderKey;
|
||||
setSafeHtml(renderSafeHtml(html, isDark));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (renderedKeyRef.current === renderKey || frameRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
|
||||
renderedKeyRef.current = renderKey;
|
||||
setSafeHtml(renderSafeHtml(html, isDark));
|
||||
return;
|
||||
}
|
||||
|
||||
frameRef.current = window.requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
const latest = latestRenderRef.current;
|
||||
renderedKeyRef.current = latest.key;
|
||||
setSafeHtml(renderSafeHtml(latest.html, latest.isDark));
|
||||
});
|
||||
}, [html, isDark, isLoading, renderKey]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (frameRef.current !== null && typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(frameRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="boosapi-html-render"
|
||||
data-testid="html-render-content"
|
||||
style={createContainerStyle(isDark)}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default HtmlRenderNode;
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Tooltip, theme } from 'antd';
|
||||
import { Copy, Check, ChevronRight, Download, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { InfographicBlockActionContext } from 'markstream-react';
|
||||
|
||||
interface Props {
|
||||
ctx: InfographicBlockActionContext;
|
||||
}
|
||||
|
||||
export const InfographicBlockHeaderActions: React.FC<Props> = ({ ctx }) => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
||||
|
||||
const getBtnStyle = useCallback((idx: number, disabled?: boolean): React.CSSProperties => ({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: token.borderRadiusSM,
|
||||
border: 'none',
|
||||
background: !disabled && hoveredIdx === idx ? (token.colorFillSecondary || 'rgba(255,255,255,0.1)') : 'transparent',
|
||||
color: !disabled && hoveredIdx === idx ? token.colorText : token.colorTextSecondary,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
padding: 0,
|
||||
transition: 'color 0.2s, background 0.2s',
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
}), [hoveredIdx, token]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{/* Collapse */}
|
||||
<Tooltip title={ctx.collapsed ? t('common.expand') : t('common.collapse')} mouseEnterDelay={0.4}>
|
||||
<button type="button" style={getBtnStyle(0)} onClick={ctx.toggleCollapse}
|
||||
onMouseEnter={() => setHoveredIdx(0)} onMouseLeave={() => setHoveredIdx(null)}>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
style={{
|
||||
transform: ctx.collapsed ? 'rotate(0deg)' : 'rotate(90deg)',
|
||||
transition: 'transform 0.2s',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Copy */}
|
||||
<Tooltip title={ctx.copied ? t('common.copied') : t('common.copy')} mouseEnterDelay={0.4}>
|
||||
<button type="button" style={getBtnStyle(1)} onClick={ctx.copy}
|
||||
onMouseEnter={() => setHoveredIdx(1)} onMouseLeave={() => setHoveredIdx(null)}>
|
||||
{ctx.copied
|
||||
? <Check size={14} style={{ color: token.colorSuccess }} />
|
||||
: <Copy size={14} />
|
||||
}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Export / Download */}
|
||||
<Tooltip title={t('common.export')} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
style={getBtnStyle(2, ctx.isExportDisabled)}
|
||||
disabled={ctx.isExportDisabled}
|
||||
onClick={ctx.exportSvg}
|
||||
onMouseEnter={() => setHoveredIdx(2)}
|
||||
onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<Tooltip title={ctx.modalOpen ? t('settings.exitFullscreen') : t('settings.fullscreen')} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
style={getBtnStyle(3, ctx.isExportDisabled)}
|
||||
disabled={ctx.isExportDisabled}
|
||||
onClick={ctx.toggleFullscreen}
|
||||
onMouseEnter={() => setHoveredIdx(3)}
|
||||
onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
{ctx.modalOpen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,277 @@
|
||||
import { useState } from 'react';
|
||||
import { theme } from 'antd';
|
||||
import { BookOpen, ChevronDown, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import type { NodeComponentProps } from 'markstream-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { MemorySourceResult, MemoryRetrievedItem } from '@/lib/memoryUtils';
|
||||
|
||||
type KnowledgeRetrievalNodeData = {
|
||||
type: 'knowledge-retrieval';
|
||||
content?: string;
|
||||
attrs?: Record<string, string> | [string, string][];
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
function getAttrValue(
|
||||
attrs: KnowledgeRetrievalNodeData['attrs'],
|
||||
key: string,
|
||||
): string | undefined {
|
||||
if (!attrs) return undefined;
|
||||
if (Array.isArray(attrs)) {
|
||||
const entry = attrs.find(([name]) => name === key);
|
||||
return entry?.[1];
|
||||
}
|
||||
return attrs[key];
|
||||
}
|
||||
|
||||
function truncateContent(text: string, maxLen = 120): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen) + '…';
|
||||
}
|
||||
|
||||
function formatRetrievalScore(item: MemoryRetrievedItem, digits: number): string {
|
||||
if (typeof item.rerankScore === 'number') {
|
||||
return item.rerankScore.toFixed(digits);
|
||||
}
|
||||
return (1 / (1 + item.score)).toFixed(digits);
|
||||
}
|
||||
|
||||
export function KnowledgeRetrievalNode(props: NodeComponentProps<KnowledgeRetrievalNodeData>) {
|
||||
const { node } = props;
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const status = getAttrValue(node.attrs, 'status') ?? (node.loading ? 'searching' : 'done');
|
||||
|
||||
let sources: MemorySourceResult[] = [];
|
||||
if (node.content) {
|
||||
try {
|
||||
const parsed = JSON.parse(node.content);
|
||||
if (Array.isArray(parsed)) sources = parsed;
|
||||
} catch {
|
||||
// invalid JSON
|
||||
}
|
||||
}
|
||||
|
||||
const totalItems = sources.reduce((sum, s) => sum + s.items.length, 0);
|
||||
|
||||
// Searching state
|
||||
if (status === 'searching') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorFillQuaternary,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="animate-spin"
|
||||
style={{ display: 'inline-flex', width: 16, height: 16 }}
|
||||
>
|
||||
<BookOpen size={16} style={{ color: token.colorPrimary }} />
|
||||
</span>
|
||||
<span style={{ color: token.colorTextSecondary, fontSize: 13 }}>
|
||||
{t('chat.knowledgeRetrieval.searching')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorErrorBg,
|
||||
color: token.colorError,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} />
|
||||
<span>{node.content || t('chat.knowledgeRetrieval.error')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'empty') {
|
||||
const reason = getAttrValue(node.attrs, 'reason');
|
||||
const messageKey = reason === 'threshold_filtered'
|
||||
? 'chat.knowledgeRetrieval.emptyThreshold'
|
||||
: 'chat.knowledgeRetrieval.empty';
|
||||
const defaultValue = reason === 'threshold_filtered'
|
||||
? '未找到符合检索阈值的知识库内容,可适当降低检索阈值。'
|
||||
: '未找到相关知识库内容。';
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorWarningBg,
|
||||
color: token.colorWarningText,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<BookOpen size={16} />
|
||||
<span>{t(messageKey, { defaultValue })}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Done state — no results
|
||||
if (totalItems === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: token.colorFillQuaternary,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<BookOpen size={14} style={{ color: token.colorPrimary }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{t('chat.knowledgeRetrieval.resultsCount', {
|
||||
count: totalItems,
|
||||
defaultValue: '检索到 {{count}} 条知识',
|
||||
})}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', color: token.colorTextTertiary }}>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Per-item overview */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
padding: '6px 12px',
|
||||
flexWrap: 'wrap',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{sources.flatMap((src, si) =>
|
||||
src.items.map((item, ii) => (
|
||||
<span
|
||||
key={`${si}-${ii}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '2px 8px',
|
||||
fontSize: 11,
|
||||
borderRadius: 4,
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
color: token.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
<BookOpen size={10} style={{ flexShrink: 0 }} />
|
||||
<span style={{ maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.document_name || item.document_id?.slice(0, 8) || '—'}
|
||||
</span>
|
||||
{item.id && <span style={{ opacity: 0.5 }}>#{item.id.slice(0, 6)}</span>}
|
||||
<span style={{ color: token.colorPrimary, fontFamily: 'monospace' }}>
|
||||
{formatRetrievalScore(item, 3)}
|
||||
</span>
|
||||
</span>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{sources.map((src, si) =>
|
||||
src.items.map((item: MemoryRetrievedItem, ii: number) => (
|
||||
<div
|
||||
key={`${si}-${ii}`}
|
||||
style={{
|
||||
marginBottom: ii < src.items.length - 1 || si < sources.length - 1 ? 8 : 0,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<BookOpen size={12} style={{ color: token.colorPrimary, flexShrink: 0 }} />
|
||||
<span style={{ fontWeight: 500, color: token.colorText }}>
|
||||
{item.document_name || item.document_id?.slice(0, 8) || '—'}
|
||||
</span>
|
||||
{item.id && (
|
||||
<span style={{ fontSize: 10, color: token.colorTextQuaternary }}>
|
||||
#{item.id.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: 10,
|
||||
color: token.colorTextQuaternary,
|
||||
}}
|
||||
>
|
||||
{formatRetrievalScore(item, 4)}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
margin: '2px 0 0 0',
|
||||
color: token.colorTextSecondary,
|
||||
lineHeight: 1.5,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{truncateContent(item.content, 200)}
|
||||
</p>
|
||||
</div>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Collapse, Tag } from 'antd';
|
||||
import { CheckCircle, Loader, XCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { NodeComponentProps } from 'markstream-react';
|
||||
|
||||
const MCP_NODE_TEXT_CHAR_LIMIT = 20_000;
|
||||
const MCP_NODE_TRUNCATED_NOTICE = 'MCP output truncated for rendering';
|
||||
|
||||
function safeGetAttr(attrs: any, key: string): string | undefined {
|
||||
if (!attrs) return undefined;
|
||||
|
||||
if (Array.isArray(attrs)) {
|
||||
for (const item of attrs) {
|
||||
if (Array.isArray(item)) {
|
||||
const [k, v] = item;
|
||||
if (k === key || k === `data-${key}`) {
|
||||
if (v == null) return undefined;
|
||||
return typeof v === 'object' ? JSON.stringify(v) : String(v);
|
||||
}
|
||||
} else if (item && typeof item === 'object' && 'name' in item) {
|
||||
if (item.name === key || item.name === `data-${key}`) {
|
||||
const v = item.value;
|
||||
if (v == null) return undefined;
|
||||
return typeof v === 'object' ? JSON.stringify(v) : String(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof attrs === 'object') {
|
||||
for (const k of [key, `data-${key}`]) {
|
||||
const v = attrs[k];
|
||||
if (v == null) continue;
|
||||
return typeof v === 'object' ? JSON.stringify(v) : String(v);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function appendBoundedText(current: string, next: string, limit: number): { text: string; truncated: boolean } {
|
||||
if (!next) return { text: current, truncated: false };
|
||||
|
||||
let text = current;
|
||||
let remaining = limit - Array.from(current).length;
|
||||
if (remaining <= 0) {
|
||||
return { text, truncated: true };
|
||||
}
|
||||
|
||||
for (const char of next) {
|
||||
if (remaining <= 0) {
|
||||
return { text, truncated: true };
|
||||
}
|
||||
text += char;
|
||||
remaining -= 1;
|
||||
}
|
||||
|
||||
return { text, truncated: false };
|
||||
}
|
||||
|
||||
function extractText(children: any[] | undefined, limit = MCP_NODE_TEXT_CHAR_LIMIT): { text: string; truncated: boolean } {
|
||||
if (!children || children.length === 0) return { text: '', truncated: false };
|
||||
|
||||
let text = '';
|
||||
for (const child of children) {
|
||||
let next = '';
|
||||
if (typeof child === 'string') {
|
||||
next = child;
|
||||
} else if (child?.content != null) {
|
||||
next = typeof child.content === 'object' ? JSON.stringify(child.content) : String(child.content);
|
||||
} else if (child?.children) {
|
||||
const nested = extractText(child.children, limit - Array.from(text).length);
|
||||
text += nested.text;
|
||||
if (nested.truncated) {
|
||||
return { text, truncated: true };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const appended = appendBoundedText(text, next, limit);
|
||||
text = appended.text;
|
||||
if (appended.truncated) {
|
||||
return { text, truncated: true };
|
||||
}
|
||||
}
|
||||
|
||||
return { text, truncated: false };
|
||||
}
|
||||
|
||||
export function McpContainerNode(props: NodeComponentProps<any>) {
|
||||
const { node, ctx, renderNode, indexKey } = props;
|
||||
|
||||
if (node.name !== 'mcp') {
|
||||
return (
|
||||
<div className={`vmr-container vmr-container-${node.name ?? 'unknown'}`}>
|
||||
{Array.isArray(node.children) && ctx && renderNode
|
||||
? node.children.map((child: any, i: number) => (
|
||||
<React.Fragment key={`${String(indexKey ?? 'vmr-container')}-${i}`}>
|
||||
{renderNode(child, `${String(indexKey ?? 'vmr-container')}-${i}`, ctx)}
|
||||
</React.Fragment>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <McpToolCard node={node} />;
|
||||
}
|
||||
|
||||
const monoStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: 12,
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
padding: '4px 0',
|
||||
};
|
||||
|
||||
function McpToolCard({ node }: { node: any }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const serverName = safeGetAttr(node.attrs, 'name') ?? 'MCP';
|
||||
const toolName = safeGetAttr(node.attrs, 'tool') ?? 'unknown';
|
||||
const rawArgs = safeGetAttr(node.attrs, 'arguments');
|
||||
const isLoading = Boolean(node.loading);
|
||||
|
||||
const [activeKey, setActiveKey] = useState<string[]>(isLoading ? ['1'] : []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
setActiveKey(['1']);
|
||||
} else {
|
||||
setActiveKey([]);
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
const resultText = useMemo(() => {
|
||||
if (isLoading) return '';
|
||||
const result = extractText(node.children);
|
||||
if (!result.truncated) return result.text;
|
||||
return `${result.text}\n\n[${MCP_NODE_TRUNCATED_NOTICE}]`;
|
||||
}, [isLoading, node.children]);
|
||||
|
||||
const isError = useMemo(() => {
|
||||
if (isLoading) return false;
|
||||
const trimmed = resultText.trim();
|
||||
return (
|
||||
trimmed.startsWith('Error:') ||
|
||||
trimmed.startsWith('Error executing tool:') ||
|
||||
trimmed.startsWith('错误')
|
||||
);
|
||||
}, [isLoading, resultText]);
|
||||
|
||||
const status = isLoading ? 'running' : isError ? 'error' : 'success';
|
||||
|
||||
const statusIcon = useMemo(() => {
|
||||
if (status === 'running') return <Loader size={12} className="animate-spin" />;
|
||||
if (status === 'error') return <XCircle size={12} />;
|
||||
return <CheckCircle size={12} />;
|
||||
}, [status]);
|
||||
|
||||
const statusColor = status === 'running' ? 'processing' : status === 'error' ? 'error' : 'success';
|
||||
const statusLabel = status === 'running'
|
||||
? t('chat.tool.running')
|
||||
: status === 'error'
|
||||
? t('chat.tool.error')
|
||||
: t('chat.tool.success');
|
||||
|
||||
const decodedArgs = useMemo(() => {
|
||||
if (!rawArgs) return null;
|
||||
try {
|
||||
const decoded = rawArgs.includes('%') ? decodeURIComponent(rawArgs) : rawArgs;
|
||||
return JSON.stringify(JSON.parse(decoded), null, 2);
|
||||
} catch {
|
||||
return rawArgs;
|
||||
}
|
||||
}, [rawArgs]);
|
||||
|
||||
const headerLabel = (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<Tag icon={statusIcon} color={statusColor} style={{ margin: 0 }}>{statusLabel}</Tag>
|
||||
<Tag style={{ margin: 0 }}>{serverName}</Tag>
|
||||
<span style={{ fontSize: 13 }}>{toolName}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
const hasContent = Boolean(decodedArgs) || Boolean(!isLoading && resultText);
|
||||
|
||||
return (
|
||||
<span style={{ display: 'block', margin: '8px 0' }}>
|
||||
<Collapse
|
||||
size="small"
|
||||
activeKey={activeKey}
|
||||
onChange={(keys) => setActiveKey(keys as string[])}
|
||||
items={hasContent ? [{
|
||||
key: '1',
|
||||
label: headerLabel,
|
||||
children: (
|
||||
<>
|
||||
{decodedArgs && (
|
||||
<div style={{ marginBottom: resultText ? 8 : 0 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>
|
||||
{t('chat.tool.input')}
|
||||
</div>
|
||||
<span style={{ ...monoStyle, maxHeight: 200 }}>{decodedArgs}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && resultText && (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>
|
||||
{t('chat.tool.output')}
|
||||
</div>
|
||||
<span style={monoStyle}>{resultText}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}] : [{
|
||||
key: '1',
|
||||
label: headerLabel,
|
||||
children: null,
|
||||
}]}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useState } from 'react';
|
||||
import { theme } from 'antd';
|
||||
import { Brain, ChevronDown, ChevronRight, AlertCircle, Database } from 'lucide-react';
|
||||
import type { NodeComponentProps } from 'markstream-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { MemorySourceResult, MemoryRetrievedItem } from '@/lib/memoryUtils';
|
||||
|
||||
type MemoryRetrievalNodeData = {
|
||||
type: 'memory-retrieval';
|
||||
content?: string;
|
||||
attrs?: Record<string, string> | [string, string][];
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
function getAttrValue(
|
||||
attrs: MemoryRetrievalNodeData['attrs'],
|
||||
key: string,
|
||||
): string | undefined {
|
||||
if (!attrs) return undefined;
|
||||
if (Array.isArray(attrs)) {
|
||||
const entry = attrs.find(([name]) => name === key);
|
||||
return entry?.[1];
|
||||
}
|
||||
return attrs[key];
|
||||
}
|
||||
|
||||
function truncateContent(text: string, maxLen = 120): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen) + '…';
|
||||
}
|
||||
|
||||
export function MemoryRetrievalNode(props: NodeComponentProps<MemoryRetrievalNodeData>) {
|
||||
const { node } = props;
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Guard against undefined node (e.g. from malformed HTML comment prefixes in old data)
|
||||
if (!node) return null;
|
||||
|
||||
const status = getAttrValue(node.attrs, 'status') ?? (node.loading ? 'searching' : 'done');
|
||||
|
||||
let sources: MemorySourceResult[] = [];
|
||||
if (node.content) {
|
||||
try {
|
||||
const parsed = JSON.parse(node.content);
|
||||
if (Array.isArray(parsed)) sources = parsed;
|
||||
} catch {
|
||||
// invalid JSON
|
||||
}
|
||||
}
|
||||
|
||||
const totalItems = sources.reduce((sum, s) => sum + s.items.length, 0);
|
||||
|
||||
// Searching state
|
||||
if (status === 'searching') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorFillQuaternary,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="animate-spin"
|
||||
style={{ display: 'inline-flex', width: 16, height: 16 }}
|
||||
>
|
||||
<Brain size={16} style={{ color: token.colorPrimary }} />
|
||||
</span>
|
||||
<span style={{ color: token.colorTextSecondary, fontSize: 13 }}>
|
||||
{t('chat.memoryRetrieval.searching')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorErrorBg,
|
||||
color: token.colorError,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} />
|
||||
<span>{node.content || t('chat.memoryRetrieval.error')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'empty') {
|
||||
const reason = getAttrValue(node.attrs, 'reason');
|
||||
const messageKey = reason === 'threshold_filtered'
|
||||
? 'chat.memoryRetrieval.emptyThreshold'
|
||||
: 'chat.memoryRetrieval.empty';
|
||||
const defaultValue = reason === 'threshold_filtered'
|
||||
? '未找到符合检索阈值的记忆内容,可适当降低检索阈值。'
|
||||
: '未找到相关记忆内容。';
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorWarningBg,
|
||||
color: token.colorWarningText,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<Brain size={16} />
|
||||
<span>{t(messageKey, { defaultValue })}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Done state — no results
|
||||
if (totalItems === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: token.colorFillQuaternary,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Brain size={14} style={{ color: token.colorPrimary }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{t('chat.memoryRetrieval.resultsCount', {
|
||||
count: totalItems,
|
||||
defaultValue: '检索到 {{count}} 条记忆',
|
||||
})}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', color: token.colorTextTertiary }}>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Per-item overview */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
padding: '6px 12px',
|
||||
flexWrap: 'wrap',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{sources.flatMap((src, si) =>
|
||||
src.items.map((item, ii) => (
|
||||
<span
|
||||
key={`${si}-${ii}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '2px 8px',
|
||||
fontSize: 11,
|
||||
borderRadius: 4,
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
color: token.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
<Database size={10} style={{ flexShrink: 0 }} />
|
||||
{item.id ? (
|
||||
<span style={{ opacity: 0.5 }}>#{item.id.slice(0, 6)}</span>
|
||||
) : (
|
||||
<span style={{ opacity: 0.5 }}>{item.document_id?.slice(0, 8) || '—'}</span>
|
||||
)}
|
||||
<span style={{ color: token.colorPrimary, fontFamily: 'monospace' }}>
|
||||
{(1 / (1 + item.score)).toFixed(3)}
|
||||
</span>
|
||||
</span>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{sources.map((src, si) =>
|
||||
src.items.map((item: MemoryRetrievedItem, ii: number) => (
|
||||
<div
|
||||
key={`${si}-${ii}`}
|
||||
style={{
|
||||
marginBottom: ii < src.items.length - 1 || si < sources.length - 1 ? 8 : 0,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<Database size={12} style={{ color: token.colorPrimary, flexShrink: 0 }} />
|
||||
<span style={{ fontWeight: 500, color: token.colorText }}>
|
||||
{t('chat.memoryRetrieval.label')}
|
||||
</span>
|
||||
{item.id && (
|
||||
<span style={{ fontSize: 10, color: token.colorTextQuaternary }}>
|
||||
#{item.id.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: 10,
|
||||
color: token.colorTextQuaternary,
|
||||
}}
|
||||
>
|
||||
{(1 / (1 + item.score)).toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
margin: '2px 0 0 0',
|
||||
color: token.colorTextSecondary,
|
||||
lineHeight: 1.5,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{truncateContent(item.content, 200)}
|
||||
</p>
|
||||
</div>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Tooltip, theme } from 'antd';
|
||||
import { Copy, Check, ChevronRight, Download, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { MermaidBlockActionContext } from 'markstream-react';
|
||||
|
||||
interface Props {
|
||||
ctx: MermaidBlockActionContext;
|
||||
}
|
||||
|
||||
export const MermaidBlockHeaderActions: React.FC<Props> = ({ ctx }) => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
||||
|
||||
const getBtnStyle = useCallback((idx: number, disabled?: boolean): React.CSSProperties => ({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: token.borderRadiusSM,
|
||||
border: 'none',
|
||||
background: !disabled && hoveredIdx === idx ? (token.colorFillSecondary || 'rgba(255,255,255,0.1)') : 'transparent',
|
||||
color: !disabled && hoveredIdx === idx ? token.colorText : token.colorTextSecondary,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
padding: 0,
|
||||
transition: 'color 0.2s, background 0.2s',
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
}), [hoveredIdx, token]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{/* Collapse */}
|
||||
<Tooltip title={ctx.collapsed ? t('common.expand') : t('common.collapse')} mouseEnterDelay={0.4}>
|
||||
<button type="button" style={getBtnStyle(0)} onClick={ctx.toggleCollapse}
|
||||
onMouseEnter={() => setHoveredIdx(0)} onMouseLeave={() => setHoveredIdx(null)}>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
style={{
|
||||
transform: ctx.collapsed ? 'rotate(0deg)' : 'rotate(90deg)',
|
||||
transition: 'transform 0.2s',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Copy */}
|
||||
<Tooltip title={ctx.copied ? t('common.copied') : t('common.copy')} mouseEnterDelay={0.4}>
|
||||
<button type="button" style={getBtnStyle(1)} onClick={ctx.copy}
|
||||
onMouseEnter={() => setHoveredIdx(1)} onMouseLeave={() => setHoveredIdx(null)}>
|
||||
{ctx.copied
|
||||
? <Check size={14} style={{ color: token.colorSuccess }} />
|
||||
: <Copy size={14} />
|
||||
}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Export / Download */}
|
||||
{ctx.mermaidAvailable && (
|
||||
<Tooltip title={t('common.export')} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
style={getBtnStyle(2, ctx.isExportDisabled)}
|
||||
disabled={ctx.isExportDisabled}
|
||||
onClick={ctx.exportSvg}
|
||||
onMouseEnter={() => setHoveredIdx(2)}
|
||||
onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Fullscreen */}
|
||||
{ctx.mermaidAvailable && (
|
||||
<Tooltip title={ctx.modalOpen ? t('settings.exitFullscreen') : t('settings.fullscreen')} mouseEnterDelay={0.4}>
|
||||
<button
|
||||
type="button"
|
||||
style={getBtnStyle(3, ctx.isExportDisabled)}
|
||||
disabled={ctx.isExportDisabled}
|
||||
onClick={ctx.toggleFullscreen}
|
||||
onMouseEnter={() => setHoveredIdx(3)}
|
||||
onMouseLeave={() => setHoveredIdx(null)}
|
||||
>
|
||||
{ctx.modalOpen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Tooltip, theme } from 'antd';
|
||||
import { ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { MermaidBlockActionContext } from 'markstream-react';
|
||||
|
||||
interface Props {
|
||||
ctx: MermaidBlockActionContext;
|
||||
}
|
||||
|
||||
export const MermaidZoomControls: React.FC<Props> = ({ ctx }) => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
||||
|
||||
const getBtnStyle = useCallback((idx: number): React.CSSProperties => ({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 28,
|
||||
height: 28,
|
||||
borderRadius: token.borderRadiusSM,
|
||||
border: 'none',
|
||||
background: hoveredIdx === idx ? (token.colorFillSecondary || 'rgba(255,255,255,0.1)') : 'transparent',
|
||||
color: hoveredIdx === idx ? token.colorText : token.colorTextSecondary,
|
||||
cursor: 'pointer',
|
||||
padding: '0 4px',
|
||||
transition: 'color 0.2s, background 0.2s',
|
||||
whiteSpace: 'nowrap',
|
||||
}), [hoveredIdx, token]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
backdropFilter: 'blur(8px)',
|
||||
backgroundColor: token.colorBgElevated + 'cc',
|
||||
padding: '2px 4px',
|
||||
}}
|
||||
>
|
||||
<Tooltip title={t('common.increase')} mouseEnterDelay={0.4}>
|
||||
<button type="button" style={getBtnStyle(0)} onClick={ctx.zoomIn}
|
||||
onMouseEnter={() => setHoveredIdx(0)} onMouseLeave={() => setHoveredIdx(null)}>
|
||||
<ZoomIn size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.decrease')} mouseEnterDelay={0.4}>
|
||||
<button type="button" style={getBtnStyle(1)} onClick={ctx.zoomOut}
|
||||
onMouseEnter={() => setHoveredIdx(1)} onMouseLeave={() => setHoveredIdx(null)}>
|
||||
<ZoomOut size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('common.reset')} mouseEnterDelay={0.4}>
|
||||
<button type="button" style={getBtnStyle(2)} onClick={ctx.resetZoom}
|
||||
onMouseEnter={() => setHoveredIdx(2)} onMouseLeave={() => setHoveredIdx(null)}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500 }}>{Math.round(ctx.zoom * 100)}%</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,647 @@
|
||||
import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { Tag, Modal, Input, theme, Tooltip, Button, Checkbox } from 'antd';
|
||||
import { Search, Settings, Pin, PinOff, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown, Eye, Wrench, Lightbulb, Mic, MessageSquare, Check, GitCompareArrows } from 'lucide-react';
|
||||
import { ModelIcon } from '@lobehub/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useProviderStore, useConversationStore, useSettingsStore, useUIStore } from '@/stores';
|
||||
import { SmartProviderIcon } from '@/lib/providerIcons';
|
||||
import { getShortcutBinding, formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { getVisibleModelCapabilities } from '@/lib/modelCapabilities';
|
||||
import type { ModelCapability, Model } from '@/types';
|
||||
import { ConversationModelIcon } from './ConversationModelIcon';
|
||||
|
||||
const PINNED_MODELS_KEY = 'boosapi_pinned_models';
|
||||
|
||||
const CAPABILITY_COLORS: Record<ModelCapability, string> = {
|
||||
TextChat: 'blue', Vision: 'green', FunctionCalling: 'purple', Reasoning: 'orange', RealtimeVoice: 'red',
|
||||
};
|
||||
const CAPABILITY_ICONS: Record<ModelCapability, React.ReactNode> = {
|
||||
TextChat: <MessageSquare size={11} />, Vision: <Eye size={11} />, FunctionCalling: <Wrench size={11} />,
|
||||
Reasoning: <Lightbulb size={11} />, RealtimeVoice: <Mic size={11} />,
|
||||
};
|
||||
|
||||
function formatTokenCount(tokens: number): string {
|
||||
if (tokens >= 1000000) { const m = tokens / 1000000; return m % 1 === 0 ? `${m}M` : `${m.toFixed(1)}M`; }
|
||||
if (tokens >= 1000) { const k = tokens / 1000; return k % 1 === 0 ? `${k}K` : `${k.toFixed(1)}K`; }
|
||||
return `${tokens}`;
|
||||
}
|
||||
|
||||
function loadPinnedModels(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(PINNED_MODELS_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function savePinnedModels(ids: string[]) {
|
||||
localStorage.setItem(PINNED_MODELS_KEY, JSON.stringify(ids));
|
||||
}
|
||||
|
||||
function isSelectableConversationModel(model: Pick<Model, 'model_type'>, includeImageModels: boolean) {
|
||||
return model.model_type === 'Chat' || (includeImageModels && model.model_type === 'Image');
|
||||
}
|
||||
|
||||
interface ModelSelectorProps {
|
||||
style?: React.CSSProperties;
|
||||
/** Custom select callback. When provided, overrides the default conversation/settings update. */
|
||||
onSelect?: (providerId: string, modelId: string) => void;
|
||||
/** Override which model is highlighted as current (e.g. for per-message model switching). */
|
||||
overrideCurrentModel?: { providerId: string; modelId: string } | null;
|
||||
/** Custom trigger element. When provided, renders this instead of the default Tag. */
|
||||
children?: React.ReactNode;
|
||||
/** Controlled open state */
|
||||
open?: boolean;
|
||||
/** Callback when open state changes */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Enable multi-select mode (checkboxes + confirm button) */
|
||||
multiSelect?: boolean;
|
||||
/** Callback for multi-select mode. Returns array of selected models. */
|
||||
onMultiSelect?: (models: Array<{ providerId: string; modelId: string }>) => void;
|
||||
/** Pre-selected models for multi-select mode */
|
||||
defaultSelectedModels?: Array<{ providerId: string; modelId: string }>;
|
||||
/** Model keys to exclude from the list (format: "providerId::modelId") */
|
||||
excludeModelKeys?: string[];
|
||||
}
|
||||
|
||||
export function ModelSelector({ style, onSelect, overrideCurrentModel, children, open: controlledOpen, onOpenChange, multiSelect, onMultiSelect, defaultSelectedModels, excludeModelKeys }: ModelSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const { providers } = useProviderStore();
|
||||
const { activeConversationId, conversations, updateConversation } =
|
||||
useConversationStore();
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const saveSettings = useSettingsStore((s) => s.saveSettings);
|
||||
const showImageModels = settings.show_image_models_in_model_selector ?? false;
|
||||
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
if (onOpenChange) onOpenChange(v);
|
||||
if (!isControlled) setInternalOpen(v);
|
||||
}, [isControlled, onOpenChange]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [pinnedModels, setPinnedModels] = useState<string[]>(loadPinnedModels);
|
||||
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
||||
const listParentRef = useRef<HTMLDivElement>(null);
|
||||
const setActivePage = useUIStore((s) => s.setActivePage);
|
||||
const setSettingsSection = useUIStore((s) => s.setSettingsSection);
|
||||
const setSelectedProviderId = useUIStore((s) => s.setSelectedProviderId);
|
||||
|
||||
// Multi-select state
|
||||
const [multiSelectedKeys, setMultiSelectedKeys] = useState<Set<string>>(new Set());
|
||||
|
||||
// Reset multi-select state when modal opens with default selections
|
||||
useEffect(() => {
|
||||
if (open && multiSelect) {
|
||||
const initial = new Set(
|
||||
(defaultSelectedModels ?? []).map((m) => `${m.providerId}::${m.modelId}`),
|
||||
);
|
||||
setMultiSelectedKeys(initial);
|
||||
}
|
||||
}, [open, multiSelect, defaultSelectedModels]);
|
||||
|
||||
const activeConversation = useMemo(
|
||||
() => conversations.find((c) => c.id === activeConversationId),
|
||||
[conversations, activeConversationId],
|
||||
);
|
||||
|
||||
const currentModel = useMemo(() => {
|
||||
let pid: string | undefined;
|
||||
let mid: string | undefined;
|
||||
if (activeConversation) {
|
||||
pid = activeConversation.provider_id;
|
||||
mid = activeConversation.model_id;
|
||||
} else if (settings.default_provider_id && settings.default_model_id) {
|
||||
pid = settings.default_provider_id;
|
||||
mid = settings.default_model_id;
|
||||
} else {
|
||||
for (const p of providers) {
|
||||
if (!p.enabled) continue;
|
||||
const m = p.models.find((m) => m.enabled && isSelectableConversationModel(m, showImageModels));
|
||||
if (m) { pid = p.id; mid = m.model_id; break; }
|
||||
}
|
||||
}
|
||||
if (!pid || !mid) return null;
|
||||
const provider = providers.find((p) => p.id === pid);
|
||||
const model = provider?.models.find((m) => m.model_id === mid);
|
||||
return { pid, mid, name: model?.name ?? mid, providerName: provider?.name ?? '' };
|
||||
}, [activeConversation, settings.default_provider_id, settings.default_model_id, providers, showImageModels]);
|
||||
|
||||
const currentValue = overrideCurrentModel
|
||||
? `${overrideCurrentModel.providerId}::${overrideCurrentModel.modelId}`
|
||||
: currentModel ? `${currentModel.pid}::${currentModel.mid}` : undefined;
|
||||
|
||||
// All enabled models flat list (for pinned section)
|
||||
const allEnabledModels = useMemo(() => {
|
||||
const result: { pid: string; mid: string; name: string; providerName: string; model: Model }[] = [];
|
||||
for (const p of providers) {
|
||||
if (!p.enabled) continue;
|
||||
for (const m of p.models) {
|
||||
if (!m.enabled) continue;
|
||||
if (!isSelectableConversationModel(m, showImageModels)) continue;
|
||||
const key = `${p.id}::${m.model_id}`;
|
||||
if (excludeModelKeys?.includes(key)) continue;
|
||||
result.push({ pid: p.id, mid: m.model_id, name: m.name, providerName: p.name, model: m });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [providers, excludeModelKeys, showImageModels]);
|
||||
|
||||
// Pinned models resolved with search filter
|
||||
const pinnedItems = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
return pinnedModels
|
||||
.map((key) => {
|
||||
const model = allEnabledModels.find((m) => `${m.pid}::${m.mid}` === key);
|
||||
return model ? { ...model, key } : null;
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> =>
|
||||
item !== null && (!q || item.name.toLowerCase().includes(q) || item.mid.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [pinnedModels, allEnabledModels, search]);
|
||||
|
||||
// Filtered providers and models (excluding search)
|
||||
const filteredProviders = useMemo(() => {
|
||||
const q = search.toLowerCase().trim();
|
||||
return providers
|
||||
.filter((p) => p.enabled)
|
||||
.map((p) => ({
|
||||
...p,
|
||||
models: p.models.filter(
|
||||
(m) => {
|
||||
if (!m.enabled) return false;
|
||||
if (!isSelectableConversationModel(m, showImageModels)) return false;
|
||||
if (excludeModelKeys?.includes(`${p.id}::${m.model_id}`)) return false;
|
||||
if (!q) return true;
|
||||
return m.name.toLowerCase().includes(q) || m.model_id.toLowerCase().includes(q) || p.name.toLowerCase().includes(q);
|
||||
},
|
||||
),
|
||||
}))
|
||||
.filter((p) => p.models.length > 0);
|
||||
}, [providers, search, excludeModelKeys, showImageModels]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(providerId: string, modelId: string) => {
|
||||
if (multiSelect) {
|
||||
// In multi-select mode, toggle the selection
|
||||
const key = `${providerId}::${modelId}`;
|
||||
setMultiSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (onSelect) {
|
||||
onSelect(providerId, modelId);
|
||||
} else if (activeConversationId) {
|
||||
updateConversation(activeConversationId, {
|
||||
provider_id: providerId,
|
||||
model_id: modelId,
|
||||
});
|
||||
} else {
|
||||
saveSettings({ default_provider_id: providerId, default_model_id: modelId });
|
||||
}
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
},
|
||||
[activeConversationId, updateConversation, saveSettings, onSelect, setOpen, multiSelect],
|
||||
);
|
||||
|
||||
const handleMultiConfirm = useCallback(() => {
|
||||
if (!onMultiSelect) return;
|
||||
const models = Array.from(multiSelectedKeys).map((key) => {
|
||||
const [providerId, modelId] = key.split('::');
|
||||
return { providerId, modelId };
|
||||
});
|
||||
onMultiSelect(models);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
}, [multiSelectedKeys, onMultiSelect, setOpen]);
|
||||
|
||||
const togglePin = useCallback((key: string) => {
|
||||
setPinnedModels((prev) => {
|
||||
const next = prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key];
|
||||
savePinnedModels(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Only the standalone header ModelSelector responds to the shortcut.
|
||||
// Bubble-action instances (onSelect) and multi-select (multiSelect) should not.
|
||||
if (multiSelect || onSelect) return;
|
||||
const onToggle = () => setOpen(!open);
|
||||
window.addEventListener('boosapi:toggle-model-selector', onToggle);
|
||||
return () => {
|
||||
window.removeEventListener('boosapi:toggle-model-selector', onToggle);
|
||||
};
|
||||
}, [open, setOpen, multiSelect, onSelect]);
|
||||
|
||||
// Auto-expand all groups when providers change or modal opens
|
||||
const providerIds = useMemo(() => filteredProviders.map((p) => p.id), [filteredProviders]);
|
||||
useEffect(() => {
|
||||
if (open) setExpandedGroups(providerIds);
|
||||
}, [open, providerIds]);
|
||||
|
||||
const allGroupsExpanded = expandedGroups.length >= providerIds.length;
|
||||
|
||||
const toggleGroupExpand = useCallback((providerId: string) => {
|
||||
setExpandedGroups((prev) =>
|
||||
prev.includes(providerId) ? prev.filter((id) => id !== providerId) : [...prev, providerId],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleAllGroups = useCallback(() => {
|
||||
setExpandedGroups((prev) => (prev.length >= providerIds.length ? [] : [...providerIds]));
|
||||
listParentRef.current?.scrollTo({ top: 0 });
|
||||
}, [providerIds]);
|
||||
|
||||
// Flatten into virtualizable rows
|
||||
type ListRow =
|
||||
| { type: 'pinned-header' }
|
||||
| { type: 'pinned-model'; pid: string; mid: string; name: string; providerName: string; key: string; model: Model }
|
||||
| { type: 'pinned-divider' }
|
||||
| { type: 'group'; provider: typeof filteredProviders[number] }
|
||||
| { type: 'model'; providerId: string; model: typeof filteredProviders[number]['models'][number]; providerName: string };
|
||||
|
||||
const hasSearchQuery = search.trim().length > 0;
|
||||
const flatRows = useMemo<ListRow[]>(() => {
|
||||
const rows: ListRow[] = [];
|
||||
if (pinnedItems.length > 0) {
|
||||
rows.push({ type: 'pinned-header' });
|
||||
for (const item of pinnedItems) {
|
||||
rows.push({ type: 'pinned-model', ...item });
|
||||
}
|
||||
rows.push({ type: 'pinned-divider' });
|
||||
}
|
||||
for (const provider of filteredProviders) {
|
||||
rows.push({ type: 'group', provider });
|
||||
// When searching, always expand all groups to avoid timing issues with expandedGroups state
|
||||
if (hasSearchQuery || expandedGroups.includes(provider.id)) {
|
||||
for (const model of provider.models) {
|
||||
rows.push({ type: 'model', providerId: provider.id, model, providerName: provider.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}, [pinnedItems, filteredProviders, expandedGroups, hasSearchQuery]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flatRows.length,
|
||||
getScrollElement: () => listParentRef.current,
|
||||
estimateSize: (index) => {
|
||||
const row = flatRows[index];
|
||||
if (row.type === 'pinned-divider') return 12;
|
||||
if (row.type === 'pinned-header') return 32;
|
||||
if (row.type === 'group') return 40;
|
||||
return 36;
|
||||
},
|
||||
getItemKey: (index) => {
|
||||
const row = flatRows[index];
|
||||
switch (row.type) {
|
||||
case 'pinned-header': return 'ph';
|
||||
case 'pinned-divider': return 'pd';
|
||||
case 'pinned-model': return `pm-${row.key}`;
|
||||
case 'group': return `g-${row.provider.id}`;
|
||||
case 'model': return `m-${row.providerId}::${row.model.model_id}`;
|
||||
}
|
||||
},
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
// Reset scroll to top when search changes so filtered results are visible
|
||||
useEffect(() => {
|
||||
if (search) {
|
||||
virtualizer.scrollToIndex(0);
|
||||
}
|
||||
}, [search, virtualizer]);
|
||||
|
||||
// Indices of selectable (model/pinned-model) rows for keyboard navigation
|
||||
const selectableIndices = useMemo(
|
||||
() => flatRows.reduce<number[]>((acc, row, i) => {
|
||||
if (row.type === 'model' || row.type === 'pinned-model') acc.push(i);
|
||||
return acc;
|
||||
}, []),
|
||||
[flatRows],
|
||||
);
|
||||
|
||||
// Reset activeIndex when search or flatRows change
|
||||
useEffect(() => {
|
||||
setActiveIndex(selectableIndices.length > 0 ? selectableIndices[0] : -1);
|
||||
}, [search, selectableIndices]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (selectableIndices.length === 0) return;
|
||||
const curPos = selectableIndices.indexOf(activeIndex);
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const next = curPos < selectableIndices.length - 1 ? selectableIndices[curPos + 1] : selectableIndices[0];
|
||||
setActiveIndex(next);
|
||||
virtualizer.scrollToIndex(next, { align: 'auto' });
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const prev = curPos > 0 ? selectableIndices[curPos - 1] : selectableIndices[selectableIndices.length - 1];
|
||||
setActiveIndex(prev);
|
||||
virtualizer.scrollToIndex(prev, { align: 'auto' });
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && activeIndex < flatRows.length) {
|
||||
const row = flatRows[activeIndex];
|
||||
if (row.type === 'model') handleSelect(row.providerId, row.model.model_id);
|
||||
else if (row.type === 'pinned-model') handleSelect(row.pid, row.mid);
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectableIndices, activeIndex, flatRows, virtualizer, handleSelect],
|
||||
);
|
||||
|
||||
const renderModelItem = (
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
modelName: string,
|
||||
providerName: string,
|
||||
isPinned: boolean,
|
||||
showProviderTag: boolean,
|
||||
model?: Model,
|
||||
isKeyboardActive?: boolean,
|
||||
) => {
|
||||
const key = `${providerId}::${modelId}`;
|
||||
const isActive = multiSelect ? multiSelectedKeys.has(key) : currentValue === key;
|
||||
const isHovered = hoveredKey === key || isKeyboardActive;
|
||||
const visibleCaps = model ? getVisibleModelCapabilities(model) : [];
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: isActive ? token.colorPrimaryBg : isHovered ? token.colorFillSecondary : undefined,
|
||||
borderRadius: 6,
|
||||
margin: '0 6px',
|
||||
padding: '5px 10px',
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
onClick={() => handleSelect(providerId, modelId)}
|
||||
onMouseEnter={() => { setHoveredKey(key); setActiveIndex(-1); }}
|
||||
onMouseLeave={() => setHoveredKey(null)}
|
||||
>
|
||||
{multiSelect && (
|
||||
<Checkbox
|
||||
checked={isActive}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
)}
|
||||
<ModelIcon model={modelId} size={20} type="avatar" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{showProviderTag && providerName && (
|
||||
<Tag style={{ fontSize: 11, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0, color: token.colorPrimary, backgroundColor: token.colorPrimaryBg, border: 'none' }}>{providerName}</Tag>
|
||||
)}
|
||||
<span style={{ fontSize: 13, color: isActive ? token.colorPrimary : undefined, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{modelName}</span>
|
||||
{visibleCaps.map((cap) => (
|
||||
<Tooltip key={cap} title={t(`settings.capability.${cap}`, cap)}>
|
||||
<Tag color={CAPABILITY_COLORS[cap]} bordered={false} style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px', margin: 0 }}>
|
||||
{CAPABILITY_ICONS[cap]}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
))}
|
||||
{model?.max_tokens != null && model.max_tokens > 0 && (
|
||||
<Tag bordered={false} color="default" style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px', margin: 0 }}>
|
||||
{formatTokenCount(model.max_tokens)}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!multiSelect && (
|
||||
<div className="flex items-center gap-1" style={{ flexShrink: 0 }} onClick={(e) => e.stopPropagation()}>
|
||||
<span
|
||||
style={{ cursor: 'pointer', color: isPinned ? token.colorPrimary : token.colorTextQuaternary, fontSize: 14 }}
|
||||
onClick={() => togglePin(key)}
|
||||
>
|
||||
{isPinned ? <PinOff size={14} /> : <Pin size={14} />}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{multiSelect && isActive && (
|
||||
<Check size={14} style={{ color: token.colorPrimary, flexShrink: 0 }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{children ? (
|
||||
<span onClick={() => setOpen(true)}>{children}</span>
|
||||
) : (
|
||||
<Tooltip title={`${t('chat.switchModel')} (${formatShortcutForDisplay(getShortcutBinding(settings, 'toggleModelSelector'))})`} placement="bottom">
|
||||
<Tag
|
||||
onClick={() => setOpen(true)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '2px 10px',
|
||||
fontSize: 13,
|
||||
borderRadius: 6,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{currentModel && (
|
||||
<>
|
||||
<ConversationModelIcon model={currentModel.mid} size={16} />
|
||||
{currentModel.providerName && (
|
||||
<Tag style={{ fontSize: 11, margin: 0, padding: '0 4px', lineHeight: '16px', color: token.colorPrimary, backgroundColor: token.colorPrimaryBg, border: 'none' }}>{currentModel.providerName}</Tag>
|
||||
)}
|
||||
<span>{currentModel.name}</span>
|
||||
</>
|
||||
)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={() => { setOpen(false); setSearch(''); }}
|
||||
mask={{ enabled: true, blur: true }}
|
||||
footer={multiSelect ? (
|
||||
<div className="flex items-center justify-between" style={{ padding: '8px 12px' }}>
|
||||
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>
|
||||
{t('chat.multiModel.selectedCount').replace('{{count}}', String(multiSelectedKeys.size))}
|
||||
</span>
|
||||
<Button type="primary" size="small" onClick={handleMultiConfirm}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
title={multiSelect ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, paddingLeft: 4 }}>
|
||||
<GitCompareArrows size={16} />
|
||||
{t('chat.multiModel.selectTitle')}
|
||||
</span>
|
||||
) : null}
|
||||
closable={false}
|
||||
width={420}
|
||||
styles={{
|
||||
body: { padding: 0, maxHeight: '60vh', display: 'flex', flexDirection: 'column' },
|
||||
}}
|
||||
rootClassName="model-selector-modal"
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
{/* Search + Expand/Collapse */}
|
||||
<div className="flex items-center gap-2" style={{ padding: '8px 8px 4px' }}>
|
||||
<Input
|
||||
prefix={<Search size={14} style={{ color: token.colorTextSecondary }} />}
|
||||
placeholder={t('chat.searchModelOrProvider')}
|
||||
variant="borderless"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorFillTertiary,
|
||||
}}
|
||||
/>
|
||||
<Tooltip title={allGroupsExpanded ? t('common.collapseAll') : t('common.expandAll')}>
|
||||
<span
|
||||
style={{ cursor: 'pointer', color: token.colorTextSecondary, flexShrink: 0, display: 'flex', alignItems: 'center', padding: 4 }}
|
||||
onClick={toggleAllGroups}
|
||||
>
|
||||
{allGroupsExpanded ? <ChevronsDownUp size={16} /> : <ChevronsUpDown size={16} />}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Virtualized model list */}
|
||||
<div
|
||||
ref={listParentRef}
|
||||
data-os-scrollbar
|
||||
style={{ overflowY: 'auto', flex: 1, padding: '2px 0 4px' }}
|
||||
>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = flatRows[virtualRow.index];
|
||||
|
||||
if (row.type === 'pinned-header') {
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="flex items-center px-3 pt-2 pb-0.5"
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
color: token.colorTextSecondary, fontSize: 12, fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<PinOff size={11} style={{ marginRight: 4 }} />
|
||||
<span>{t('chat.pinnedModels')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.type === 'pinned-model') {
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderModelItem(row.pid, row.mid, row.name, row.providerName, true, true, row.model, virtualRow.index === activeIndex)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.type === 'pinned-divider') {
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
padding: '4px 14px',
|
||||
}}
|
||||
>
|
||||
<div style={{ borderTop: `1px solid ${token.colorBorderSecondary}` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.type === 'group') {
|
||||
const isExpanded = hasSearchQuery || expandedGroups.includes(row.provider.id);
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
padding: '2px 8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer"
|
||||
style={{ userSelect: 'none', background: 'var(--ant-color-fill-quaternary, rgba(0,0,0,0.02))' }}
|
||||
onClick={() => toggleGroupExpand(row.provider.id)}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<SmartProviderIcon provider={row.provider} size={20} type="avatar" />
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{row.provider.name}</span>
|
||||
<Tag style={{ fontSize: 11, lineHeight: '18px', padding: '0 6px', margin: 0 }}>{row.provider.models.length}</Tag>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Settings
|
||||
size={14}
|
||||
style={{ cursor: 'pointer', color: token.colorTextQuaternary }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setActivePage('settings');
|
||||
setSettingsSection('providers');
|
||||
setSelectedProviderId(row.provider.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// type === 'model'
|
||||
const isPinned = pinnedModels.includes(`${row.providerId}::${row.model.model_id}`);
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderModelItem(row.providerId, row.model.model_id, row.model.name, row.providerName, isPinned, false, row.model, virtualRow.index === activeIndex)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import { Alert, Button, Dropdown, Popconfirm, Tag, Tooltip, Typography, theme } from 'antd';
|
||||
import { ArrowLeftRight, Check, ChevronLeft, ChevronRight, Columns2, GitBranch, LayoutList, Pencil, RotateCcw, Rows3, Trash2 } from 'lucide-react';
|
||||
import { ModelIcon } from '@lobehub/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { OverlayScrollbars } from 'overlayscrollbars';
|
||||
import type { Message } from '@/types';
|
||||
import { CopyButton } from '@/components/common/CopyButton';
|
||||
import { stripBoosapiTags } from '@/lib/chatMarkdown';
|
||||
import { getMessageVersionGroupKey, selectDisplayVersionsByModel } from '@/lib/chatMultiModel';
|
||||
import {
|
||||
getLiveStreamContent,
|
||||
subscribeLiveStreamContent,
|
||||
useConversationStore,
|
||||
} from '@/stores';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
|
||||
export type MultiModelDisplayMode = 'tabs' | 'side-by-side' | 'stacked';
|
||||
|
||||
function useLiveStreamContent(messageId: string | null | undefined, enabled: boolean): string | undefined {
|
||||
const subscribedMessageId = enabled ? messageId : null;
|
||||
return useSyncExternalStore(
|
||||
useCallback(
|
||||
(listener) => subscribeLiveStreamContent(subscribedMessageId, listener),
|
||||
[subscribedMessageId],
|
||||
),
|
||||
useCallback(
|
||||
() => getLiveStreamContent(subscribedMessageId),
|
||||
[subscribedMessageId],
|
||||
),
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function MultiModelVersionContent({
|
||||
message,
|
||||
isVersionStreaming,
|
||||
renderContent,
|
||||
}: {
|
||||
message: Message;
|
||||
isVersionStreaming: boolean;
|
||||
renderContent: (msg: Message, isVersionStreaming: boolean) => React.ReactNode;
|
||||
}) {
|
||||
const liveContent = useLiveStreamContent(message.id, isVersionStreaming);
|
||||
const renderMessage = liveContent === undefined ? message : { ...message, content: liveContent };
|
||||
return <>{renderContent(renderMessage, isVersionStreaming)}</>;
|
||||
}
|
||||
|
||||
/** Error boundary to prevent white-screen crashes in multi-model display */
|
||||
class MultiModelErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode; fallback?: React.ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode; fallback?: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback ?? (
|
||||
<Alert type="warning" message="Multi-model display error" showIcon />
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MultiModelDisplayProps {
|
||||
versions: Message[];
|
||||
activeMessageId: string;
|
||||
mode: 'side-by-side' | 'stacked';
|
||||
conversationId: string;
|
||||
onSwitchVersion: (parentMessageId: string, messageId: string) => void;
|
||||
onDeleteVersion?: (messageId: string) => void;
|
||||
onRegenerateVersion?: (message: Message) => void | Promise<void>;
|
||||
onEditVersion?: (message: Message) => void;
|
||||
onBranchVersion?: (message: Message, asChild: boolean) => void;
|
||||
onSwitchModelVersion?: (message: Message, providerId: string, modelId: string) => void | Promise<void>;
|
||||
onSetContextVersion?: (message: Message) => void;
|
||||
onDisplayVersionChange?: (parentMessageId: string, modelKey: string, messageId: string) => void;
|
||||
displayVersionIdsByModelKey?: ReadonlyMap<string, string>;
|
||||
renderContent: (msg: Message, isVersionStreaming: boolean) => React.ReactNode;
|
||||
getModelDisplayInfo: (
|
||||
modelId?: string | null,
|
||||
providerId?: string | null,
|
||||
) => { modelName: string; providerName: string };
|
||||
streamingMessageId?: string | null;
|
||||
multiModelDoneMessageIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders multiple model versions side-by-side or stacked.
|
||||
* Used when multi_model_display_mode is not 'tabs'.
|
||||
*/
|
||||
export const MultiModelDisplay = React.memo(function MultiModelDisplay({
|
||||
versions,
|
||||
activeMessageId,
|
||||
mode,
|
||||
conversationId,
|
||||
onSwitchVersion,
|
||||
onDeleteVersion,
|
||||
onRegenerateVersion,
|
||||
onEditVersion,
|
||||
onBranchVersion,
|
||||
onSwitchModelVersion,
|
||||
onSetContextVersion,
|
||||
onDisplayVersionChange,
|
||||
displayVersionIdsByModelKey,
|
||||
renderContent,
|
||||
getModelDisplayInfo,
|
||||
streamingMessageId,
|
||||
}: MultiModelDisplayProps) {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Safety: if versions is empty or invalid, render nothing
|
||||
if (!versions || versions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<MultiModelErrorBoundary>
|
||||
<MultiModelDisplayInner
|
||||
versions={versions}
|
||||
activeMessageId={activeMessageId}
|
||||
mode={mode}
|
||||
conversationId={conversationId}
|
||||
onSwitchVersion={onSwitchVersion}
|
||||
onDeleteVersion={onDeleteVersion}
|
||||
onRegenerateVersion={onRegenerateVersion}
|
||||
onEditVersion={onEditVersion}
|
||||
onBranchVersion={onBranchVersion}
|
||||
onSwitchModelVersion={onSwitchModelVersion}
|
||||
onSetContextVersion={onSetContextVersion}
|
||||
onDisplayVersionChange={onDisplayVersionChange}
|
||||
displayVersionIdsByModelKey={displayVersionIdsByModelKey}
|
||||
renderContent={renderContent}
|
||||
getModelDisplayInfo={getModelDisplayInfo}
|
||||
streamingMessageId={streamingMessageId}
|
||||
token={token}
|
||||
t={t}
|
||||
/>
|
||||
</MultiModelErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
interface MultiModelDisplayInnerProps extends Omit<MultiModelDisplayProps, 'multiModelDoneMessageIds'> {
|
||||
token: ReturnType<typeof theme.useToken>['token'];
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
}
|
||||
|
||||
function MultiModelDisplayInner({
|
||||
versions,
|
||||
activeMessageId,
|
||||
mode,
|
||||
conversationId,
|
||||
onSwitchVersion,
|
||||
onDeleteVersion,
|
||||
onRegenerateVersion,
|
||||
onEditVersion,
|
||||
onBranchVersion,
|
||||
onSwitchModelVersion,
|
||||
onSetContextVersion,
|
||||
onDisplayVersionChange,
|
||||
displayVersionIdsByModelKey,
|
||||
renderContent,
|
||||
getModelDisplayInfo,
|
||||
streamingMessageId,
|
||||
token,
|
||||
t,
|
||||
}: MultiModelDisplayInnerProps) {
|
||||
const parentMessageId = versions[0]?.parent_message_id;
|
||||
const storeMessages = useConversationStore((state) => state.messages);
|
||||
const storeStreaming = useConversationStore((state) => state.streaming);
|
||||
const streamingConversationId = useConversationStore((state) => state.streamingConversationId);
|
||||
const liveVersions = useMemo(() => {
|
||||
if (!parentMessageId) return [];
|
||||
return storeMessages.filter((message) =>
|
||||
message.parent_message_id === parentMessageId && message.role === 'assistant'
|
||||
);
|
||||
}, [parentMessageId, storeMessages]);
|
||||
const renderVersions = liveVersions.length > 0 ? liveVersions : versions;
|
||||
const displayVersions = useMemo(
|
||||
() => selectDisplayVersionsByModel(renderVersions, activeMessageId, displayVersionIdsByModelKey),
|
||||
[activeMessageId, displayVersionIdsByModelKey, renderVersions],
|
||||
);
|
||||
const isDisplayStreaming = storeStreaming && streamingConversationId === conversationId;
|
||||
|
||||
// For side-by-side mode, force the .ant-bubble ancestor to take full width
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (mode !== 'side-by-side') return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const modified: Array<{ el: HTMLElement; prev: string }> = [];
|
||||
let cur: HTMLElement | null = el;
|
||||
while (cur) {
|
||||
if (cur.classList.contains('ant-bubble')) {
|
||||
modified.push({ el: cur, prev: cur.style.cssText });
|
||||
cur.style.width = '100%';
|
||||
cur.style.boxSizing = 'border-box';
|
||||
break;
|
||||
}
|
||||
if (cur.classList.contains('ant-bubble-body') || cur.classList.contains('ant-bubble-content')) {
|
||||
modified.push({ el: cur, prev: cur.style.cssText });
|
||||
cur.style.overflow = 'hidden';
|
||||
cur.style.minWidth = '0';
|
||||
cur.style.width = '100%';
|
||||
}
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const item of modified) {
|
||||
item.el.style.cssText = item.prev;
|
||||
}
|
||||
};
|
||||
}, [mode]);
|
||||
|
||||
// Initialize OverlayScrollbars for persistent horizontal scrollbar
|
||||
useEffect(() => {
|
||||
if (mode !== 'side-by-side') return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const inst = OverlayScrollbars(
|
||||
{ target: el, elements: { viewport: el } },
|
||||
{
|
||||
scrollbars: {
|
||||
theme: 'os-theme-boosapi',
|
||||
autoHide: 'never',
|
||||
clickScroll: true,
|
||||
},
|
||||
overflow: { x: 'scroll', y: 'hidden' },
|
||||
},
|
||||
);
|
||||
|
||||
return () => inst.destroy();
|
||||
}, [mode]);
|
||||
|
||||
if (displayVersions.length <= 1) {
|
||||
const msg = displayVersions[0];
|
||||
if (!msg) return null;
|
||||
const isVersionStreaming = isDisplayStreaming && (msg.id === streamingMessageId || msg.status === 'partial');
|
||||
return (
|
||||
<MultiModelVersionContent
|
||||
message={msg}
|
||||
isVersionStreaming={isVersionStreaming}
|
||||
renderContent={renderContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const containerStyle: React.CSSProperties =
|
||||
mode === 'side-by-side'
|
||||
? {
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
overflowX: 'auto',
|
||||
paddingBottom: 8,
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
alignItems: 'stretch',
|
||||
}
|
||||
: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const cardStyle: React.CSSProperties =
|
||||
mode === 'side-by-side'
|
||||
? {
|
||||
minWidth: 300,
|
||||
flex: '0 0 auto',
|
||||
width: `calc((100% - ${(displayVersions.length - 1) * 12}px) / ${displayVersions.length})`,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}
|
||||
: {
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} style={containerStyle} className={mode === 'side-by-side' ? 'boosapi-multi-model-scroll' : undefined}>
|
||||
{displayVersions.map((vMsg) => {
|
||||
const isActive = vMsg.id === activeMessageId;
|
||||
const isVersionStreaming = isDisplayStreaming && (
|
||||
vMsg.id === streamingMessageId || vMsg.status === 'partial'
|
||||
);
|
||||
const { modelName, providerName } = getModelDisplayInfo(
|
||||
vMsg.model_id,
|
||||
vMsg.provider_id,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={vMsg.id}
|
||||
data-testid={`multi-model-card-${vMsg.id}`}
|
||||
style={{
|
||||
...cardStyle,
|
||||
borderColor: isActive ? token.colorPrimary : token.colorBorderSecondary,
|
||||
}}
|
||||
>
|
||||
{/* Card header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
backgroundColor: token.colorBgLayout,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<ModelIcon model={vMsg.model_id ?? ''} size={20} type="avatar" />
|
||||
{providerName && (
|
||||
<Tag
|
||||
style={{
|
||||
fontSize: 11,
|
||||
margin: 0,
|
||||
padding: '0 4px',
|
||||
lineHeight: '18px',
|
||||
color: token.colorPrimary,
|
||||
backgroundColor: token.colorPrimaryBg,
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
{providerName}
|
||||
</Tag>
|
||||
)}
|
||||
<Typography.Text style={{ fontSize: 13 }}>{modelName}</Typography.Text>
|
||||
{isVersionStreaming && (
|
||||
<span className="boosapi-streaming-dots" aria-hidden="true" style={{ marginLeft: 4 }}>
|
||||
<span /><span /><span />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="multi-model-card-header-actions" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<MultiModelContextButton
|
||||
message={vMsg}
|
||||
isActive={isActive}
|
||||
parentMessageId={parentMessageId}
|
||||
token={token}
|
||||
t={t}
|
||||
onSwitchVersion={onSwitchVersion}
|
||||
onSetContextVersion={onSetContextVersion}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Card content — key includes mode to force re-mount on layout switch */}
|
||||
<div
|
||||
key={`content-${mode}`}
|
||||
data-testid={`multi-model-card-content-${vMsg.id}`}
|
||||
style={{
|
||||
padding: '12px',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<MultiModelVersionContent
|
||||
message={vMsg}
|
||||
isVersionStreaming={isVersionStreaming}
|
||||
renderContent={renderContent}
|
||||
/>
|
||||
</div>
|
||||
<MultiModelCardActions
|
||||
message={vMsg}
|
||||
renderVersions={renderVersions}
|
||||
displayVersions={displayVersions}
|
||||
isVersionStreaming={isVersionStreaming}
|
||||
parentMessageId={parentMessageId}
|
||||
token={token}
|
||||
t={t}
|
||||
onDeleteVersion={onDeleteVersion}
|
||||
onRegenerateVersion={onRegenerateVersion}
|
||||
onEditVersion={onEditVersion}
|
||||
onBranchVersion={onBranchVersion}
|
||||
onSwitchModelVersion={onSwitchModelVersion}
|
||||
onDisplayVersionChange={onDisplayVersionChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiModelContextButton({
|
||||
message,
|
||||
isActive,
|
||||
parentMessageId,
|
||||
token,
|
||||
t,
|
||||
onSwitchVersion,
|
||||
onSetContextVersion,
|
||||
}: {
|
||||
message: Message;
|
||||
isActive: boolean;
|
||||
parentMessageId?: string | null;
|
||||
token: ReturnType<typeof theme.useToken>['token'];
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
onSwitchVersion: (parentMessageId: string, messageId: string) => void;
|
||||
onSetContextVersion?: (message: Message) => void;
|
||||
}) {
|
||||
const setContext = () => {
|
||||
if (isActive || !parentMessageId) return;
|
||||
if (onSetContextVersion) {
|
||||
onSetContextVersion(message);
|
||||
return;
|
||||
}
|
||||
onSwitchVersion(parentMessageId, message.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={isActive ? t('chat.currentContext', 'Current context') : t('chat.useAsContext', 'Use as context')}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`multi-model-set-context-${message.id}`}
|
||||
disabled={isActive || !parentMessageId}
|
||||
onClick={setContext}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
cursor: isActive ? 'default' : 'pointer',
|
||||
backgroundColor: isActive ? token.colorPrimary : 'transparent',
|
||||
color: isActive ? '#fff' : token.colorTextSecondary,
|
||||
border: isActive ? 'none' : `1px solid ${token.colorBorder}`,
|
||||
transition: 'all 0.2s',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiModelCardActions({
|
||||
message,
|
||||
renderVersions,
|
||||
displayVersions,
|
||||
isVersionStreaming,
|
||||
parentMessageId,
|
||||
token,
|
||||
t,
|
||||
onDeleteVersion,
|
||||
onRegenerateVersion,
|
||||
onEditVersion,
|
||||
onBranchVersion,
|
||||
onSwitchModelVersion,
|
||||
onDisplayVersionChange,
|
||||
}: {
|
||||
message: Message;
|
||||
renderVersions: Message[];
|
||||
displayVersions: Message[];
|
||||
isVersionStreaming: boolean;
|
||||
parentMessageId?: string | null;
|
||||
token: ReturnType<typeof theme.useToken>['token'];
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
onDeleteVersion?: (messageId: string) => void;
|
||||
onRegenerateVersion?: (message: Message) => void | Promise<void>;
|
||||
onEditVersion?: (message: Message) => void;
|
||||
onBranchVersion?: (message: Message, asChild: boolean) => void;
|
||||
onSwitchModelVersion?: (message: Message, providerId: string, modelId: string) => void | Promise<void>;
|
||||
onDisplayVersionChange?: (parentMessageId: string, modelKey: string, messageId: string) => void;
|
||||
}) {
|
||||
const modelKey = getMessageVersionGroupKey(message);
|
||||
const sameModelVersions = useMemo(
|
||||
() => renderVersions
|
||||
.filter((version) => getMessageVersionGroupKey(version) === modelKey)
|
||||
.sort((left, right) =>
|
||||
left.version_index - right.version_index
|
||||
|| left.created_at - right.created_at
|
||||
|| left.id.localeCompare(right.id)
|
||||
),
|
||||
[modelKey, renderVersions],
|
||||
);
|
||||
const currentVersionIndex = sameModelVersions.findIndex((version) => version.id === message.id);
|
||||
const canUseVersionPagination = Boolean(parentMessageId && sameModelVersions.length > 1 && onDisplayVersionChange);
|
||||
const actionsDisabled = isVersionStreaming || message.status === 'partial';
|
||||
const currentModelOverride = message.provider_id && message.model_id
|
||||
? { providerId: message.provider_id, modelId: message.model_id }
|
||||
: null;
|
||||
|
||||
const switchDisplayedVersion = (nextIndex: number) => {
|
||||
if (!canUseVersionPagination || !parentMessageId) return;
|
||||
const next = sameModelVersions[nextIndex];
|
||||
if (!next) return;
|
||||
onDisplayVersionChange?.(parentMessageId, modelKey, next.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="multi-model-card-footer-actions"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
padding: '6px 10px',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
backgroundColor: token.colorBgContainer,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 2, minWidth: 58 }}>
|
||||
{sameModelVersions.length > 1 && (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ChevronLeft size={14} />}
|
||||
disabled={!canUseVersionPagination || currentVersionIndex <= 0}
|
||||
data-testid={`multi-model-version-prev-${message.id}`}
|
||||
onClick={() => switchDisplayedVersion(currentVersionIndex - 1)}
|
||||
style={{ minWidth: 20, padding: '0 2px' }}
|
||||
/>
|
||||
<Typography.Text style={{ fontSize: 11, color: token.colorTextSecondary }}>
|
||||
{Math.max(currentVersionIndex, 0) + 1}/{sameModelVersions.length}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ChevronRight size={14} />}
|
||||
disabled={!canUseVersionPagination || currentVersionIndex >= sameModelVersions.length - 1}
|
||||
data-testid={`multi-model-version-next-${message.id}`}
|
||||
onClick={() => switchDisplayedVersion(currentVersionIndex + 1)}
|
||||
style={{ minWidth: 20, padding: '0 2px' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<CopyButton
|
||||
text={() => stripBoosapiTags(message.content ?? '')}
|
||||
size={13}
|
||||
timeout={3000}
|
||||
/>
|
||||
<Tooltip title={t('chat.regenerate')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<RotateCcw size={13} />}
|
||||
disabled={actionsDisabled || !onRegenerateVersion}
|
||||
data-testid={`multi-model-regenerate-${message.id}`}
|
||||
onClick={() => onRegenerateVersion?.(message)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('chat.editMessage')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<Pencil size={13} />}
|
||||
disabled={actionsDisabled || !onEditVersion}
|
||||
data-testid={`multi-model-edit-${message.id}`}
|
||||
onClick={() => onEditVersion?.(message)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<ModelSelector
|
||||
onSelect={(providerId, modelId) => onSwitchModelVersion?.(message, providerId, modelId)}
|
||||
overrideCurrentModel={currentModelOverride}
|
||||
>
|
||||
<Tooltip title={t('chat.switchModel')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ArrowLeftRight size={13} />}
|
||||
disabled={actionsDisabled || !onSwitchModelVersion}
|
||||
data-testid={`multi-model-switch-model-${message.id}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ModelSelector>
|
||||
<Dropdown
|
||||
disabled={actionsDisabled || !onBranchVersion}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'independent',
|
||||
label: t('chat.branchIndependent'),
|
||||
onClick: () => onBranchVersion?.(message, false),
|
||||
},
|
||||
{
|
||||
key: 'child',
|
||||
label: t('chat.branchChild'),
|
||||
onClick: () => onBranchVersion?.(message, true),
|
||||
},
|
||||
],
|
||||
}}
|
||||
trigger={['click']}
|
||||
placement="bottom"
|
||||
>
|
||||
<Tooltip title={t('chat.branchConversation')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<GitBranch size={13} />}
|
||||
disabled={actionsDisabled || !onBranchVersion}
|
||||
data-testid={`multi-model-branch-${message.id}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
{onDeleteVersion && displayVersions.length > 1 && (
|
||||
<Popconfirm
|
||||
title={t('chat.deleteConfirm')}
|
||||
onConfirm={() => onDeleteVersion(message.id)}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
disabled={actionsDisabled}
|
||||
icon={<Trash2 size={13} />}
|
||||
data-testid={`multi-model-delete-${message.id}`}
|
||||
/>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Layout switcher row — rendered below ModelTags.
|
||||
* Lets users temporarily override the display mode for a specific message.
|
||||
*/
|
||||
export function LayoutSwitcher({
|
||||
currentMode,
|
||||
onModeChange,
|
||||
}: {
|
||||
currentMode: MultiModelDisplayMode;
|
||||
onModeChange: (mode: MultiModelDisplayMode) => void;
|
||||
}) {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const modes: { key: MultiModelDisplayMode; icon: React.ReactNode; label: string }[] = [
|
||||
{ key: 'tabs', icon: <LayoutList size={14} />, label: t('settings.multiModelDisplayModeTabs') },
|
||||
{ key: 'side-by-side', icon: <Columns2 size={14} />, label: t('settings.multiModelDisplayModeSideBySide') },
|
||||
{ key: 'stacked', icon: <Rows3 size={14} />, label: t('settings.multiModelDisplayModeStacked') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
{modes.map(({ key, icon, label }) => (
|
||||
<Tooltip key={key} title={label} mouseEnterDelay={0.3}>
|
||||
<div
|
||||
onClick={() => onModeChange(key)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: token.borderRadiusSM,
|
||||
cursor: currentMode === key ? 'default' : 'pointer',
|
||||
backgroundColor: currentMode === key ? token.colorPrimaryBg : 'transparent',
|
||||
color: currentMode === key ? token.colorPrimary : token.colorTextQuaternary,
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Card, Space, Tag, Typography, theme } from 'antd';
|
||||
import { Shield, ShieldCheck, ShieldX, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useAgentStore } from '@/stores';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface PermissionCardProps {
|
||||
conversationId: string;
|
||||
toolUseId: string;
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
status: 'pending' | 'approved' | 'denied' | 'expired';
|
||||
}
|
||||
|
||||
const PermissionCard: React.FC<PermissionCardProps> = ({
|
||||
conversationId,
|
||||
toolUseId,
|
||||
toolName,
|
||||
input,
|
||||
status,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const approveToolUse = useAgentStore((state) => state.approveToolUse);
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
|
||||
const handleApprove = async (decision: string) => {
|
||||
setLoading(decision);
|
||||
try {
|
||||
await approveToolUse(conversationId, toolUseId, decision);
|
||||
} catch (e) {
|
||||
console.error('[PermissionCard] handleApprove failed:', e);
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const inputStr = JSON.stringify(input, null, 2);
|
||||
|
||||
const borderColor =
|
||||
status === 'pending'
|
||||
? token.colorWarningBorder
|
||||
: status === 'approved'
|
||||
? token.colorSuccessBorder
|
||||
: status === 'denied'
|
||||
? token.colorErrorBorder
|
||||
: token.colorBorderSecondary;
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
margin: '8px 0',
|
||||
borderColor,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={8}>
|
||||
{/* Header */}
|
||||
<Space align="center">
|
||||
<Shield size={16} />
|
||||
<Text strong>{t('common.permissionRequired', 'Permission Required')}</Text>
|
||||
<Tag>{toolName}</Tag>
|
||||
</Space>
|
||||
|
||||
{/* Input preview */}
|
||||
<div
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('common.toolInput', 'Tool Input')}
|
||||
</Text>
|
||||
</div>
|
||||
{expanded && (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 8,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: token.colorBgTextHover,
|
||||
borderRadius: token.borderRadius,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{inputStr}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Action buttons or result */}
|
||||
{status === 'pending' ? (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<ShieldCheck size={14} />}
|
||||
loading={loading === 'allow_once'}
|
||||
onClick={() => handleApprove('allow_once')}
|
||||
>
|
||||
{t('common.allowOnce', 'Allow Once')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ShieldCheck size={14} />}
|
||||
loading={loading === 'allow_always'}
|
||||
onClick={() => handleApprove('allow_always')}
|
||||
>
|
||||
{t('common.allowAlways', 'Always Allow')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<ShieldX size={14} />}
|
||||
loading={loading === 'deny'}
|
||||
onClick={() => handleApprove('deny')}
|
||||
>
|
||||
{t('common.deny', 'Deny')}
|
||||
</Button>
|
||||
</Space>
|
||||
) : status === 'approved' ? (
|
||||
<Space>
|
||||
<ShieldCheck size={14} style={{ color: token.colorSuccess }} />
|
||||
<Text type="success">{t('common.approved', 'Approved')}</Text>
|
||||
</Space>
|
||||
) : status === 'denied' ? (
|
||||
<Space>
|
||||
<ShieldX size={14} style={{ color: token.colorError }} />
|
||||
<Text type="danger">{t('common.denied', 'Denied')}</Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Space>
|
||||
<Text type="warning">⚠️ {t('common.expired', 'Expired (Agent disconnected)')}</Text>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionCard;
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useMemo } from 'react';
|
||||
import { theme, Typography } from 'antd';
|
||||
import { ThoughtChain, type ThoughtChainItemType } from '@ant-design/x';
|
||||
import { Terminal, FileEdit, Search, Wrench } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ToolCallItem {
|
||||
toolUseId: string;
|
||||
toolName: string;
|
||||
status: 'queued' | 'running' | 'success' | 'error' | 'cancelled';
|
||||
input?: string;
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface ToolCallChainProps {
|
||||
toolCalls: ToolCallItem[];
|
||||
}
|
||||
|
||||
const statusMap: Record<string, ThoughtChainItemType['status']> = {
|
||||
queued: 'loading',
|
||||
running: 'loading',
|
||||
success: 'success',
|
||||
error: 'error',
|
||||
cancelled: 'abort',
|
||||
};
|
||||
|
||||
const toolIcons: Record<string, React.ReactNode> = {
|
||||
bash: <Terminal size={14} />,
|
||||
write: <FileEdit size={14} />,
|
||||
read: <Search size={14} />,
|
||||
edit: <FileEdit size={14} />,
|
||||
glob: <Search size={14} />,
|
||||
grep: <Search size={14} />,
|
||||
ls: <Search size={14} />,
|
||||
};
|
||||
|
||||
function getToolIcon(toolName: string): React.ReactNode {
|
||||
const lower = toolName.toLowerCase();
|
||||
for (const [key, icon] of Object.entries(toolIcons)) {
|
||||
if (lower.includes(key)) return icon;
|
||||
}
|
||||
return <Wrench size={14} />;
|
||||
}
|
||||
|
||||
function getInputSummary(input?: string): string | undefined {
|
||||
if (!input) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(input);
|
||||
if (parsed.command) return parsed.command;
|
||||
if (parsed.path) return parsed.path;
|
||||
if (parsed.file_path) return parsed.file_path;
|
||||
if (parsed.pattern) return parsed.pattern;
|
||||
const firstVal = Object.values(parsed)[0];
|
||||
if (typeof firstVal === 'string') return firstVal.length > 80 ? firstVal.slice(0, 80) + '…' : firstVal;
|
||||
} catch {
|
||||
// not json
|
||||
}
|
||||
return input.length > 80 ? input.slice(0, 80) + '…' : input;
|
||||
}
|
||||
|
||||
export function ToolCallCard({ toolCalls }: ToolCallChainProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const chainItems: ThoughtChainItemType[] = useMemo(() => {
|
||||
return toolCalls.map((tc) => {
|
||||
const contentParts: React.ReactNode[] = [];
|
||||
|
||||
if (tc.input) {
|
||||
contentParts.push(
|
||||
<details key="input" style={{ margin: 0 }}>
|
||||
<summary style={{ fontSize: 12, color: token.colorTextSecondary, cursor: 'pointer', userSelect: 'none' }}>
|
||||
{t('chat.inspector.toolInput', '输入参数')}
|
||||
</summary>
|
||||
<pre
|
||||
style={{
|
||||
margin: '4px 0 0',
|
||||
padding: 8,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: token.colorBgTextHover,
|
||||
borderRadius: token.borderRadius,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{tc.input}
|
||||
</pre>
|
||||
</details>,
|
||||
);
|
||||
}
|
||||
|
||||
if (tc.output) {
|
||||
contentParts.push(
|
||||
<details key="output" style={{ margin: 0 }}>
|
||||
<summary style={{ fontSize: 12, color: token.colorTextSecondary, cursor: 'pointer', userSelect: 'none' }}>
|
||||
{t('chat.inspector.toolOutput', '执行结果')}
|
||||
</summary>
|
||||
<pre
|
||||
style={{
|
||||
margin: '4px 0 0',
|
||||
padding: 8,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: token.colorBgTextHover,
|
||||
borderRadius: token.borderRadius,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
color: tc.isError ? token.colorError : undefined,
|
||||
}}
|
||||
>
|
||||
{tc.output}
|
||||
</pre>
|
||||
</details>,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
key: tc.toolUseId,
|
||||
icon: getToolIcon(tc.toolName),
|
||||
title: tc.toolName,
|
||||
description: (
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, fontFamily: 'monospace' }}
|
||||
ellipsis
|
||||
>
|
||||
{getInputSummary(tc.input)}
|
||||
</Typography.Text>
|
||||
),
|
||||
status: statusMap[tc.status] || 'loading',
|
||||
collapsible: tc.status === 'success' || tc.status === 'error' || tc.status === 'cancelled',
|
||||
content: contentParts.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{contentParts}
|
||||
</div>
|
||||
) : undefined,
|
||||
} satisfies ThoughtChainItemType;
|
||||
});
|
||||
}, [toolCalls, token, t]);
|
||||
|
||||
if (chainItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ margin: '4px 0 8px' }}>
|
||||
<ThoughtChain
|
||||
items={chainItems}
|
||||
line="dashed"
|
||||
styles={{
|
||||
item: { padding: '4px 0' },
|
||||
itemContent: { fontSize: 12 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Typography, Spin } from 'antd';
|
||||
import { Mic, MicOff, Phone, Loader, Volume2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVoiceChat } from '@/hooks/useVoiceChat';
|
||||
import type { RealtimeConfig, VoiceSessionState } from '@/types';
|
||||
|
||||
interface VoiceCallProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
port?: number;
|
||||
config: RealtimeConfig;
|
||||
}
|
||||
|
||||
function StatusDisplay({ state }: { state: VoiceSessionState }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const content = useMemo(() => {
|
||||
switch (state) {
|
||||
case 'Connecting':
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Spin indicator={<Loader size={48} style={{ color: '#fff', animation: 'spin 1s linear infinite' }} />} />
|
||||
<Typography.Text style={{ color: '#fff', fontSize: 18 }}>
|
||||
{t('voice.connecting')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
case 'Connected':
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Mic size={48} style={{ color: '#52c41a' }} />
|
||||
<Typography.Text style={{ color: '#fff', fontSize: 18 }}>
|
||||
{t('voice.connected')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
case 'Speaking':
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="voice-waveform">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="voice-bar"
|
||||
style={{
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Typography.Text style={{ color: '#fff', fontSize: 18 }}>
|
||||
{t('voice.speaking')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
case 'Listening':
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Volume2 size={48} style={{ color: '#1677ff' }} className="animate-pulse" />
|
||||
<Typography.Text style={{ color: '#fff', fontSize: 18 }}>
|
||||
{t('voice.listening')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
case 'Disconnecting':
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Spin indicator={<Loader size={48} style={{ color: '#fff', animation: 'spin 1s linear infinite' }} />} />
|
||||
<Typography.Text style={{ color: '#fff', fontSize: 18 }}>
|
||||
{t('voice.disconnecting')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [state, t]);
|
||||
|
||||
return <>{content}</>;
|
||||
}
|
||||
|
||||
export function VoiceCall({ visible, onClose, port, config }: VoiceCallProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state, isMuted, start, stop, toggleMute } = useVoiceChat({ port, config });
|
||||
|
||||
// Auto-start when overlay becomes visible
|
||||
if (visible && state === 'Idle') {
|
||||
start();
|
||||
}
|
||||
|
||||
const handleEndCall = () => {
|
||||
stop();
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[1000] flex flex-col items-center justify-center"
|
||||
style={{ background: 'rgba(0,0,0,0.85)' }}
|
||||
>
|
||||
{/* Status display */}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<StatusDisplay state={state} />
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-8 pb-16">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={isMuted ? <MicOff size={20} /> : <Mic size={20} />}
|
||||
onClick={toggleMute}
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
background: isMuted ? '#ff4d4f' : 'rgba(255,255,255,0.2)',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
}}
|
||||
title={t('voice.toggleMute')}
|
||||
/>
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<Phone size={24} style={{ transform: 'rotate(225deg)' }} />}
|
||||
onClick={handleEndCall}
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
background: '#ff4d4f',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontSize: 24,
|
||||
}}
|
||||
title={t('voice.endCall')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Waveform CSS */}
|
||||
<style>{`
|
||||
.voice-waveform {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 60px;
|
||||
}
|
||||
.voice-bar {
|
||||
width: 6px;
|
||||
height: 20px;
|
||||
background: #52c41a;
|
||||
border-radius: 3px;
|
||||
animation: voiceWave 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes voiceWave {
|
||||
0% { height: 12px; }
|
||||
100% { height: 48px; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, theme } from 'antd';
|
||||
import { SyncOutlined } from '@ant-design/icons';
|
||||
import { Search, ChevronDown, ChevronRight, ExternalLink, AlertCircle } from 'lucide-react';
|
||||
import type { NodeComponentProps } from 'markstream-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Think from '@ant-design/x/es/think';
|
||||
|
||||
interface SearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
type WebSearchNodeData = {
|
||||
type: 'web-search';
|
||||
content?: string;
|
||||
attrs?: Record<string, string> | [string, string][];
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
type WebSearchQueryNodeData = {
|
||||
type: 'web-search-query';
|
||||
content?: string;
|
||||
attrs?: Record<string, string> | [string, string][];
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
function getAttrValue(
|
||||
attrs: WebSearchNodeData['attrs'],
|
||||
key: string,
|
||||
): string | undefined {
|
||||
if (!attrs) return undefined;
|
||||
if (Array.isArray(attrs)) {
|
||||
const entry = attrs.find(([name]) => name === key);
|
||||
return entry?.[1];
|
||||
}
|
||||
return attrs[key];
|
||||
}
|
||||
|
||||
function decodeAttrValue(value: string): string {
|
||||
return value
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
function getFavicon(url: string) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=16`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function SearchQuerySummaryPanel({
|
||||
status,
|
||||
query,
|
||||
error,
|
||||
}: {
|
||||
status: string;
|
||||
query?: string;
|
||||
error?: string;
|
||||
}) {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useTranslation();
|
||||
const isLoading = status === 'summarizing';
|
||||
const isError = status === 'error';
|
||||
const [expanded, setExpanded] = useState(isError);
|
||||
const title = isLoading
|
||||
? t('chat.search.summarizingQuery')
|
||||
: isError
|
||||
? t('chat.search.querySummaryFailed')
|
||||
: t('chat.search.querySummary');
|
||||
const hasBody = Boolean(error || query);
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Think
|
||||
title={(
|
||||
<span
|
||||
data-boosapi-search-query-status={status}
|
||||
style={{ color: isError ? token.colorError : undefined }}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
blink={isLoading}
|
||||
loading={isLoading ? (
|
||||
<SyncOutlined style={{ fontSize: 12, animation: 'boosapi-think-spin 1s linear infinite' }} />
|
||||
) : false}
|
||||
icon={isError ? <AlertCircle size={14} /> : <Search size={14} />}
|
||||
expanded={expanded}
|
||||
onExpand={(next) => {
|
||||
if (hasBody) setExpanded(next);
|
||||
}}
|
||||
>
|
||||
{isError && error && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={error}
|
||||
style={{ marginBottom: query ? 8 : 0 }}
|
||||
/>
|
||||
)}
|
||||
{query && (
|
||||
<div
|
||||
style={{
|
||||
color: token.colorText,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{query}
|
||||
</div>
|
||||
)}
|
||||
</Think>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebSearchQueryNode(props: NodeComponentProps<WebSearchQueryNodeData>) {
|
||||
const { node } = props;
|
||||
|
||||
const status = getAttrValue(node.attrs, 'status') ?? (node.loading ? 'summarizing' : 'done');
|
||||
const queryAttr = getAttrValue(node.attrs, 'query');
|
||||
const query = queryAttr ? decodeAttrValue(queryAttr).trim() : '';
|
||||
const error = node.content ? decodeAttrValue(node.content).trim() : '';
|
||||
|
||||
return (
|
||||
<SearchQuerySummaryPanel
|
||||
status={status}
|
||||
query={query}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebSearchNode(props: NodeComponentProps<WebSearchNodeData>) {
|
||||
const { node } = props;
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const status = getAttrValue(node.attrs, 'status') ?? (node.loading ? 'searching' : 'done');
|
||||
const searchQuery = getAttrValue(node.attrs, 'query');
|
||||
const displayQuery = searchQuery ? decodeAttrValue(searchQuery).trim() : '';
|
||||
const queryStatus = getAttrValue(node.attrs, 'query-status');
|
||||
const queryErrorAttr = getAttrValue(node.attrs, 'query-error');
|
||||
const queryError = queryErrorAttr ? decodeAttrValue(queryErrorAttr).trim() : '';
|
||||
|
||||
// Parse results from node content
|
||||
let results: SearchResult[] = [];
|
||||
if (node.content) {
|
||||
try {
|
||||
const parsed = JSON.parse(node.content);
|
||||
if (Array.isArray(parsed)) results = parsed;
|
||||
} catch {
|
||||
// invalid JSON
|
||||
}
|
||||
}
|
||||
|
||||
const shouldShowQuerySummary = status === 'summarizing' || !!queryStatus || !!displayQuery;
|
||||
const querySummary = shouldShowQuerySummary
|
||||
? (
|
||||
<SearchQuerySummaryPanel
|
||||
status={status === 'summarizing' ? 'summarizing' : queryStatus ?? 'done'}
|
||||
query={displayQuery}
|
||||
error={queryError}
|
||||
/>
|
||||
)
|
||||
: null;
|
||||
|
||||
// Summarizing/searching states
|
||||
if (status === 'summarizing' || status === 'searching') {
|
||||
if (status === 'summarizing') {
|
||||
return querySummary;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{querySummary}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorFillQuaternary,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="animate-spin"
|
||||
style={{ display: 'inline-flex', width: 16, height: 16 }}
|
||||
>
|
||||
<Search size={16} style={{ color: token.colorPrimary }} />
|
||||
</span>
|
||||
<span style={{ color: token.colorTextSecondary, fontSize: 13 }}>
|
||||
{t('chat.search.searching')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<>
|
||||
{querySummary}
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={node.content || t('chat.search.error')}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Done state — show an explicit empty state instead of silently disappearing.
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<>
|
||||
{querySummary}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: token.colorFillQuaternary,
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<Search size={16} style={{ color: token.colorTextTertiary }} />
|
||||
<span style={{ flex: 1 }}>{t('chat.search.noResults', '未找到相关搜索结果')}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{querySummary}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: token.colorFillQuaternary,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Search size={14} style={{ color: token.colorPrimary }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{t('chat.search.resultsCount', { count: results.length })}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', color: token.colorTextTertiary }}>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Source icons row (always visible) */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
padding: '6px 12px',
|
||||
flexWrap: 'wrap',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{results.map((r, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={r.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={r.title}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '2px 8px',
|
||||
fontSize: 11,
|
||||
borderRadius: 4,
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
color: token.colorTextSecondary,
|
||||
textDecoration: 'none',
|
||||
maxWidth: 160,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getFavicon(r.url)}
|
||||
width={12}
|
||||
height={12}
|
||||
alt=""
|
||||
style={{ flexShrink: 0 }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
{r.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{results.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
marginBottom: i < results.length - 1 ? 8 : 0,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={r.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
color: token.colorPrimary,
|
||||
fontWeight: 500,
|
||||
textDecoration: 'none',
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getFavicon(r.url)}
|
||||
width={14}
|
||||
height={14}
|
||||
alt=""
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
{r.title}
|
||||
<ExternalLink size={10} style={{ opacity: 0.5 }} />
|
||||
</a>
|
||||
{r.content && (
|
||||
<p
|
||||
style={{
|
||||
margin: '2px 0 0 0',
|
||||
color: token.colorTextSecondary,
|
||||
lineHeight: 1.5,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{r.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { App } from 'antd';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChatImageNode } from '../ChatImageNode';
|
||||
|
||||
const imageActions = vi.hoisted(() => ({
|
||||
copyChatImage: vi.fn(async (_src: string) => undefined),
|
||||
saveChatImage: vi.fn(async (_src: string, _defaultName?: string) => true),
|
||||
getDefaultImageFilename: vi.fn((_src: string, _alt?: string | null) => 'boosapi-image.png'),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/chatImageActions', () => ({
|
||||
copyChatImage: (src: string) => imageActions.copyChatImage(src),
|
||||
saveChatImage: (src: string, defaultName?: string) => imageActions.saveChatImage(src, defaultName),
|
||||
getDefaultImageFilename: (src: string, alt?: string | null) => imageActions.getDefaultImageFilename(src, alt),
|
||||
}));
|
||||
|
||||
function renderNode(src = 'data:image/png;base64,aGVsbG8=') {
|
||||
return render(
|
||||
<App>
|
||||
<ChatImageNode
|
||||
node={{
|
||||
type: 'image',
|
||||
src,
|
||||
alt: 'generated preview',
|
||||
title: null,
|
||||
raw: ``,
|
||||
}}
|
||||
/>
|
||||
</App>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ChatImageNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
imageActions.copyChatImage.mockResolvedValue(undefined);
|
||||
imageActions.saveChatImage.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('shows a loading region until the image finishes loading', () => {
|
||||
renderNode();
|
||||
|
||||
expect(screen.getByText('图片加载中...')).toBeInTheDocument();
|
||||
|
||||
fireEvent.load(screen.getByAltText('generated preview'));
|
||||
|
||||
expect(screen.queryByText('图片加载中...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an antd error alert with the image link when loading fails', () => {
|
||||
const src = 'https://example.com/missing.png';
|
||||
renderNode(src);
|
||||
|
||||
fireEvent.error(screen.getByAltText('generated preview'));
|
||||
|
||||
expect(screen.getByText('图片加载失败')).toBeInTheDocument();
|
||||
expect(screen.getByText(src)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reads image details from html img node attributes', () => {
|
||||
const src = 'https://example.com/generated.webp';
|
||||
render(
|
||||
<App>
|
||||
<ChatImageNode
|
||||
node={{
|
||||
type: 'img',
|
||||
attrs: [
|
||||
['src', src],
|
||||
['alt', 'html preview'],
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</App>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('图片加载中...')).toBeInTheDocument();
|
||||
|
||||
fireEvent.load(screen.getByAltText('html preview'));
|
||||
|
||||
expect(screen.queryByText('图片加载中...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies and saves a loaded image through the image actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const src = 'data:image/png;base64,aGVsbG8=';
|
||||
renderNode(src);
|
||||
|
||||
fireEvent.load(screen.getByAltText('generated preview'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '复制图片' }));
|
||||
await waitFor(() => expect(imageActions.copyChatImage).toHaveBeenCalledWith(src));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '保存图片' }));
|
||||
await waitFor(() => expect(imageActions.saveChatImage).toHaveBeenCalledWith(src, 'boosapi-image.png'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatMessageRenderBoundary } from '../ChatMessageRenderBoundary';
|
||||
|
||||
function ThrowingMessage(): ReactElement {
|
||||
throw new Error('render failed');
|
||||
}
|
||||
|
||||
describe('ChatMessageRenderBoundary', () => {
|
||||
it('falls back to plain text for a single failed message render', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<div>
|
||||
<div>previous message</div>
|
||||
<ChatMessageRenderBoundary fallback={<div style={{ whiteSpace: 'pre-wrap' }}>plain fallback text</div>}>
|
||||
<ThrowingMessage />
|
||||
</ChatMessageRenderBoundary>
|
||||
<div>next message</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('previous message')).toBeInTheDocument();
|
||||
expect(screen.getByText('plain fallback text')).toBeInTheDocument();
|
||||
expect(screen.getByText('next message')).toBeInTheDocument();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { act, fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChatScrollIndicator } from '../ChatScrollIndicator';
|
||||
|
||||
function createScrollableBubbleList() {
|
||||
const scrollBox = document.createElement('div');
|
||||
scrollBox.className = 'ant-bubble-list-scroll-box';
|
||||
scrollBox.style.flexDirection = 'column-reverse';
|
||||
|
||||
let scrollTop = 0;
|
||||
Object.defineProperties(scrollBox, {
|
||||
clientHeight: { configurable: true, value: 800 },
|
||||
scrollHeight: { configurable: true, value: 2000 },
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => {
|
||||
scrollTop = value;
|
||||
scrollBox.dispatchEvent(new Event('scroll'));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
document.body.appendChild(scrollBox);
|
||||
return scrollBox;
|
||||
}
|
||||
|
||||
describe('ChatScrollIndicator', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reports user scroll intent before dragging the custom scrollbar thumb', async () => {
|
||||
createScrollableBubbleList();
|
||||
const onUserScrollIntent = vi.fn();
|
||||
|
||||
render(<ChatScrollIndicator onUserScrollIntent={onUserScrollIntent} />);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.chat-scroll-indicator')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const thumb = document.querySelector('.chat-scroll-indicator') as HTMLElement;
|
||||
fireEvent.pointerDown(thumb, { clientY: 10 });
|
||||
|
||||
expect(onUserScrollIntent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports user scroll intent before jumping from the custom scrollbar track', async () => {
|
||||
createScrollableBubbleList();
|
||||
const onUserScrollIntent = vi.fn();
|
||||
|
||||
render(<ChatScrollIndicator onUserScrollIntent={onUserScrollIntent} />);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.chat-scroll-indicator-track')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const track = document.querySelector('.chat-scroll-indicator-track') as HTMLElement;
|
||||
fireEvent.pointerDown(track, { clientY: 400 });
|
||||
|
||||
expect(onUserScrollIntent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,523 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChatSidebar } from '../ChatSidebar';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
confirm: vi.fn(),
|
||||
deleteConversation: vi.fn(),
|
||||
setActiveConversation: vi.fn(),
|
||||
createConversation: vi.fn(),
|
||||
updateConversation: vi.fn(),
|
||||
togglePin: vi.fn(),
|
||||
toggleArchive: vi.fn(),
|
||||
fetchArchivedConversations: vi.fn(),
|
||||
batchDelete: vi.fn(),
|
||||
batchArchive: vi.fn(),
|
||||
regenerateTitle: vi.fn(),
|
||||
saveSettings: vi.fn(),
|
||||
fetchCategories: vi.fn(),
|
||||
createCategory: vi.fn(),
|
||||
updateCategory: vi.fn(),
|
||||
deleteCategory: vi.fn(),
|
||||
setCollapsed: vi.fn(),
|
||||
}));
|
||||
|
||||
const conversationState: any = {
|
||||
conversations: [
|
||||
{
|
||||
id: 'conv-1',
|
||||
title: '快捷删除测试',
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
category_id: null,
|
||||
parent_conversation_id: null,
|
||||
is_pinned: false,
|
||||
is_archived: false,
|
||||
message_count: 0,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
],
|
||||
activeConversationId: 'conv-1',
|
||||
setActiveConversation: mocks.setActiveConversation,
|
||||
createConversation: mocks.createConversation,
|
||||
deleteConversation: mocks.deleteConversation,
|
||||
updateConversation: mocks.updateConversation,
|
||||
togglePin: mocks.togglePin,
|
||||
toggleArchive: mocks.toggleArchive,
|
||||
archivedConversations: [],
|
||||
fetchArchivedConversations: mocks.fetchArchivedConversations,
|
||||
batchDelete: mocks.batchDelete,
|
||||
batchArchive: mocks.batchArchive,
|
||||
streamingConversationId: null,
|
||||
titleGeneratingConversationId: null,
|
||||
regenerateTitle: mocks.regenerateTitle,
|
||||
};
|
||||
|
||||
const providerState = {
|
||||
providers: [
|
||||
{
|
||||
id: 'provider-1',
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
enabled: true,
|
||||
model_type: 'Chat',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const settingsState = {
|
||||
settings: {
|
||||
default_provider_id: 'provider-1',
|
||||
default_model_id: 'model-1',
|
||||
last_selected_conversation_id: null,
|
||||
},
|
||||
loading: false,
|
||||
saveSettings: mocks.saveSettings,
|
||||
};
|
||||
|
||||
const categoryState: any = {
|
||||
categories: [],
|
||||
fetchCategories: mocks.fetchCategories,
|
||||
createCategory: mocks.createCategory,
|
||||
updateCategory: mocks.updateCategory,
|
||||
deleteCategory: mocks.deleteCategory,
|
||||
setCollapsed: mocks.setCollapsed,
|
||||
};
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, string>) => ({
|
||||
'chat.delete': '删除',
|
||||
'chat.directDeleteHint': '按住 Ctrl 可直接删除',
|
||||
'chat.deleteConfirm': '确定删除此对话?',
|
||||
'chat.searchPlaceholder': '搜索对话...',
|
||||
'chat.archived': '已归档',
|
||||
'chat.createCategory': '新建分类',
|
||||
'chat.newConversation': '新建对话',
|
||||
'chat.newConversationInCurrentCategory': `在 ${options?.category ?? '当前分类'} 下新建`,
|
||||
'chat.newStandaloneConversation': '独立新建',
|
||||
'chat.multiSelect': '多选',
|
||||
'chat.noConversations': '暂无对话',
|
||||
'chat.today': '今天',
|
||||
'chat.yesterday': '昨天',
|
||||
'chat.thisWeek': '本周',
|
||||
'chat.thisMonth': '本月',
|
||||
'chat.earlier': '更早',
|
||||
'chat.pin': '置顶',
|
||||
'chat.unpin': '取消置顶',
|
||||
'chat.pinned': '已置顶',
|
||||
'chat.archive': '归档',
|
||||
'chat.rename': '重命名',
|
||||
'chat.generateTitle': '生成标题',
|
||||
'chat.generatingTitle': '正在生成标题',
|
||||
'chat.export': '导出',
|
||||
}[key] ?? key),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
App: {
|
||||
useApp: () => ({
|
||||
message: { success: vi.fn(), warning: vi.fn(), error: vi.fn() },
|
||||
modal: { confirm: mocks.confirm },
|
||||
}),
|
||||
},
|
||||
Button: ({ children, icon, onClick, 'aria-label': ariaLabel, title, disabled }: any) => (
|
||||
<button type="button" aria-label={ariaLabel ?? title} disabled={disabled} onClick={onClick}>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Input: (props: any) => <input {...props} />,
|
||||
Tooltip: ({ children, title }: any) => (
|
||||
<span title={typeof title === 'string' ? title : undefined}>{children}</span>
|
||||
),
|
||||
Checkbox: ({ checked, onChange, onClick }: any) => (
|
||||
<input type="checkbox" checked={checked} onChange={onChange} onClick={onClick} readOnly />
|
||||
),
|
||||
Dropdown: ({ children, menu }: any) => (
|
||||
<div>
|
||||
{children}
|
||||
{menu?.items?.map((item: any) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
aria-label={typeof item.label === 'string' ? item.label : undefined}
|
||||
disabled={item.disabled}
|
||||
onClick={() => menu.onClick?.({ key: item.key, domEvent: { stopPropagation: vi.fn() } })}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
Empty: ({ description }: any) => <div>{description}</div>,
|
||||
Avatar: () => null,
|
||||
theme: {
|
||||
useToken: () => ({
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
colorPrimaryBg: '#e6f4ff',
|
||||
colorBgContainer: '#fff',
|
||||
colorFillContent: '#f5f5f5',
|
||||
colorTextSecondary: '#666',
|
||||
colorTextQuaternary: '#aaa',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@ant-design/x/es/conversations', () => ({
|
||||
default: ({ items, menu }: any) => (
|
||||
<ul>
|
||||
{items.map((item: any) => {
|
||||
const menuConfig = typeof menu === 'function' ? menu(item) : menu;
|
||||
const trigger = menuConfig?.trigger
|
||||
? menuConfig.trigger(item, { originNode: <button type="button" aria-label="更多" /> })
|
||||
: <button type="button" aria-label="更多" />;
|
||||
|
||||
return (
|
||||
<li key={item.key} data-conv-id={item['data-conv-id']}>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
{trigger}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="菜单删除"
|
||||
onClick={() => menuConfig?.onClick?.({ key: 'delete', domEvent: {} })}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@dnd-kit/core', () => ({
|
||||
DndContext: ({ children }: any) => <>{children}</>,
|
||||
DragOverlay: ({ children }: any) => <>{children}</>,
|
||||
closestCenter: vi.fn(),
|
||||
PointerSensor: vi.fn(),
|
||||
useSensor: vi.fn(() => ({})),
|
||||
useSensors: vi.fn(() => []),
|
||||
useDraggable: () => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: vi.fn(),
|
||||
isDragging: false,
|
||||
}),
|
||||
useDroppable: () => ({
|
||||
setNodeRef: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
ModelIcon: () => null,
|
||||
modelMappings: [],
|
||||
}));
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useConversationStore: Object.assign(
|
||||
(selector: (state: typeof conversationState) => unknown) => selector(conversationState),
|
||||
{ getState: () => ({ ...conversationState, fetchConversations: vi.fn() }) },
|
||||
),
|
||||
useProviderStore: (selector: (state: typeof providerState) => unknown) => selector(providerState),
|
||||
useSettingsStore: Object.assign(
|
||||
(selector: (state: typeof settingsState) => unknown) => selector(settingsState),
|
||||
{ getState: () => settingsState },
|
||||
),
|
||||
useCategoryStore: Object.assign(
|
||||
(selector: (state: typeof categoryState) => unknown) => selector(categoryState),
|
||||
{ setState: vi.fn(), getState: () => categoryState },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useResolvedAvatarSrc', () => ({
|
||||
useResolvedAvatarSrc: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/convIcon', () => ({
|
||||
getConvIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/exportChat', () => ({
|
||||
exportAsMarkdown: vi.fn(),
|
||||
exportAsText: vi.fn(),
|
||||
exportAsPNG: vi.fn(),
|
||||
exportAsJSON: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/invoke', () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/shortcuts', () => ({
|
||||
getShortcutBinding: () => 'CmdOrCtrl+N',
|
||||
formatShortcutForDisplay: () => 'Ctrl+N',
|
||||
}));
|
||||
|
||||
vi.mock('../CategoryEditModal', () => ({
|
||||
CategoryEditModal: () => null,
|
||||
}));
|
||||
|
||||
describe('ChatSidebar direct delete shortcut', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
conversationState.conversations = [
|
||||
{
|
||||
id: 'conv-1',
|
||||
title: '快捷删除测试',
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
category_id: null,
|
||||
parent_conversation_id: null,
|
||||
is_pinned: false,
|
||||
is_archived: false,
|
||||
message_count: 0,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
];
|
||||
conversationState.activeConversationId = 'conv-1';
|
||||
conversationState.titleGeneratingConversationId = null;
|
||||
categoryState.categories = [];
|
||||
mocks.regenerateTitle.mockResolvedValue(undefined);
|
||||
mocks.createConversation.mockResolvedValue({
|
||||
id: 'conv-new',
|
||||
title: '新建对话',
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
category_id: null,
|
||||
parent_conversation_id: null,
|
||||
is_pinned: false,
|
||||
is_archived: false,
|
||||
message_count: 0,
|
||||
created_at: 2,
|
||||
updated_at: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the confirmation dialog for a normal menu delete click', () => {
|
||||
render(<ChatSidebar />);
|
||||
|
||||
expect(screen.getByRole('button', { name: '更多' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '删除' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '菜单删除' }));
|
||||
|
||||
expect(mocks.confirm).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.deleteConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders long conversation titles inside a constrained truncation element', () => {
|
||||
const longTitle = '这是一个非常长的用户首条消息标题,用来模拟标题总结模型失败时回退到用户输入导致侧边栏被撑高的问题';
|
||||
conversationState.conversations[0].title = longTitle;
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
const title = screen.getByText(longTitle);
|
||||
expect(title).toHaveClass('boosapi-chat-conversation-title');
|
||||
expect(title).toHaveAttribute('title', longTitle);
|
||||
expect(title).toHaveStyle({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
});
|
||||
|
||||
it('turns the more trigger into direct delete while Ctrl is held', async () => {
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Control', ctrlKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '删除' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除' }), { ctrlKey: true });
|
||||
|
||||
expect(mocks.confirm).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteConversation).toHaveBeenCalledWith('conv-1');
|
||||
});
|
||||
|
||||
it('turns the more trigger into direct delete while Cmd is held', async () => {
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Meta', metaKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '删除' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除' }), { metaKey: true });
|
||||
|
||||
expect(mocks.confirm).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteConversation).toHaveBeenCalledWith('conv-1');
|
||||
});
|
||||
|
||||
it('does not add a separate row delete action and hides the delete trigger on active rows until hover', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(process.cwd(), 'src/components/chat/ChatSidebar.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(source).not.toContain('boosapi-chat-conversation-direct-delete');
|
||||
expect(source).not.toContain('PanelLeftClose');
|
||||
expect(source).not.toContain('onCollapse');
|
||||
expect(source).not.toContain('chat.collapseSidebar');
|
||||
expect(source).toContain('.ant-conversations .ant-conversations-item-active .boosapi-chat-conversation-menu-delete');
|
||||
expect(source).toContain('opacity: 0;');
|
||||
});
|
||||
|
||||
it('offers current-category and standalone choices when creating from a categorized conversation', async () => {
|
||||
conversationState.conversations[0].category_id = 'cat-work';
|
||||
categoryState.categories = [
|
||||
{
|
||||
id: 'cat-work',
|
||||
name: '工作',
|
||||
icon_type: null,
|
||||
icon_value: null,
|
||||
system_prompt: '工作分类提示词',
|
||||
default_provider_id: null,
|
||||
default_model_id: null,
|
||||
default_temperature: null,
|
||||
default_max_tokens: null,
|
||||
default_top_p: null,
|
||||
default_frequency_penalty: null,
|
||||
sort_order: 0,
|
||||
is_collapsed: false,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
];
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '在 工作 下新建' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.createConversation).toHaveBeenCalledWith(
|
||||
'新建对话',
|
||||
'model-1',
|
||||
'provider-1',
|
||||
{ categoryId: 'cat-work' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('lets users create a standalone conversation from a categorized conversation', async () => {
|
||||
conversationState.conversations[0].category_id = 'cat-work';
|
||||
categoryState.categories = [
|
||||
{
|
||||
id: 'cat-work',
|
||||
name: '工作',
|
||||
icon_type: null,
|
||||
icon_value: null,
|
||||
system_prompt: '工作分类提示词',
|
||||
default_provider_id: null,
|
||||
default_model_id: null,
|
||||
default_temperature: null,
|
||||
default_max_tokens: null,
|
||||
default_top_p: null,
|
||||
default_frequency_penalty: null,
|
||||
sort_order: 0,
|
||||
is_collapsed: false,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
];
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '独立新建' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.createConversation).toHaveBeenCalledWith(
|
||||
'新建对话',
|
||||
'model-1',
|
||||
'provider-1',
|
||||
{ categoryId: null },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps long sidebar conversation titles in a truncation target with the full title available', () => {
|
||||
const longTitle = '这是一个非常长的会话标题,用于验证侧边栏不会因为标题过长而撑高或者挤压操作按钮';
|
||||
conversationState.conversations[0].title = longTitle;
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
const title = screen.getByText(longTitle);
|
||||
expect(title).toHaveClass('boosapi-chat-conversation-title');
|
||||
expect(title).toHaveAttribute('title', longTitle);
|
||||
});
|
||||
|
||||
it('shows an inline loading status on the conversation row while generating its title', () => {
|
||||
conversationState.titleGeneratingConversationId = 'conv-1';
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
const status = screen.getByRole('status', { name: '正在生成标题' });
|
||||
expect(status).toHaveClass('boosapi-chat-conversation-title-generating');
|
||||
});
|
||||
|
||||
it('keeps long title truncation separate from the title generation loading indicator', () => {
|
||||
const longTitle = '这是一个非常长的会话标题,用于验证侧边栏不会因为标题过长而撑高或者挤压操作按钮';
|
||||
conversationState.conversations[0].title = longTitle;
|
||||
conversationState.titleGeneratingConversationId = 'conv-1';
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
const title = screen.getByText(longTitle);
|
||||
expect(title).toHaveClass('boosapi-chat-conversation-title');
|
||||
expect(title).toHaveAttribute('title', longTitle);
|
||||
expect(title).not.toHaveTextContent('正在生成标题');
|
||||
expect(screen.getByRole('status', { name: '正在生成标题' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds generate title after rename in the right-click menu and calls the title regeneration action', async () => {
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('快捷删除测试'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '生成标题' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const menuLabels = screen
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.getAttribute('aria-label') ?? button.textContent)
|
||||
.filter(Boolean);
|
||||
expect(menuLabels.indexOf('生成标题')).toBeGreaterThan(menuLabels.indexOf('重命名'));
|
||||
expect(menuLabels.indexOf('生成标题')).toBeLessThan(menuLabels.indexOf('导出'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成标题' }));
|
||||
|
||||
expect(mocks.regenerateTitle).toHaveBeenCalledWith('conv-1');
|
||||
});
|
||||
|
||||
it('disables generate title while the conversation title is already generating', async () => {
|
||||
conversationState.titleGeneratingConversationId = 'conv-1';
|
||||
|
||||
render(<ChatSidebar />);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('快捷删除测试'));
|
||||
|
||||
const generateTitleButton = await screen.findByRole('button', { name: '生成标题' });
|
||||
expect(generateTitleButton).toBeDisabled();
|
||||
|
||||
fireEvent.click(generateTitleButton);
|
||||
|
||||
expect(mocks.regenerateTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('ChatView sidebar collapse control', () => {
|
||||
const readSource = () => fs.readFileSync(
|
||||
path.resolve(process.cwd(), 'src/components/chat/ChatView.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
it('renders the sidebar toggle before the active conversation icon in the top bar', () => {
|
||||
const source = readSource();
|
||||
const topBarIndex = source.indexOf('{/* Top Bar */}');
|
||||
const toggleIndex = source.indexOf('{renderChatSidebarToggle()}', topBarIndex);
|
||||
const iconIndex = source.indexOf('{renderConvIconForChat(24)}', topBarIndex);
|
||||
|
||||
expect(toggleIndex).toBeGreaterThan(topBarIndex);
|
||||
expect(toggleIndex).toBeLessThan(iconIndex);
|
||||
});
|
||||
|
||||
it('renders the sidebar toggle before the welcome title when no conversation is active', () => {
|
||||
const source = readSource();
|
||||
const activeIconIndex = source.indexOf('{renderConvIconForChat(24)}');
|
||||
const inactiveToggleIndex = source.indexOf('{renderChatSidebarToggle()}', activeIconIndex + 1);
|
||||
const welcomeIndex = source.indexOf("<Typography.Text type=\"secondary\">{t('chat.welcome')}</Typography.Text>");
|
||||
|
||||
expect(inactiveToggleIndex).toBeGreaterThan(activeIconIndex);
|
||||
expect(inactiveToggleIndex).toBeLessThan(welcomeIndex);
|
||||
});
|
||||
|
||||
it('persists the inverse chat_sidebar_collapsed setting from the title bar button', () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('PanelLeftClose');
|
||||
expect(source).toContain('PanelLeftOpen');
|
||||
expect(source).toContain('const saveSettings = useSettingsStore((s) => s.saveSettings);');
|
||||
expect(source).toContain('saveSettings({ chat_sidebar_collapsed: !chatSidebarCollapsed })');
|
||||
});
|
||||
|
||||
it('does not render a tooltip for the title bar sidebar toggle', () => {
|
||||
const source = readSource();
|
||||
const toggleStart = source.indexOf('const renderChatSidebarToggle = useCallback(() => {');
|
||||
const toggleEnd = source.indexOf('// ── User avatar helper', toggleStart);
|
||||
const toggleSource = source.slice(toggleStart, toggleEnd);
|
||||
|
||||
expect(toggleSource).not.toContain('<Tooltip');
|
||||
expect(toggleSource).toContain('aria-label={label}');
|
||||
});
|
||||
|
||||
it('constrains long active conversation titles in the top bar', () => {
|
||||
const source = readSource();
|
||||
|
||||
expect(source).toContain('boosapi-chat-title-shell');
|
||||
expect(source).toContain('boosapi-chat-title-text');
|
||||
expect(source).toContain('ellipsis={{ tooltip: activeConversation.title }}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import type { Message } from '@/types';
|
||||
import {
|
||||
buildAssistantDisplayContent,
|
||||
splitAssistantErrorDisplayContent,
|
||||
} from '../toolCallDisplay';
|
||||
import {
|
||||
resolveAssistantMessageForBubbleKey,
|
||||
} from '../chatMessageLookup';
|
||||
|
||||
function makeMessage(overrides: Partial<Message>): Message {
|
||||
return {
|
||||
id: 'msg-1',
|
||||
conversation_id: 'conv-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
provider_id: null,
|
||||
model_id: null,
|
||||
token_count: null,
|
||||
attachments: [],
|
||||
thinking: null,
|
||||
tool_calls_json: null,
|
||||
tool_call_id: null,
|
||||
created_at: 1,
|
||||
parent_message_id: null,
|
||||
version_index: 0,
|
||||
is_active: true,
|
||||
status: 'complete',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ChatView assistant display policy', () => {
|
||||
it('does not mount AssistantFooter while an assistant message is streaming', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/components/chat/ChatView.tsx'), 'utf8');
|
||||
const footerBranch = source.match(/footer:\s*msg && activeConversationId \? \([\s\S]*?\) : footerLoading \?/);
|
||||
|
||||
expect(footerBranch?.[0]).toContain('{!isStreaming && (');
|
||||
expect(footerBranch?.[0]).not.toContain('isStreaming={isStreaming}');
|
||||
});
|
||||
|
||||
it('temporarily closes live streaming think content before rendering', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/components/chat/ChatView.tsx'), 'utf8');
|
||||
|
||||
expect(source).toMatch(/renderContentNode\(\s*closeStreamingThinkBlock\(\s*liveContent,/);
|
||||
});
|
||||
|
||||
it('uses a finite live markdown window instead of placeholder batching', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/components/chat/ChatView.tsx'), 'utf8');
|
||||
const propsBlock = source.match(/const CHAT_RENDER_BATCH_PROPS = \{[\s\S]*?\} as const;/)?.[0] ?? '';
|
||||
|
||||
expect(propsBlock).toContain('deferNodesUntilVisible: false');
|
||||
expect(propsBlock).toMatch(/maxLiveNodes:\s*[1-9]\d*,/);
|
||||
expect(propsBlock).not.toMatch(/maxLiveNodes:\s*(?:0|Infinity),/);
|
||||
});
|
||||
|
||||
it('routes plain text and markdown messages through chat typography classes', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/components/chat/ChatView.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('className="boosapi-chat-text"');
|
||||
expect(source).toContain('className="boosapi-chat-markdown"');
|
||||
});
|
||||
|
||||
it('defines chat typography CSS without overriding code font settings', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/index.css'), 'utf8');
|
||||
const chatBlock = source.match(/\.boosapi-chat-text,[\s\S]*?\.boosapi-chat-markdown \.markdown-renderer \{[\s\S]*?\}/)?.[0] ?? '';
|
||||
const codeBlock = source.match(/\.boosapi-chat-markdown (?:pre|code)[\s\S]*?\}/)?.[0] ?? '';
|
||||
|
||||
expect(chatBlock).toContain('font-family: var(--chat-font-family');
|
||||
expect(chatBlock).toContain('font-size: var(--chat-font-size');
|
||||
expect(chatBlock).toContain('line-height: var(--chat-line-height');
|
||||
expect(chatBlock).toContain('font-weight: var(--chat-font-weight');
|
||||
expect(codeBlock).toContain('font-family: var(--code-font-family');
|
||||
expect(codeBlock).not.toContain('--chat-font-family');
|
||||
});
|
||||
|
||||
it('resolves stable ai bubble keys through their parent message id', () => {
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-1',
|
||||
parent_message_id: 'user-1',
|
||||
content: 'final answer',
|
||||
});
|
||||
const assistantByParentId = new Map<string, Message>([['user-1', assistant]]);
|
||||
const messageById = new Map<string, Message>([
|
||||
['user-1', makeMessage({ id: 'user-1', role: 'user', content: 'question' })],
|
||||
['assistant-1', assistant],
|
||||
]);
|
||||
|
||||
expect(resolveAssistantMessageForBubbleKey(
|
||||
'ai:user-1',
|
||||
assistantByParentId,
|
||||
messageById,
|
||||
)).toBe(assistant);
|
||||
});
|
||||
|
||||
it('injects a web-search display card for normal assistant replies with searched parent messages', () => {
|
||||
const user = makeMessage({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: '<!-- search:{"sources":[{"title":"A","url":"https://example.com"}],"query":"BoosApi 下载"} -->\nsearch\n---\n\n用户问题',
|
||||
});
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-1',
|
||||
parent_message_id: 'user-1',
|
||||
content: 'final answer',
|
||||
});
|
||||
|
||||
const content = buildAssistantDisplayContent(assistant, [user, assistant]);
|
||||
|
||||
expect(content).toContain('<web-search status="done" data-boosapi="1"');
|
||||
expect(content).toContain('query="BoosApi 下载"');
|
||||
expect(content).toContain('final answer');
|
||||
});
|
||||
|
||||
it('still injects web-search display when streaming thinking content is present', () => {
|
||||
const user = makeMessage({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: '<!-- search:{"sources":[{"title":"A","url":"https://example.com"}],"query":"BoosApi 下载"} -->\nsearch\n---\n\n用户问题',
|
||||
});
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-1',
|
||||
parent_message_id: 'user-1',
|
||||
content: '<think data-boosapi="1">\n正在分析搜索结果',
|
||||
status: 'partial',
|
||||
});
|
||||
|
||||
const content = buildAssistantDisplayContent(assistant, [user, assistant]);
|
||||
|
||||
expect(content).toContain('<web-search status="done" data-boosapi="1"');
|
||||
expect(content).toContain('<think data-boosapi="1">');
|
||||
});
|
||||
|
||||
it('does not inject a web-search display card into assistant error text', () => {
|
||||
const user = makeMessage({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: '<!-- search:{"sources":[{"title":"A","url":"https://example.com"}]} -->\nsearch\n---\n\n用户问题',
|
||||
});
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-1',
|
||||
parent_message_id: 'user-1',
|
||||
content: '模型首包超时',
|
||||
status: 'error',
|
||||
});
|
||||
|
||||
const content = buildAssistantDisplayContent(assistant, [user, assistant]);
|
||||
|
||||
expect(content).toBe('模型首包超时');
|
||||
expect(content).not.toContain('<web-search');
|
||||
});
|
||||
|
||||
it('splits display-only prefixes away from assistant error messages', () => {
|
||||
const display = splitAssistantErrorDisplayContent(
|
||||
'<web-search status="done" data-boosapi="1">[{"title":"A"}]</web-search>\n\n模型首包超时',
|
||||
);
|
||||
|
||||
expect(display.prefix).toContain('<web-search status="done" data-boosapi="1">');
|
||||
expect(display.message).toBe('模型首包超时');
|
||||
expect(display.message).not.toContain('[{"title":"A"}]');
|
||||
});
|
||||
|
||||
it('splits partial streamed content away from stream error messages', () => {
|
||||
const display = splitAssistantErrorDisplayContent(
|
||||
'已生成的前半段\n\n<!-- boosapi-stream-error -->\n模型响应空闲超时',
|
||||
);
|
||||
|
||||
expect(display.prefix).toBe('已生成的前半段');
|
||||
expect(display.message).toBe('模型响应空闲超时');
|
||||
});
|
||||
|
||||
it('truncates persisted MCP blocks before markdown rendering', () => {
|
||||
const longOutput = 'x'.repeat(25_000);
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-1',
|
||||
content: `before\n\n:::mcp {"name":"server","tool":"fetch_url"}\n${longOutput}\n:::\n\nafter`,
|
||||
});
|
||||
|
||||
const content = buildAssistantDisplayContent(assistant, [assistant]);
|
||||
|
||||
expect(content).toContain('before');
|
||||
expect(content).toContain('after');
|
||||
expect(content).toContain('MCP output truncated for rendering');
|
||||
expect(content.length).toBeLessThan(longOutput.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ConversationModelIcon } from '../ConversationModelIcon';
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
modelMappings: [{ keywords: ['deepseek'] }],
|
||||
ModelIcon: ({ model, size }: { model: string; size: number }) => (
|
||||
<span data-testid="model-icon" data-model={model} data-size={size} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Brain: ({ size, strokeWidth }: { size: number; strokeWidth: number }) => (
|
||||
<span data-testid="brain-icon" data-size={size} data-stroke-width={strokeWidth} />
|
||||
),
|
||||
}));
|
||||
|
||||
describe('ConversationModelIcon', () => {
|
||||
it('wraps model avatars in a fixed centered box', () => {
|
||||
render(<ConversationModelIcon model="deepseek-chat" size={20} />);
|
||||
|
||||
const wrapper = screen.getByTestId('model-icon').parentElement;
|
||||
expect(wrapper).toHaveClass('boosapi-conversation-model-icon');
|
||||
expect(wrapper).toHaveStyle({
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a centered local fallback for unknown model avatars', () => {
|
||||
render(<ConversationModelIcon model="unknown-model" size={20} />);
|
||||
|
||||
expect(screen.queryByTestId('model-icon')).not.toBeInTheDocument();
|
||||
|
||||
const avatar = screen.getByTestId('brain-icon').closest('.boosapi-conversation-model-icon');
|
||||
expect(avatar).toHaveClass('boosapi-conversation-model-icon');
|
||||
expect(avatar).toHaveStyle({
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { HtmlRenderNode } from '../HtmlRenderNode';
|
||||
|
||||
describe('HtmlRenderNode', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders sanitized html directly', () => {
|
||||
render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
raw: '<html-render><div style="color:red" onclick="bad()">safe</div><script>alert(1)</script></html-render>',
|
||||
content: '<div style="color:red" onclick="bad()">safe</div><script>alert(1)</script>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const content = screen.getByTestId('html-render-content');
|
||||
expect(content.innerHTML).toContain('style="color:red"');
|
||||
expect(content.innerHTML).toContain('safe');
|
||||
expect(content.innerHTML).not.toContain('onclick');
|
||||
expect(content.innerHTML).not.toContain('<script');
|
||||
});
|
||||
|
||||
it('removes unsafe urls from rendered links', () => {
|
||||
render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
raw: '<html-render><a href="javascript:alert(1)">bad</a></html-render>',
|
||||
content: '<a href="javascript:alert(1)">bad</a>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByText('bad');
|
||||
expect(link).not.toHaveAttribute('href');
|
||||
});
|
||||
|
||||
it('does not render display chrome around the html fragment', () => {
|
||||
render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
raw: '<html-render><section><h1>Title</h1></section></html-render>',
|
||||
content: '<section><h1>Title</h1></section>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const content = screen.getByTestId('html-render-content');
|
||||
expect(content).toHaveTextContent('Title');
|
||||
expect(screen.queryByText('HTML Render')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '预览' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '源码' })).toBeNull();
|
||||
});
|
||||
|
||||
it('coalesces streaming html updates until the next animation frame', () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
frameCallbacks.push(callback);
|
||||
return frameCallbacks.length;
|
||||
});
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
|
||||
|
||||
const { rerender } = render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
loading: true,
|
||||
content: '<div>first</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('html-render-content')).toHaveTextContent('first');
|
||||
|
||||
rerender(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
loading: true,
|
||||
content: '<div>second</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
rerender(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
loading: true,
|
||||
content: '<div>third</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('html-render-content')).toHaveTextContent('first');
|
||||
expect(frameCallbacks).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
frameCallbacks[0]?.(16);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('html-render-content')).toHaveTextContent('third');
|
||||
});
|
||||
|
||||
it('renders final html updates immediately without waiting for animation frame', () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
frameCallbacks.push(callback);
|
||||
return frameCallbacks.length;
|
||||
});
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
|
||||
|
||||
const { rerender } = render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
loading: true,
|
||||
content: '<div>draft</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
loading: false,
|
||||
content: '<div>final</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('html-render-content')).toHaveTextContent('final');
|
||||
});
|
||||
|
||||
it('adapts common light inline colors for dark mode', () => {
|
||||
render(
|
||||
<HtmlRenderNode
|
||||
{...({
|
||||
isDark: true,
|
||||
node: {
|
||||
type: 'html-render',
|
||||
content: '<div style="color:#111;background:#fff;border:1px solid #eee">dark friendly</div>',
|
||||
},
|
||||
} as any)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const content = screen.getByTestId('html-render-content');
|
||||
expect(content).toHaveStyle({ colorScheme: 'dark' });
|
||||
expect(content.innerHTML).toContain('var(--boosapi-html-fg)');
|
||||
expect(content.innerHTML).toContain('rgba(255,255,255,0.06)');
|
||||
expect(content.innerHTML).toContain('rgba(255,255,255,0.18)');
|
||||
});
|
||||
|
||||
it('removes style tags from html render fragments', () => {
|
||||
render(
|
||||
<HtmlRenderNode
|
||||
node={{
|
||||
type: 'html-render',
|
||||
content: '<style>.x{color:red}</style><div>safe</div>',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const content = screen.getByTestId('html-render-content');
|
||||
expect(content).toHaveTextContent('safe');
|
||||
expect(content.innerHTML).not.toContain('<style');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { App } from 'antd';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AppSettings } from '@/types';
|
||||
import { InputArea } from '../InputArea';
|
||||
|
||||
const sendMessage = vi.fn();
|
||||
const createConversation = vi.fn();
|
||||
const setSearchEnabled = vi.fn();
|
||||
const setSearchProviderId = vi.fn();
|
||||
const loadSearchProviders = vi.fn();
|
||||
const loadMcpServers = vi.fn();
|
||||
const toggleMcpServer = vi.fn();
|
||||
const loadKnowledgeBases = vi.fn();
|
||||
const toggleKnowledgeBase = vi.fn();
|
||||
const loadMemoryNamespaces = vi.fn();
|
||||
const toggleMemoryNamespace = vi.fn();
|
||||
const setThinkingBudget = vi.fn();
|
||||
const setThinkingLevel = vi.fn();
|
||||
const insertContextClear = vi.fn();
|
||||
const getContextUsage = vi.fn();
|
||||
const setActivePage = vi.fn();
|
||||
const setSettingsSection = vi.fn();
|
||||
|
||||
const conversationState = {
|
||||
streaming: false,
|
||||
compressingConversationId: null as string | null,
|
||||
activeConversationId: 'conv-1',
|
||||
sendMessage,
|
||||
createConversation,
|
||||
messages: [],
|
||||
conversations: [
|
||||
{
|
||||
id: 'conv-1',
|
||||
title: 'Test',
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
},
|
||||
],
|
||||
searchEnabled: true,
|
||||
searchProviderId: 'search-1',
|
||||
setSearchEnabled,
|
||||
setSearchProviderId,
|
||||
enabledMcpServerIds: [] as string[],
|
||||
toggleMcpServer,
|
||||
enabledKnowledgeBaseIds: [] as string[],
|
||||
toggleKnowledgeBase,
|
||||
enabledMemoryNamespaceIds: [] as string[],
|
||||
toggleMemoryNamespace,
|
||||
thinkingBudget: null as number | null,
|
||||
thinkingLevel: null as string | null,
|
||||
setThinkingBudget,
|
||||
setThinkingLevel,
|
||||
insertContextClear,
|
||||
getContextUsage,
|
||||
};
|
||||
|
||||
const providerState = {
|
||||
providers: [
|
||||
{
|
||||
id: 'provider-1',
|
||||
provider_type: 'gemini',
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'model-1',
|
||||
name: 'model-1',
|
||||
model_type: 'Chat',
|
||||
enabled: true,
|
||||
capabilities: [] as string[],
|
||||
max_tokens: 128000,
|
||||
param_overrides: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const settingsState: { settings: Partial<AppSettings> } = {
|
||||
settings: {
|
||||
default_provider_id: null,
|
||||
default_model_id: null,
|
||||
document_attachment_reading_enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
const searchState = {
|
||||
providers: [
|
||||
{
|
||||
id: 'search-1',
|
||||
name: 'Test Search',
|
||||
providerType: 'tavily',
|
||||
},
|
||||
],
|
||||
loadProviders: loadSearchProviders,
|
||||
};
|
||||
|
||||
const mcpState = {
|
||||
servers: [],
|
||||
loadServers: loadMcpServers,
|
||||
};
|
||||
|
||||
const knowledgeState = {
|
||||
bases: [],
|
||||
loadBases: loadKnowledgeBases,
|
||||
};
|
||||
|
||||
const memoryState = {
|
||||
namespaces: [],
|
||||
loadNamespaces: loadMemoryNamespaces,
|
||||
};
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useConversationStore: Object.assign(
|
||||
(selector: (state: typeof conversationState) => unknown) => selector(conversationState),
|
||||
{ getState: () => conversationState },
|
||||
),
|
||||
useProviderStore: Object.assign(
|
||||
(selector: (state: typeof providerState) => unknown) => selector(providerState),
|
||||
{ getState: () => providerState },
|
||||
),
|
||||
useSettingsStore: (selector: (state: typeof settingsState) => unknown) => selector(settingsState),
|
||||
useSearchStore: (selector: (state: typeof searchState) => unknown) => selector(searchState),
|
||||
useMcpStore: (selector: (state: typeof mcpState) => unknown) => selector(mcpState),
|
||||
useKnowledgeStore: (selector: (state: typeof knowledgeState) => unknown) => selector(knowledgeState),
|
||||
useMemoryStore: (selector: (state: typeof memoryState) => unknown) => selector(memoryState),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/uiStore', () => ({
|
||||
useUIStore: (selector: (state: { setActivePage: typeof setActivePage; setSettingsSection: typeof setSettingsSection }) => unknown) =>
|
||||
selector({ setActivePage, setSettingsSection }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/modelCapabilities', () => ({
|
||||
findModelByIds: (providers: typeof providerState.providers, providerId: string, modelId: string) =>
|
||||
providers.find((provider) => provider.id === providerId)?.models.find((model) => model.model_id === modelId) ?? null,
|
||||
supportsReasoning: (model: { capabilities?: string[] } | null | undefined) => model?.capabilities?.includes('Reasoning') ?? false,
|
||||
modelHasCapability: (model: { capabilities?: string[] } | null | undefined, capability: string) =>
|
||||
model?.capabilities?.includes(capability) ?? false,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/SearchProviderIcon', () => ({
|
||||
SearchProviderTypeIcon: () => null,
|
||||
PROVIDER_TYPE_LABELS: {
|
||||
tavily: 'Tavily',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
ModelIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../VoiceCall', () => ({
|
||||
VoiceCall: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../ConversationSettingsModal', () => ({
|
||||
ConversationSettingsModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../ModelSelector', () => ({
|
||||
ModelSelector: () => null,
|
||||
}));
|
||||
|
||||
describe('InputArea', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
providerState.providers[0].provider_type = 'gemini';
|
||||
providerState.providers[0].models[0].model_id = 'model-1';
|
||||
providerState.providers[0].models[0].name = 'model-1';
|
||||
providerState.providers[0].models[0].capabilities = [];
|
||||
providerState.providers[0].models[0].param_overrides = null;
|
||||
conversationState.conversations[0].model_id = 'model-1';
|
||||
conversationState.thinkingBudget = null;
|
||||
conversationState.thinkingLevel = null;
|
||||
conversationState.compressingConversationId = null;
|
||||
getContextUsage.mockResolvedValue(null);
|
||||
settingsState.settings.document_attachment_reading_enabled = false;
|
||||
});
|
||||
|
||||
const waitForNextFrame = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
|
||||
it('focuses the chat textarea when the window regains focus without another active input', async () => {
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('chat.inputPlaceholder') as HTMLTextAreaElement;
|
||||
expect(document.activeElement).not.toBe(textarea);
|
||||
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
await waitForNextFrame();
|
||||
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
});
|
||||
|
||||
it('does not steal focus from another focused input when the window regains focus', async () => {
|
||||
render(
|
||||
<>
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>
|
||||
<input aria-label="external-input" />
|
||||
</>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('chat.inputPlaceholder') as HTMLTextAreaElement;
|
||||
const externalInput = screen.getByLabelText('external-input') as HTMLInputElement;
|
||||
externalInput.focus();
|
||||
expect(document.activeElement).toBe(externalInput);
|
||||
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
await waitForNextFrame();
|
||||
|
||||
expect(document.activeElement).toBe(externalInput);
|
||||
expect(document.activeElement).not.toBe(textarea);
|
||||
});
|
||||
|
||||
it('clears the textarea immediately after sending even while search-backed send is still pending', async () => {
|
||||
let resolveSend!: () => void;
|
||||
sendMessage.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveSend = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText('chat.inputPlaceholder') as HTMLTextAreaElement;
|
||||
await userEvent.type(textarea, 'search me');
|
||||
|
||||
expect(textarea.value).toBe('search me');
|
||||
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledWith('search me', undefined, 'search-1');
|
||||
expect(textarea.value).toBe('');
|
||||
|
||||
resolveSend();
|
||||
});
|
||||
|
||||
it('renders model-specific reasoning options for Gemini 3.1 models', async () => {
|
||||
providerState.providers[0].provider_type = 'gemini';
|
||||
providerState.providers[0].models[0].model_id = 'gemini-3.1-flash';
|
||||
providerState.providers[0].models[0].name = 'Gemini 3.1 Flash';
|
||||
providerState.providers[0].models[0].capabilities = ['Reasoning'];
|
||||
conversationState.conversations[0].model_id = 'gemini-3.1-flash';
|
||||
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByLabelText('chat.thinkingIntensity'));
|
||||
|
||||
expect(await screen.findByText('Minimal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Low')).toBeInTheDocument();
|
||||
expect(screen.getByText('Medium')).toBeInTheDocument();
|
||||
expect(screen.getByText('High')).toBeInTheDocument();
|
||||
expect(screen.queryByText('XHigh')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders DeepSeek V4 reasoning options from the provider profile', async () => {
|
||||
providerState.providers[0].provider_type = 'deepseek';
|
||||
providerState.providers[0].models[0].model_id = 'deepseek-v4-flash';
|
||||
providerState.providers[0].models[0].name = 'DeepSeek v4 Flash';
|
||||
providerState.providers[0].models[0].capabilities = ['Reasoning'];
|
||||
conversationState.conversations[0].model_id = 'deepseek-v4-flash';
|
||||
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByLabelText('chat.thinkingIntensity'));
|
||||
|
||||
expect(await screen.findByText('High')).toBeInTheDocument();
|
||||
expect(screen.getByText('Max')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Low')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Medium')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('XHigh')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows backend context usage instead of a loaded-message estimate', async () => {
|
||||
getContextUsage.mockResolvedValueOnce({
|
||||
used_tokens: 720000,
|
||||
max_tokens: 1000000,
|
||||
threshold_tokens: 700000,
|
||||
has_summary: true,
|
||||
compressed_until_message_id: 'msg-1',
|
||||
messages_after_boundary: 3,
|
||||
});
|
||||
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(getContextUsage).toHaveBeenCalledWith('conv-1'));
|
||||
await userEvent.hover(screen.getByLabelText('上下文 tokens'));
|
||||
|
||||
expect(await screen.findByText('720,000 / 1,000,000 tokens (72%)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows document attachment controls for non-vision models when document reading is enabled', () => {
|
||||
settingsState.settings.document_attachment_reading_enabled = true;
|
||||
|
||||
render(
|
||||
<App>
|
||||
<InputArea />
|
||||
</App>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('chat.attachFile')).toBeInTheDocument();
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(input?.accept).toContain('.pdf');
|
||||
expect(input?.accept).toContain('.doc');
|
||||
expect(input?.accept).toContain('.docx');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import type React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Conversation, ProviderConfig } from '@/types';
|
||||
import { ModelSelector } from '../ModelSelector';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
updateConversation: vi.fn(),
|
||||
saveSettings: vi.fn(),
|
||||
setActivePage: vi.fn(),
|
||||
setSettingsSection: vi.fn(),
|
||||
setSelectedProviderId: vi.fn(),
|
||||
}));
|
||||
|
||||
let providers: ProviderConfig[] = [];
|
||||
let conversations: Conversation[] = [];
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
function makeProvider(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
|
||||
return {
|
||||
id: 'provider-1',
|
||||
name: 'OpenAI Compatible',
|
||||
provider_type: 'openai',
|
||||
api_host: 'https://api.example.com',
|
||||
api_path: '/v1/chat/completions',
|
||||
enabled: true,
|
||||
models: [],
|
||||
keys: [],
|
||||
proxy_config: null,
|
||||
custom_headers: null,
|
||||
icon: null,
|
||||
builtin_id: null,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeConversation(overrides: Partial<Conversation> = {}): Conversation {
|
||||
return {
|
||||
id: 'conv-1',
|
||||
title: 'Test',
|
||||
model_id: 'gpt-5.4',
|
||||
provider_id: 'provider-1',
|
||||
system_prompt: null,
|
||||
temperature: null,
|
||||
max_tokens: null,
|
||||
top_p: null,
|
||||
frequency_penalty: null,
|
||||
search_enabled: false,
|
||||
search_provider_id: null,
|
||||
thinking_budget: null,
|
||||
thinking_level: null,
|
||||
enabled_mcp_server_ids: [],
|
||||
enabled_knowledge_base_ids: [],
|
||||
enabled_memory_namespace_ids: [],
|
||||
is_pinned: false,
|
||||
is_archived: false,
|
||||
context_compression: false,
|
||||
category_id: null,
|
||||
parent_conversation_id: null,
|
||||
mode: 'chat',
|
||||
message_count: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
ModelIcon: () => <span data-testid="model-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock('../ConversationModelIcon', () => ({
|
||||
ConversationModelIcon: ({ model, size }: { model: string; size: number }) => (
|
||||
<span data-testid="conversation-model-icon" data-model={model} data-size={size} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/providerIcons', () => ({
|
||||
SmartProviderIcon: () => <span data-testid="provider-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/shortcuts', () => ({
|
||||
getShortcutBinding: () => null,
|
||||
formatShortcutForDisplay: () => '',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/modelCapabilities', () => ({
|
||||
getVisibleModelCapabilities: () => [],
|
||||
}));
|
||||
|
||||
vi.mock('@tanstack/react-virtual', () => ({
|
||||
useVirtualizer: ({ count, getItemKey }: { count: number; getItemKey: (index: number) => React.Key }) => ({
|
||||
getTotalSize: () => count * 40,
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
index,
|
||||
key: getItemKey(index),
|
||||
start: index * 40,
|
||||
})),
|
||||
measureElement: vi.fn(),
|
||||
scrollToIndex: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Tag: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
|
||||
Modal: ({ open, children, footer, title }: { open?: boolean; children?: React.ReactNode; footer?: React.ReactNode; title?: React.ReactNode }) => (
|
||||
open ? <div>{title}{children}{footer}</div> : null
|
||||
),
|
||||
Input: ({ prefix, placeholder, value, onChange, onKeyDown }: {
|
||||
prefix?: React.ReactNode;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
onChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
|
||||
}) => (
|
||||
<label>
|
||||
{prefix}
|
||||
<input placeholder={placeholder} value={value} onChange={onChange} onKeyDown={onKeyDown} />
|
||||
</label>
|
||||
),
|
||||
Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
Button: ({ children, onClick }: { children?: React.ReactNode; onClick?: React.MouseEventHandler<HTMLButtonElement> }) => (
|
||||
<button onClick={onClick}>{children}</button>
|
||||
),
|
||||
Checkbox: ({ checked }: { checked?: boolean }) => <input type="checkbox" readOnly checked={checked} />,
|
||||
theme: {
|
||||
useToken: () => ({
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
colorPrimaryBg: '#e6f4ff',
|
||||
colorFillSecondary: '#f5f5f5',
|
||||
colorFillTertiary: '#fafafa',
|
||||
colorTextSecondary: '#666',
|
||||
colorTextQuaternary: '#999',
|
||||
colorBorderSecondary: '#eee',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useProviderStore: (selector?: (state: { providers: ProviderConfig[] }) => unknown) => {
|
||||
const state = { providers };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
useConversationStore: (selector?: (state: { activeConversationId: string; conversations: Conversation[]; updateConversation: typeof mocks.updateConversation }) => unknown) => {
|
||||
const state = {
|
||||
activeConversationId: 'conv-1',
|
||||
conversations,
|
||||
updateConversation: mocks.updateConversation,
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
useSettingsStore: (selector: (state: { settings: Record<string, unknown>; saveSettings: typeof mocks.saveSettings }) => unknown) =>
|
||||
selector({
|
||||
settings,
|
||||
saveSettings: mocks.saveSettings,
|
||||
}),
|
||||
useUIStore: (selector: (state: {
|
||||
setActivePage: typeof mocks.setActivePage;
|
||||
setSettingsSection: typeof mocks.setSettingsSection;
|
||||
setSelectedProviderId: typeof mocks.setSelectedProviderId;
|
||||
}) => unknown) =>
|
||||
selector({
|
||||
setActivePage: mocks.setActivePage,
|
||||
setSettingsSection: mocks.setSettingsSection,
|
||||
setSelectedProviderId: mocks.setSelectedProviderId,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ModelSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
providers = [
|
||||
makeProvider({
|
||||
models: [
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'gpt-5.4',
|
||||
name: 'GPT 5.4',
|
||||
group_name: null,
|
||||
model_type: 'Chat',
|
||||
capabilities: ['TextChat'],
|
||||
max_tokens: 128000,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'gpt-image-2',
|
||||
name: 'GPT Image 2',
|
||||
group_name: null,
|
||||
model_type: 'Image',
|
||||
capabilities: [],
|
||||
max_tokens: null,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'text-embedding-3-large',
|
||||
name: 'Text Embedding 3 Large',
|
||||
group_name: null,
|
||||
model_type: 'Embedding',
|
||||
capabilities: [],
|
||||
max_tokens: null,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'jina-reranker-v3',
|
||||
name: 'Jina Reranker v3',
|
||||
group_name: null,
|
||||
model_type: 'Rerank',
|
||||
capabilities: [],
|
||||
max_tokens: null,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
conversations = [makeConversation()];
|
||||
settings = {
|
||||
default_provider_id: null,
|
||||
default_model_id: null,
|
||||
show_image_models_in_model_selector: false,
|
||||
};
|
||||
});
|
||||
|
||||
it('shows only enabled chat models in the conversation model selector', () => {
|
||||
render(<ModelSelector open onOpenChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getAllByText('GPT 5.4').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('GPT Image 2')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Text Embedding 3 Large')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Jina Reranker v3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show pinned non-chat models', () => {
|
||||
localStorage.setItem('boosapi_pinned_models', JSON.stringify(['provider-1::gpt-image-2']));
|
||||
|
||||
render(
|
||||
<ModelSelector
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
excludeModelKeys={['provider-1::gpt-5.4']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('chat.pinnedModels')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('GPT Image 2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows enabled image models when the setting is enabled', () => {
|
||||
settings = {
|
||||
...settings,
|
||||
show_image_models_in_model_selector: true,
|
||||
};
|
||||
|
||||
render(<ModelSelector open onOpenChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getAllByText('GPT 5.4').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('GPT Image 2').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('Text Embedding 3 Large')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Jina Reranker v3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows pinned image models when the setting is enabled', () => {
|
||||
settings = {
|
||||
...settings,
|
||||
show_image_models_in_model_selector: true,
|
||||
};
|
||||
localStorage.setItem('boosapi_pinned_models', JSON.stringify(['provider-1::gpt-image-2']));
|
||||
|
||||
render(
|
||||
<ModelSelector
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
excludeModelKeys={['provider-1::gpt-5.4']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('chat.pinnedModels')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('GPT Image 2').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('uses the centered conversation model fallback in the current model trigger', () => {
|
||||
conversations = [makeConversation({ model_id: 'unknown-model', provider_id: 'missing-provider' })];
|
||||
|
||||
render(<ModelSelector />);
|
||||
|
||||
const icon = screen.getByTestId('conversation-model-icon');
|
||||
expect(icon).toHaveAttribute('data-model', 'unknown-model');
|
||||
expect(icon).toHaveAttribute('data-size', '16');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import { App } from 'antd';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type React from 'react';
|
||||
import type { Message } from '@/types';
|
||||
import { clearLiveStreamContent, setLiveStreamContent, useConversationStore } from '@/stores';
|
||||
import { MultiModelDisplay } from '../MultiModelDisplay';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/icons', () => ({
|
||||
ModelIcon: ({ model }: { model: string }) => <span data-testid="model-icon">{model}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('overlayscrollbars', () => ({
|
||||
OverlayScrollbars: vi.fn(() => ({ destroy: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock('../ModelSelector', () => ({
|
||||
ModelSelector: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
function makeMessage(overrides: Partial<Message> & Pick<Message, 'id' | 'model_id' | 'content'>): Message {
|
||||
return {
|
||||
id: overrides.id,
|
||||
conversation_id: 'conv-1',
|
||||
role: 'assistant',
|
||||
content: overrides.content,
|
||||
provider_id: overrides.provider_id ?? 'provider-1',
|
||||
model_id: overrides.model_id,
|
||||
token_count: null,
|
||||
prompt_tokens: null,
|
||||
completion_tokens: null,
|
||||
attachments: [],
|
||||
thinking: null,
|
||||
tool_calls_json: null,
|
||||
tool_call_id: null,
|
||||
created_at: overrides.created_at ?? 1,
|
||||
parent_message_id: overrides.parent_message_id ?? 'user-1',
|
||||
version_index: overrides.version_index ?? 0,
|
||||
is_active: overrides.is_active ?? true,
|
||||
status: overrides.status ?? 'complete',
|
||||
tokens_per_second: null,
|
||||
first_token_latency_ms: null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDisplay(
|
||||
versions: Message[],
|
||||
activeMessageId = versions[0]?.id ?? '',
|
||||
mode: 'side-by-side' | 'stacked' = 'side-by-side',
|
||||
props: Partial<React.ComponentProps<typeof MultiModelDisplay>> = {},
|
||||
) {
|
||||
return (
|
||||
<App>
|
||||
<MultiModelDisplay
|
||||
versions={versions}
|
||||
activeMessageId={activeMessageId}
|
||||
mode={mode}
|
||||
conversationId="conv-1"
|
||||
onSwitchVersion={vi.fn()}
|
||||
onDeleteVersion={vi.fn()}
|
||||
streamingMessageId={null}
|
||||
multiModelDoneMessageIds={[]}
|
||||
getModelDisplayInfo={(modelId) => ({ modelName: modelId ?? '', providerName: '' })}
|
||||
renderContent={(message) => <div>{message.content}</div>}
|
||||
{...props}
|
||||
/>
|
||||
</App>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDisplayWithStreamingLabel(versions: Message[], streamingMessageId: string | null) {
|
||||
return (
|
||||
<App>
|
||||
<MultiModelDisplay
|
||||
versions={versions}
|
||||
activeMessageId={versions[0]?.id ?? ''}
|
||||
mode="side-by-side"
|
||||
conversationId="conv-1"
|
||||
onSwitchVersion={vi.fn()}
|
||||
onDeleteVersion={vi.fn()}
|
||||
streamingMessageId={streamingMessageId}
|
||||
multiModelDoneMessageIds={[]}
|
||||
getModelDisplayInfo={(modelId) => ({ modelName: modelId ?? '', providerName: '' })}
|
||||
renderContent={(message, isStreaming) => (
|
||||
<div data-testid={`content-${message.id}`}>
|
||||
{isStreaming ? 'streaming' : 'stable'}:{message.content}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</App>
|
||||
);
|
||||
}
|
||||
|
||||
describe('MultiModelDisplay', () => {
|
||||
beforeEach(() => {
|
||||
useConversationStore.setState({
|
||||
messages: [],
|
||||
activeConversationId: 'conv-1',
|
||||
streaming: false,
|
||||
streamingConversationId: null,
|
||||
streamingMessageId: null,
|
||||
});
|
||||
clearLiveStreamContent('assistant-a');
|
||||
clearLiveStreamContent('assistant-b');
|
||||
});
|
||||
|
||||
it('does not fall back to the error boundary when deleting down to one model', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({ id: 'assistant-b', model_id: 'model-b', content: 'beta', is_active: false, version_index: 1 });
|
||||
|
||||
const { rerender } = render(renderDisplay([modelA, modelB]));
|
||||
|
||||
expect(screen.getByText('alpha')).toBeInTheDocument();
|
||||
expect(screen.getByText('beta')).toBeInTheDocument();
|
||||
|
||||
rerender(renderDisplay([modelA]));
|
||||
|
||||
expect(screen.queryByText('Multi-model display error')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('alpha')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates an inactive streaming card from the store without rerendering the parent bubble item', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({ id: 'assistant-b', model_id: 'model-b', content: '', is_active: false, status: 'partial', version_index: 1 });
|
||||
useConversationStore.setState({ messages: [modelA, modelB] });
|
||||
|
||||
render(renderDisplay([modelA, modelB]));
|
||||
|
||||
expect(screen.queryByText('streamed token')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
useConversationStore.setState({
|
||||
messages: [modelA, { ...modelB, content: 'streamed token' }],
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByText('streamed token')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates an inactive streaming card from live stream content without replacing store messages', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({ id: 'assistant-b', model_id: 'model-b', content: '', is_active: false, status: 'partial', version_index: 1 });
|
||||
useConversationStore.setState({
|
||||
messages: [modelA, modelB],
|
||||
streaming: true,
|
||||
streamingConversationId: 'conv-1',
|
||||
streamingMessageId: null,
|
||||
});
|
||||
|
||||
render(renderDisplay([modelA, modelB]));
|
||||
|
||||
act(() => {
|
||||
setLiveStreamContent('assistant-b', 'streamed token');
|
||||
});
|
||||
|
||||
expect(screen.getByText('streamed token')).toBeInTheDocument();
|
||||
expect(useConversationStore.getState().messages.find((message) => message.id === 'assistant-b')?.content).toBe('');
|
||||
});
|
||||
|
||||
it('shows the active same-model version in side-by-side mode', () => {
|
||||
const modelAOld = makeMessage({
|
||||
id: 'assistant-a-old',
|
||||
model_id: 'model-a',
|
||||
content: 'old same-model answer',
|
||||
is_active: true,
|
||||
version_index: 0,
|
||||
created_at: 1,
|
||||
});
|
||||
const modelALatest = makeMessage({
|
||||
id: 'assistant-a-latest',
|
||||
model_id: 'model-a',
|
||||
content: 'latest same-model answer',
|
||||
is_active: false,
|
||||
version_index: 1,
|
||||
created_at: 2,
|
||||
});
|
||||
const modelB = makeMessage({
|
||||
id: 'assistant-b',
|
||||
model_id: 'model-b',
|
||||
content: 'other model answer',
|
||||
is_active: false,
|
||||
version_index: 0,
|
||||
created_at: 3,
|
||||
});
|
||||
|
||||
render(renderDisplay([modelAOld, modelALatest, modelB], modelAOld.id, 'side-by-side'));
|
||||
|
||||
expect(screen.getByText('old same-model answer')).toBeInTheDocument();
|
||||
expect(screen.queryByText('latest same-model answer')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('other model answer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the active same-model version in stacked mode', () => {
|
||||
const modelAOld = makeMessage({
|
||||
id: 'assistant-a-old',
|
||||
model_id: 'model-a',
|
||||
content: 'old stacked answer',
|
||||
is_active: true,
|
||||
version_index: 0,
|
||||
created_at: 1,
|
||||
});
|
||||
const modelALatest = makeMessage({
|
||||
id: 'assistant-a-latest',
|
||||
model_id: 'model-a',
|
||||
content: 'latest stacked answer',
|
||||
is_active: false,
|
||||
version_index: 1,
|
||||
created_at: 2,
|
||||
});
|
||||
const modelB = makeMessage({
|
||||
id: 'assistant-b',
|
||||
model_id: 'model-b',
|
||||
content: 'stacked other model answer',
|
||||
is_active: false,
|
||||
version_index: 0,
|
||||
created_at: 3,
|
||||
});
|
||||
|
||||
render(renderDisplay([modelAOld, modelALatest, modelB], modelAOld.id, 'stacked'));
|
||||
|
||||
expect(screen.getByText('old stacked answer')).toBeInTheDocument();
|
||||
expect(screen.queryByText('latest stacked answer')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('stacked other model answer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('treats partial cards as streaming while their conversation is streaming even without a matching streamingMessageId', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({
|
||||
id: 'assistant-b',
|
||||
model_id: 'model-b',
|
||||
content: '```ts\nconst token = 1;',
|
||||
is_active: false,
|
||||
status: 'partial',
|
||||
version_index: 1,
|
||||
});
|
||||
useConversationStore.setState({
|
||||
messages: [modelA, modelB],
|
||||
streaming: true,
|
||||
streamingConversationId: 'conv-1',
|
||||
streamingMessageId: null,
|
||||
});
|
||||
|
||||
render(renderDisplayWithStreamingLabel([modelA, modelB], null));
|
||||
|
||||
expect(screen.getByTestId('content-assistant-b')).toHaveTextContent('streaming:```ts');
|
||||
});
|
||||
|
||||
it('routes per-card actions to the displayed message without switching context', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({ id: 'assistant-b', model_id: 'model-b', content: 'beta', is_active: false, version_index: 1 });
|
||||
const onRegenerateVersion = vi.fn();
|
||||
const onSetContextVersion = vi.fn();
|
||||
const onSwitchVersion = vi.fn();
|
||||
|
||||
render(renderDisplay([modelA, modelB], modelA.id, 'side-by-side', {
|
||||
onRegenerateVersion,
|
||||
onSetContextVersion,
|
||||
onSwitchVersion,
|
||||
}));
|
||||
|
||||
fireEvent.click(screen.getByTestId('multi-model-regenerate-assistant-b'));
|
||||
expect(onRegenerateVersion).toHaveBeenCalledWith(modelB);
|
||||
expect(onSwitchVersion).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTestId('multi-model-set-context-assistant-b'));
|
||||
expect(onSetContextVersion).toHaveBeenCalledWith(modelB);
|
||||
});
|
||||
|
||||
it('keeps context selection in the card header while operations stay in the footer', () => {
|
||||
const modelA = makeMessage({ id: 'assistant-a', model_id: 'model-a', content: 'alpha' });
|
||||
const modelB = makeMessage({ id: 'assistant-b', model_id: 'model-b', content: 'beta', is_active: false, version_index: 1 });
|
||||
|
||||
render(renderDisplay([modelA, modelB], modelA.id, 'side-by-side', {
|
||||
onRegenerateVersion: vi.fn(),
|
||||
onSetContextVersion: vi.fn(),
|
||||
}));
|
||||
|
||||
expect(screen.getByTestId('multi-model-set-context-assistant-b').closest('.multi-model-card-header-actions')).not.toBeNull();
|
||||
expect(screen.getByTestId('multi-model-set-context-assistant-b').closest('.multi-model-card-footer-actions')).toBeNull();
|
||||
expect(screen.getByTestId('multi-model-regenerate-assistant-b').closest('.multi-model-card-footer-actions')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('stretches card content so footer actions stay pinned to the bottom', () => {
|
||||
const modelA = makeMessage({
|
||||
id: 'assistant-a',
|
||||
model_id: 'model-a',
|
||||
content: 'alpha\n\nalpha\n\nalpha\n\nalpha',
|
||||
});
|
||||
const modelB = makeMessage({
|
||||
id: 'assistant-b',
|
||||
model_id: 'model-b',
|
||||
content: 'beta',
|
||||
is_active: false,
|
||||
version_index: 1,
|
||||
});
|
||||
|
||||
render(renderDisplay([modelA, modelB], modelA.id, 'side-by-side', {
|
||||
onRegenerateVersion: vi.fn(),
|
||||
}));
|
||||
|
||||
const shortCard = screen.getByTestId('multi-model-card-assistant-b');
|
||||
const shortContent = screen.getByTestId('multi-model-card-content-assistant-b');
|
||||
|
||||
expect(shortCard).toHaveStyle({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
expect(shortContent.getAttribute('style')).toContain('flex: 1');
|
||||
expect(shortContent).toHaveStyle({ minHeight: '0' });
|
||||
expect(screen.getByTestId('multi-model-regenerate-assistant-b').closest('.multi-model-card-footer-actions')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('switches the displayed same-model version locally without setting context', () => {
|
||||
const modelAOld = makeMessage({
|
||||
id: 'assistant-a-old',
|
||||
model_id: 'model-a',
|
||||
content: 'old same-model answer',
|
||||
is_active: true,
|
||||
version_index: 0,
|
||||
created_at: 1,
|
||||
});
|
||||
const modelALatest = makeMessage({
|
||||
id: 'assistant-a-latest',
|
||||
model_id: 'model-a',
|
||||
content: 'latest same-model answer',
|
||||
is_active: false,
|
||||
version_index: 1,
|
||||
created_at: 2,
|
||||
});
|
||||
const modelB = makeMessage({
|
||||
id: 'assistant-b',
|
||||
model_id: 'model-b',
|
||||
content: 'other model answer',
|
||||
is_active: false,
|
||||
version_index: 0,
|
||||
created_at: 3,
|
||||
});
|
||||
const onDisplayVersionChange = vi.fn();
|
||||
const onSwitchVersion = vi.fn();
|
||||
|
||||
render(renderDisplay([modelAOld, modelALatest, modelB], modelAOld.id, 'side-by-side', {
|
||||
onDisplayVersionChange,
|
||||
onSwitchVersion,
|
||||
}));
|
||||
|
||||
fireEvent.click(screen.getByTestId('multi-model-version-next-assistant-a-old'));
|
||||
|
||||
expect(onDisplayVersionChange).toHaveBeenCalledWith(
|
||||
modelAOld.parent_message_id,
|
||||
'provider-1:model-a',
|
||||
modelALatest.id,
|
||||
);
|
||||
expect(onSwitchVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WebSearchNode, WebSearchQueryNode } from '../WebSearchNode';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => ({
|
||||
'chat.search.searching': '正在联网搜索...',
|
||||
'chat.search.summarizingQuery': '正在总结搜索语句...',
|
||||
'chat.search.querySummary': '搜索语句总结完成',
|
||||
'chat.search.querySummaryFailed': '搜索语句总结失败',
|
||||
'chat.search.resultsCount': '1个搜索结果',
|
||||
'chat.search.noResults': '未找到相关搜索结果',
|
||||
'chat.search.query': '搜索语句',
|
||||
'chat.search.error': '搜索失败',
|
||||
})[key] ?? fallback ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('WebSearchNode', () => {
|
||||
it('shows the searching state when attrs come from tuple pairs', () => {
|
||||
render(
|
||||
<WebSearchNode
|
||||
node={{
|
||||
type: 'web-search',
|
||||
attrs: [['status', 'searching']],
|
||||
content: '',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('正在联网搜索...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the query summarization state', () => {
|
||||
render(
|
||||
<WebSearchQueryNode
|
||||
node={{
|
||||
type: 'web-search-query',
|
||||
attrs: { status: 'summarizing' },
|
||||
content: '',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('正在总结搜索语句...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reveals the summarized search query when expanded', () => {
|
||||
render(
|
||||
<WebSearchQueryNode
|
||||
node={{
|
||||
type: 'web-search-query',
|
||||
attrs: {
|
||||
status: 'done',
|
||||
query: 'BoosApi Desktop 0.0.76 Windows 下载地址',
|
||||
},
|
||||
content: '',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('BoosApi Desktop 0.0.76 Windows 下载地址')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('搜索语句总结完成'));
|
||||
expect(screen.getByText('搜索语句总结完成')).toBeInTheDocument();
|
||||
expect(screen.getByText('BoosApi Desktop 0.0.76 Windows 下载地址')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows query summary errors below the fold title with an Ant alert', () => {
|
||||
const { container } = render(
|
||||
<WebSearchQueryNode
|
||||
node={{
|
||||
type: 'web-search-query',
|
||||
attrs: {
|
||||
status: 'error',
|
||||
query: 'BoosApi 产品详情',
|
||||
},
|
||||
content: '模型返回空搜索语句',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const title = screen.getByText('搜索语句总结失败');
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(title.closest('[data-boosapi-search-query-status="error"]')).toBeInTheDocument();
|
||||
expect(title.closest('[data-boosapi-search-query-status="error"]')).toHaveStyle({
|
||||
color: '#ff4d4f',
|
||||
});
|
||||
expect(screen.getByText('模型返回空搜索语句')).toBeInTheDocument();
|
||||
expect(screen.getByText('BoosApi 产品详情')).toBeInTheDocument();
|
||||
expect(container.querySelector('.ant-alert-error')).toBeInTheDocument();
|
||||
|
||||
const errorNode = screen.getByText('模型返回空搜索语句');
|
||||
expect(title.compareDocumentPosition(errorNode) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows an empty result state instead of rendering nothing', () => {
|
||||
render(
|
||||
<WebSearchNode
|
||||
node={{
|
||||
type: 'web-search',
|
||||
attrs: { status: 'done' },
|
||||
content: '[]',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('未找到相关搜索结果')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the concrete search error message', () => {
|
||||
const { container } = render(
|
||||
<WebSearchNode
|
||||
node={{
|
||||
type: 'web-search',
|
||||
attrs: { status: 'error' },
|
||||
content: 'Tavily request failed',
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Tavily request failed')).toBeInTheDocument();
|
||||
expect(container.querySelector('.ant-alert-error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD,
|
||||
CHAT_SCROLL_IS_REVERSED,
|
||||
getDistanceToHistoryTop,
|
||||
getScrollTopAfterPrepend,
|
||||
hasMeasuredScrollLayoutChanged,
|
||||
hasScrollLayoutMetricsChanged,
|
||||
resolveChatScrollElements,
|
||||
shouldIgnoreScrollDepartureFromBottom,
|
||||
shouldKeepAutoScroll,
|
||||
shouldStickToBottomOnLayoutChange,
|
||||
shouldShowScrollToBottom,
|
||||
} from '../chatScroll';
|
||||
|
||||
describe('chat scroll helpers', () => {
|
||||
it('exposes the chat bubble list as a normal scroll container when Bubble.List autoScroll is disabled', () => {
|
||||
expect(CHAT_SCROLL_IS_REVERSED).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the app scroll mode to show the bottom button when the user leaves the latest message area', () => {
|
||||
expect(shouldShowScrollToBottom(2000, 1200, 800, CHAT_SCROLL_IS_REVERSED)).toBe(false);
|
||||
expect(shouldShowScrollToBottom(2000, 1040, 800, CHAT_SCROLL_IS_REVERSED)).toBe(false);
|
||||
expect(shouldShowScrollToBottom(2000, 900, 800, CHAT_SCROLL_IS_REVERSED)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats reversed bubble scroll near zero as the latest-message position', () => {
|
||||
expect(shouldShowScrollToBottom(2000, 0, 800, true)).toBe(false);
|
||||
expect(shouldShowScrollToBottom(2000, -80, 800, true)).toBe(false);
|
||||
expect(shouldShowScrollToBottom(2000, -240, 800, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('measures distance to the logical history top for auto-loading older pages', () => {
|
||||
expect(getDistanceToHistoryTop(2000, -1200, 800, true)).toBe(0);
|
||||
expect(getDistanceToHistoryTop(2000, 0, 800, true)).toBe(1200);
|
||||
expect(getDistanceToHistoryTop(2000, 0, 800, false)).toBe(0);
|
||||
});
|
||||
|
||||
it('stops auto-scroll as soon as the user meaningfully leaves the bottom', () => {
|
||||
expect(CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD).toBe(8);
|
||||
expect(shouldKeepAutoScroll(2000, 0, 800, true)).toBe(true);
|
||||
expect(shouldKeepAutoScroll(2000, -7, 800, true)).toBe(true);
|
||||
expect(shouldKeepAutoScroll(2000, -12, 800, true)).toBe(false);
|
||||
expect(shouldKeepAutoScroll(2000, 1200, 800, false)).toBe(true);
|
||||
expect(shouldKeepAutoScroll(2000, 1193, 800, false)).toBe(true);
|
||||
expect(shouldKeepAutoScroll(2000, 1180, 800, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects layout changes that happen after content finishes rendering', () => {
|
||||
expect(hasScrollLayoutMetricsChanged(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1248, clientHeight: 800 },
|
||||
)).toBe(true);
|
||||
|
||||
expect(hasScrollLayoutMetricsChanged(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1200, clientHeight: 760 },
|
||||
)).toBe(true);
|
||||
|
||||
expect(hasScrollLayoutMetricsChanged(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1200.5, clientHeight: 800 },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat the first unknown scroll measurement as a layout departure', () => {
|
||||
const hasLayoutChanged = hasMeasuredScrollLayoutChanged(
|
||||
{ scrollHeight: 0, clientHeight: 0 },
|
||||
{ scrollHeight: 2000, clientHeight: 800 },
|
||||
);
|
||||
|
||||
expect(hasLayoutChanged).toBe(false);
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(false, true, false, hasLayoutChanged)).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves the chat scroll elements from the message area before the Bubble.List ref is ready', () => {
|
||||
const root = document.createElement('div');
|
||||
const scrollBox = document.createElement('div');
|
||||
const scrollContent = document.createElement('div');
|
||||
scrollBox.className = 'ant-bubble-list-scroll-box';
|
||||
scrollContent.className = 'ant-bubble-list-scroll-content';
|
||||
scrollBox.appendChild(scrollContent);
|
||||
root.appendChild(scrollBox);
|
||||
|
||||
expect(resolveChatScrollElements(root, null)).toEqual({ scrollBox, scrollContent });
|
||||
});
|
||||
|
||||
it('keeps bottom lock on post-render layout changes only when the user was pinned', () => {
|
||||
expect(shouldStickToBottomOnLayoutChange(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1280, clientHeight: 800 },
|
||||
true,
|
||||
)).toBe(true);
|
||||
|
||||
expect(shouldStickToBottomOnLayoutChange(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1280, clientHeight: 800 },
|
||||
false,
|
||||
)).toBe(false);
|
||||
|
||||
expect(shouldStickToBottomOnLayoutChange(
|
||||
{ scrollHeight: 1200, clientHeight: 800 },
|
||||
{ scrollHeight: 1280, clientHeight: 800 },
|
||||
true,
|
||||
true,
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores non-user scroll departures only when layout size changed while pinned', () => {
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(false, true, false, true)).toBe(true);
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(false, true, false, false)).toBe(false);
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(false, true, true, true)).toBe(false);
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(true, true, false, true)).toBe(false);
|
||||
expect(shouldIgnoreScrollDepartureFromBottom(false, false, false, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves the viewport anchor when older messages are prepended in reversed chat mode', () => {
|
||||
expect(getScrollTopAfterPrepend(0, 1200, 1600, true)).toBe(-400);
|
||||
expect(getScrollTopAfterPrepend(-240, 1200, 1600, true)).toBe(-640);
|
||||
});
|
||||
|
||||
it('preserves the viewport anchor when older messages are prepended in regular scroll mode', () => {
|
||||
expect(getScrollTopAfterPrepend(0, 1200, 1600, false)).toBe(400);
|
||||
expect(getScrollTopAfterPrepend(240, 1200, 1600, false)).toBe(640);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getStreamingLoadingState,
|
||||
getStreamingStatusPresentation,
|
||||
hasBoosapiDisplayContent,
|
||||
hasModelVisibleContent,
|
||||
THINKING_LOADING_MARKER,
|
||||
closeStreamingThinkBlock,
|
||||
isAssistantStreamingForRender,
|
||||
shouldRenderAssistantMarkdownFromContent,
|
||||
shouldShowInitialStreamingDots,
|
||||
shouldShowInlineStreamingStatus,
|
||||
splitLeadingBoosapiDisplayContent,
|
||||
stripLeadingBoosapiDisplayTags,
|
||||
} from '../chatStreaming';
|
||||
|
||||
describe('chat streaming helpers', () => {
|
||||
it('derives bubble and footer loading state from stream progress and content presence', () => {
|
||||
expect(getStreamingLoadingState(true, '')).toEqual({
|
||||
bubbleLoading: true,
|
||||
footerLoading: false,
|
||||
});
|
||||
|
||||
expect(getStreamingLoadingState(true, 'hello')).toEqual({
|
||||
bubbleLoading: false,
|
||||
footerLoading: true,
|
||||
});
|
||||
|
||||
expect(getStreamingLoadingState(false, 'hello')).toEqual({
|
||||
bubbleLoading: false,
|
||||
footerLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps streamed assistant messages on the content renderer after completion', () => {
|
||||
expect(shouldRenderAssistantMarkdownFromContent(true, false)).toBe(true);
|
||||
expect(shouldRenderAssistantMarkdownFromContent(false, true)).toBe(true);
|
||||
expect(shouldRenderAssistantMarkdownFromContent(false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('shows initial streaming dots for empty model content', () => {
|
||||
const stripDisplayTags = (content: string) => content
|
||||
.replace(/<knowledge-retrieval [^>]*data-boosapi="1"[^>]*>[\s\S]*?<\/knowledge-retrieval>\s*/g, '')
|
||||
.trim();
|
||||
|
||||
expect(shouldShowInitialStreamingDots(true, '', stripDisplayTags)).toBe(true);
|
||||
expect(shouldShowInitialStreamingDots(true, '<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>', stripDisplayTags)).toBe(true);
|
||||
expect(shouldShowInitialStreamingDots(true, 'answer', stripDisplayTags)).toBe(false);
|
||||
expect(shouldShowInitialStreamingDots(false, '', stripDisplayTags)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps inline streaming status visible while only thinking content is streaming', () => {
|
||||
expect(shouldShowInlineStreamingStatus({
|
||||
isStreaming: true,
|
||||
hasDisplayContent: false,
|
||||
hasActiveThinkingOnly: true,
|
||||
hasRenderedModelText: false,
|
||||
})).toBe(true);
|
||||
|
||||
expect(shouldShowInlineStreamingStatus({
|
||||
isStreaming: true,
|
||||
hasDisplayContent: false,
|
||||
hasActiveThinkingOnly: true,
|
||||
hasRenderedModelText: true,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('describes waiting before the first model chunk', () => {
|
||||
expect(getStreamingStatusPresentation({
|
||||
isStreaming: true,
|
||||
activity: {
|
||||
startedAt: 1_000,
|
||||
firstChunkAt: null,
|
||||
lastChunkAt: null,
|
||||
providerId: 'provider-1',
|
||||
modelId: 'model-1',
|
||||
phase: 'waiting_first_packet',
|
||||
},
|
||||
now: 5_000,
|
||||
hasModelText: false,
|
||||
})!.labelKey).toBe('chat.streamingStatus.waitingFirstPacket');
|
||||
|
||||
expect(getStreamingStatusPresentation({
|
||||
isStreaming: true,
|
||||
activity: {
|
||||
startedAt: 1_000,
|
||||
firstChunkAt: null,
|
||||
lastChunkAt: null,
|
||||
providerId: 'provider-1',
|
||||
modelId: 'model-1',
|
||||
phase: 'waiting_first_packet',
|
||||
},
|
||||
now: 12_000,
|
||||
hasModelText: false,
|
||||
})!.labelKey).toBe('chat.streamingStatus.waitingProvider');
|
||||
});
|
||||
|
||||
it('describes active and idle generation after model chunks arrive', () => {
|
||||
expect(getStreamingStatusPresentation({
|
||||
isStreaming: true,
|
||||
activity: {
|
||||
startedAt: 1_000,
|
||||
firstChunkAt: 2_000,
|
||||
lastChunkAt: 9_000,
|
||||
providerId: 'provider-1',
|
||||
modelId: 'model-1',
|
||||
phase: 'streaming',
|
||||
},
|
||||
now: 12_000,
|
||||
hasModelText: true,
|
||||
})!.labelKey).toBe('chat.streamingStatus.generating');
|
||||
|
||||
expect(getStreamingStatusPresentation({
|
||||
isStreaming: true,
|
||||
activity: {
|
||||
startedAt: 1_000,
|
||||
firstChunkAt: 2_000,
|
||||
lastChunkAt: 3_000,
|
||||
providerId: 'provider-1',
|
||||
modelId: 'model-1',
|
||||
phase: 'streaming',
|
||||
},
|
||||
now: 19_000,
|
||||
hasModelText: true,
|
||||
})!.labelKey).toBe('chat.streamingStatus.waitingNextChunk');
|
||||
});
|
||||
|
||||
it('ignores display-only tags when deciding whether model text exists', () => {
|
||||
const stripDisplayTags = (content: string) => content
|
||||
.replace(/<knowledge-retrieval [^>]*data-boosapi="1"[^>]*>[\s\S]*?<\/knowledge-retrieval>\s*/g, '')
|
||||
.replace(/<think[^>]*>[\s\S]*?<\/think>\s*/g, '')
|
||||
.trim();
|
||||
|
||||
expect(hasModelVisibleContent(
|
||||
'<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>',
|
||||
stripDisplayTags,
|
||||
)).toBe(false);
|
||||
expect(hasModelVisibleContent(
|
||||
'<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>\n\nanswer',
|
||||
stripDisplayTags,
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects BoosApi display tags independently from model text', () => {
|
||||
expect(hasBoosapiDisplayContent(
|
||||
'<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>',
|
||||
)).toBe(true);
|
||||
expect(hasBoosapiDisplayContent('answer')).toBe(false);
|
||||
});
|
||||
|
||||
it('splits leading BoosApi display tags from streamed model text', () => {
|
||||
const knowledge = '<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>\n\n';
|
||||
const memory = '<memory-retrieval status="done" data-boosapi="1">[]</memory-retrieval>\n\n';
|
||||
|
||||
expect(splitLeadingBoosapiDisplayContent(`${knowledge}${memory}answer`)).toEqual({
|
||||
prefix: `${knowledge}${memory}`,
|
||||
body: 'answer',
|
||||
});
|
||||
expect(splitLeadingBoosapiDisplayContent(`answer\n${knowledge}`)).toEqual({
|
||||
prefix: '',
|
||||
body: `answer\n${knowledge}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('strips selected leading display tags while preserving other display prefixes', () => {
|
||||
const web = '<web-search status="done" data-boosapi="1">[]</web-search>\n\n';
|
||||
const query = '<web-search-query status="done" data-boosapi="1">query</web-search-query>\n\n';
|
||||
const knowledge = '<knowledge-retrieval status="done" data-boosapi="1">[]</knowledge-retrieval>\n\n';
|
||||
|
||||
expect(stripLeadingBoosapiDisplayTags(
|
||||
`${query}${web}${knowledge}answer`,
|
||||
['knowledge-retrieval', 'memory-retrieval'],
|
||||
)).toBe(`${query}${web}answer`);
|
||||
});
|
||||
|
||||
it('temporarily closes streamed native think blocks without dropping leading text', () => {
|
||||
const content = '<think>好的,用户让我讲个笑话。';
|
||||
|
||||
expect(closeStreamingThinkBlock(content, true)).toBe(
|
||||
`<think>\n好的,用户让我讲个笑话。${THINKING_LOADING_MARKER}\n</think>\n\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not alter completed, incomplete, or non-streaming think content', () => {
|
||||
expect(closeStreamingThinkBlock('<think>done</think>\n\nanswer', true)).toBe('<think>done</think>\n\nanswer');
|
||||
expect(closeStreamingThinkBlock('<thi', true)).toBe('<thi');
|
||||
expect(closeStreamingThinkBlock('<think>still thinking', false)).toBe('<think>still thinking');
|
||||
});
|
||||
|
||||
it('temporarily closes streamed think blocks with attributes', () => {
|
||||
expect(closeStreamingThinkBlock('<think data-boosapi="1">\nreasoning', true)).toBe(
|
||||
`<think data-boosapi="1">\nreasoning${THINKING_LOADING_MARKER}\n</think>\n\n`,
|
||||
);
|
||||
expect(closeStreamingThinkBlock('<think totalMs="123">done</think>\n\nanswer', true)).toBe(
|
||||
'<think totalMs="123">done</think>\n\nanswer',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps partial assistant messages on the streaming renderer even when ids differ', () => {
|
||||
expect(isAssistantStreamingForRender({
|
||||
isStreaming: true,
|
||||
messageId: 'real-message-id',
|
||||
streamingMessageId: 'temp-assistant-id',
|
||||
status: 'partial',
|
||||
})).toBe(true);
|
||||
expect(isAssistantStreamingForRender({
|
||||
isStreaming: true,
|
||||
messageId: 'real-message-id',
|
||||
streamingMessageId: 'temp-assistant-id',
|
||||
status: 'complete',
|
||||
})).toBe(false);
|
||||
expect(isAssistantStreamingForRender({
|
||||
isStreaming: false,
|
||||
messageId: 'real-message-id',
|
||||
streamingMessageId: 'temp-assistant-id',
|
||||
status: 'partial',
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatChatTime, normalizeChatTimestamp } from '../chatTime';
|
||||
|
||||
describe('chatTime', () => {
|
||||
it('formats backend unix-second timestamps the same as millisecond timestamps', () => {
|
||||
const timestampMs = Date.UTC(2026, 3, 30, 13, 0, 0);
|
||||
const timestampSeconds = Math.floor(timestampMs / 1000);
|
||||
|
||||
expect(normalizeChatTimestamp(timestampSeconds)).toBe(timestampMs);
|
||||
expect(formatChatTime(timestampSeconds, timestampMs)).toBe(formatChatTime(timestampMs, timestampMs));
|
||||
});
|
||||
|
||||
it('keeps frontend millisecond timestamps unchanged', () => {
|
||||
const timestampMs = Date.UTC(2026, 3, 30, 13, 45, 0);
|
||||
|
||||
expect(normalizeChatTimestamp(timestampMs)).toBe(timestampMs);
|
||||
});
|
||||
|
||||
it('shows only the time for messages from today', () => {
|
||||
const messageTime = new Date(2026, 3, 30, 21, 0, 5).getTime();
|
||||
const now = new Date(2026, 3, 30, 21, 30, 0).getTime();
|
||||
|
||||
expect(formatChatTime(messageTime, now)).toBe('21:00:05');
|
||||
});
|
||||
|
||||
it('shows the local date and time for messages outside today', () => {
|
||||
const messageTime = new Date(2026, 3, 29, 21, 0, 5).getTime();
|
||||
const now = new Date(2026, 3, 30, 21, 30, 0).getTime();
|
||||
|
||||
expect(formatChatTime(messageTime, now)).toBe('2026-04-29 21:00:05');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function readSource(...segments: string[]) {
|
||||
return fs.readFileSync(path.resolve(process.cwd(), ...segments), 'utf8');
|
||||
}
|
||||
|
||||
describe('Phase A feedback regressions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps chat scroll handling aligned with the shared Bubble.List scroll mode', () => {
|
||||
const chatViewSource = readSource('src/components/chat/ChatView.tsx');
|
||||
const chatScrollSource = readSource('src/components/chat/chatScroll.ts');
|
||||
|
||||
expect(chatScrollSource).toContain('CHAT_SCROLL_IS_REVERSED = false');
|
||||
expect(chatViewSource).toContain('CHAT_SCROLL_IS_REVERSED');
|
||||
expect(chatViewSource).not.toContain('shouldShowScrollToBottom(\n target.scrollHeight,\n target.scrollTop,\n target.clientHeight,\n false,');
|
||||
expect(chatViewSource).not.toContain('shouldKeepAutoScroll(\n target.scrollHeight,\n target.scrollTop,\n target.clientHeight,\n false,');
|
||||
expect(chatViewSource).not.toContain('getDistanceToHistoryTop(\n target.scrollHeight,\n target.scrollTop,\n target.clientHeight,\n false,');
|
||||
});
|
||||
|
||||
it('gives the chat textarea a dedicated visible-scrollbar hook instead of relying on globally hidden scrollbars', () => {
|
||||
const inputAreaSource = readSource('src/components/chat/InputArea.tsx');
|
||||
const cssSource = readSource('src/index.css');
|
||||
|
||||
expect(inputAreaSource).toContain('className="boosapi-input-textarea"');
|
||||
expect(cssSource).toContain('.boosapi-input-textarea');
|
||||
expect(cssSource).toMatch(/\.boosapi-input-textarea[\s\S]*scrollbar-width:\s*thin/i);
|
||||
});
|
||||
|
||||
it('feeds normalized assistant markdown content into the stream renderer', () => {
|
||||
const chatViewSource = readSource('src/components/chat/ChatView.tsx');
|
||||
|
||||
expect(chatViewSource).toContain('const rendererContent = displaySplit.body;');
|
||||
expect(chatViewSource).not.toContain('const rendererContent = displaySplit.prefix ? displaySplit.body : content;');
|
||||
});
|
||||
|
||||
it('keeps a null conversation max tokens override visually off instead of hydrating it from the global default', () => {
|
||||
const modalSource = readSource('src/components/chat/ConversationSettingsModal.tsx');
|
||||
|
||||
expect(modalSource).toContain('setMaxTokens(conversation.max_tokens ?? null)');
|
||||
expect(modalSource).not.toContain('setMaxTokens(conversation.max_tokens ?? settings.default_max_tokens ?? 4096)');
|
||||
});
|
||||
|
||||
it('treats max tokens clearing as an explicit nullable contract from modal to TypeScript types to Rust persistence', () => {
|
||||
const modalSource = readSource('src/components/chat/ConversationSettingsModal.tsx');
|
||||
const typeSource = readSource('src/types/index.ts');
|
||||
const rustTypeSource = readSource('src-tauri/crates/core/src/types.rs');
|
||||
const repoSource = readSource('src-tauri/crates/core/src/repo/conversation.rs');
|
||||
|
||||
expect(modalSource).toContain('max_tokens: maxTokens,');
|
||||
expect(typeSource).toMatch(/max_tokens\?: number \| null;/);
|
||||
expect(rustTypeSource).toMatch(/deserialize_double_option"\)\]\s*pub max_tokens: Option<Option<i64>>/);
|
||||
expect(repoSource).toContain('if let Some(max_tokens) = input.max_tokens {');
|
||||
expect(repoSource).toContain('am.max_tokens = Set(max_tokens);');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readSource(...segments: string[]) {
|
||||
return fs.readFileSync(path.resolve(process.cwd(), ...segments), 'utf8');
|
||||
}
|
||||
|
||||
describe('Phase B category template regressions', () => {
|
||||
it('extends category contracts with default model and parameter template fields', () => {
|
||||
const typeSource = readSource('src/types/index.ts');
|
||||
const rustTypeSource = readSource('src-tauri/crates/core/src/types.rs');
|
||||
const entitySource = readSource('src-tauri/crates/core/src/entity/conversation_categories.rs');
|
||||
|
||||
expect(typeSource).toMatch(/default_provider_id: string \| null;/);
|
||||
expect(typeSource).toMatch(/default_model_id: string \| null;/);
|
||||
expect(typeSource).toMatch(/default_temperature: number \| null;/);
|
||||
expect(typeSource).toMatch(/default_max_tokens: number \| null;/);
|
||||
expect(typeSource).toMatch(/default_top_p: number \| null;/);
|
||||
expect(typeSource).toMatch(/default_frequency_penalty: number \| null;/);
|
||||
|
||||
expect(rustTypeSource).toMatch(/pub default_provider_id: Option<String>/);
|
||||
expect(rustTypeSource).toMatch(/pub default_model_id: Option<String>/);
|
||||
expect(rustTypeSource).toMatch(/pub default_temperature: Option<f64>/);
|
||||
expect(rustTypeSource).toMatch(/pub default_max_tokens: Option<i64>/);
|
||||
expect(rustTypeSource).toMatch(/pub default_top_p: Option<f64>/);
|
||||
expect(rustTypeSource).toMatch(/pub default_frequency_penalty: Option<f64>/);
|
||||
|
||||
expect(entitySource).toMatch(/pub default_provider_id: Option<String>/);
|
||||
expect(entitySource).toMatch(/pub default_model_id: Option<String>/);
|
||||
});
|
||||
|
||||
it('lets the category editor configure a default model plus model params', () => {
|
||||
const modalSource = readSource('src/components/chat/CategoryEditModal.tsx');
|
||||
|
||||
expect(modalSource).toContain('ModelSelect');
|
||||
expect(modalSource).toContain('ModelParamSliders');
|
||||
expect(modalSource).toContain('default_provider_id');
|
||||
expect(modalSource).toContain('default_model_id');
|
||||
expect(modalSource).toContain('default_temperature');
|
||||
expect(modalSource).toContain('default_max_tokens');
|
||||
expect(modalSource).toContain('default_top_p');
|
||||
expect(modalSource).toContain('default_frequency_penalty');
|
||||
});
|
||||
|
||||
it('adds a category-scoped new conversation action instead of forcing users to create then move', () => {
|
||||
const sidebarSource = readSource('src/components/chat/ChatSidebar.tsx');
|
||||
|
||||
expect(sidebarSource).toContain('onCreateConversation');
|
||||
expect(sidebarSource).toContain("key: 'new'");
|
||||
expect(sidebarSource).toContain('handleNewConversation(cat.id)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readSource(...segments: string[]) {
|
||||
return fs.readFileSync(path.resolve(process.cwd(), ...segments), 'utf8');
|
||||
}
|
||||
|
||||
describe('Phase C output control regressions', () => {
|
||||
it('lets assistant replies enter the shared edit flow instead of restricting edits to user prompts', () => {
|
||||
const source = readSource('src/components/chat/ChatView.tsx');
|
||||
|
||||
expect(source).toContain('editingMessageRole');
|
||||
expect(source).toContain("msg.role === 'assistant'");
|
||||
expect(source).toContain("key: 'edit'");
|
||||
expect(source).toContain("editingMessageRole === 'assistant'");
|
||||
});
|
||||
|
||||
it('shows a per-turn total token summary alongside prompt and completion counts', () => {
|
||||
const source = readSource('src/components/chat/ChatView.tsx');
|
||||
|
||||
expect(source).toContain('const totalTokens = (msg.prompt_tokens ?? 0) + (msg.completion_tokens ?? 0);');
|
||||
expect(source).toContain('{formatTokenCount(totalTokens)} tokens');
|
||||
expect(source).not.toContain("t('chat.totalTokens'");
|
||||
});
|
||||
|
||||
it('adds transcript copy and no-thinking export variants at chat level', () => {
|
||||
const source = readSource('src/components/chat/ChatView.tsx');
|
||||
|
||||
expect(source).toContain("key: 'copy-md'");
|
||||
expect(source).toContain("key: 'export-md-no-thinking'");
|
||||
expect(source).toContain("key: 'export-json-no-thinking'");
|
||||
});
|
||||
|
||||
it('lets export helpers optionally strip thinking content before saving or copying', () => {
|
||||
const source = readSource('src/lib/exportChat.ts');
|
||||
|
||||
expect(source).toContain('includeThinking');
|
||||
expect(source).toContain('stripBoosapiTags');
|
||||
expect(source).toContain('copyTranscript');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Message } from '@/types';
|
||||
import { buildAssistantDisplayContent, shouldHideAssistantBubble } from '../toolCallDisplay';
|
||||
|
||||
function makeMessage(overrides: Partial<Message>): Message {
|
||||
return {
|
||||
id: 'msg-1',
|
||||
conversation_id: 'conv-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
provider_id: null,
|
||||
model_id: null,
|
||||
token_count: null,
|
||||
attachments: [],
|
||||
thinking: null,
|
||||
tool_calls_json: null,
|
||||
tool_call_id: null,
|
||||
created_at: 1,
|
||||
parent_message_id: null,
|
||||
version_index: 0,
|
||||
is_active: true,
|
||||
status: 'complete',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAssistantDisplayContent', () => {
|
||||
it('returns content as-is for assistant messages', () => {
|
||||
const msg = makeMessage({ content: 'Hello world' });
|
||||
expect(buildAssistantDisplayContent(msg, [])).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('returns content with :::mcp containers unchanged', () => {
|
||||
const content = ':::mcp {"name":"@boosapi/fetch","tool":"fetch_url"}\nresult\n:::\n';
|
||||
const msg = makeMessage({ content });
|
||||
expect(buildAssistantDisplayContent(msg, [])).toBe(content);
|
||||
});
|
||||
|
||||
it('does not hide an empty normal assistant placeholder bubble while streaming', () => {
|
||||
const assistant = makeMessage({
|
||||
id: 'assistant-streaming',
|
||||
content: '',
|
||||
tool_calls_json: null,
|
||||
});
|
||||
|
||||
expect(shouldHideAssistantBubble(assistant, '')).toBe(false);
|
||||
});
|
||||
|
||||
it('hides assistant bubble with empty content but present tool_calls_json', () => {
|
||||
const assistant = makeMessage({
|
||||
content: '',
|
||||
tool_calls_json: '[{"id":"call-1","type":"function","function":{"name":"fetch","arguments":"{}"}}]',
|
||||
});
|
||||
|
||||
expect(shouldHideAssistantBubble(assistant, '')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Message } from '@/types';
|
||||
|
||||
const ASSISTANT_BUBBLE_KEY_PREFIX = 'ai:';
|
||||
|
||||
export function normalizeAssistantBubbleParentKey(key: unknown): string {
|
||||
const rawKey = String(key ?? '');
|
||||
return rawKey.startsWith(ASSISTANT_BUBBLE_KEY_PREFIX)
|
||||
? rawKey.slice(ASSISTANT_BUBBLE_KEY_PREFIX.length)
|
||||
: rawKey;
|
||||
}
|
||||
|
||||
export function resolveAssistantMessageForBubbleKey(
|
||||
key: unknown,
|
||||
assistantByParentId: Map<string, Message>,
|
||||
messageById: Map<string, Message>,
|
||||
): Message | undefined {
|
||||
const rawKey = String(key ?? '');
|
||||
const parentKey = normalizeAssistantBubbleParentKey(rawKey);
|
||||
return assistantByParentId.get(parentKey)
|
||||
?? assistantByParentId.get(rawKey)
|
||||
?? messageById.get(rawKey)
|
||||
?? messageById.get(parentKey);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
export const CHAT_SCROLL_IS_REVERSED = false;
|
||||
export const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 8;
|
||||
export const CHAT_SCROLL_TO_BOTTOM_VISIBILITY_THRESHOLD = 160;
|
||||
export const CHAT_SCROLL_BOX_SELECTOR = '.ant-bubble-list-scroll-box';
|
||||
export const CHAT_SCROLL_CONTENT_SELECTOR = '.ant-bubble-list-scroll-content';
|
||||
|
||||
export type ChatScrollElements = {
|
||||
scrollBox: HTMLElement | null;
|
||||
scrollContent: HTMLElement | null;
|
||||
};
|
||||
|
||||
export function resolveChatScrollElements(
|
||||
root: ParentNode | null | undefined,
|
||||
scrollBoxCandidate?: HTMLElement | null,
|
||||
): ChatScrollElements {
|
||||
const rootNode = root as Node | null | undefined;
|
||||
const candidateBelongsToRoot = Boolean(
|
||||
scrollBoxCandidate
|
||||
&& (!rootNode || rootNode === scrollBoxCandidate || rootNode.contains(scrollBoxCandidate)),
|
||||
);
|
||||
const scrollBox: HTMLElement | null = candidateBelongsToRoot && scrollBoxCandidate
|
||||
? scrollBoxCandidate
|
||||
: root?.querySelector<HTMLElement>(CHAT_SCROLL_BOX_SELECTOR) ?? null;
|
||||
const scrollContent = scrollBox?.querySelector<HTMLElement>(CHAT_SCROLL_CONTENT_SELECTOR)
|
||||
?? (scrollBox?.firstElementChild as HTMLElement | null)
|
||||
?? null;
|
||||
|
||||
return { scrollBox, scrollContent };
|
||||
}
|
||||
|
||||
export function getDistanceToHistoryTop(
|
||||
scrollHeight: number,
|
||||
scrollTop: number,
|
||||
clientHeight: number,
|
||||
isReversed: boolean,
|
||||
) {
|
||||
return isReversed ? scrollHeight + scrollTop - clientHeight : scrollTop;
|
||||
}
|
||||
|
||||
export function getScrollTopAfterPrepend(
|
||||
previousScrollTop: number,
|
||||
previousScrollHeight: number,
|
||||
nextScrollHeight: number,
|
||||
isReversed: boolean,
|
||||
) {
|
||||
const heightDelta = Math.max(0, nextScrollHeight - previousScrollHeight);
|
||||
return isReversed
|
||||
? previousScrollTop - heightDelta
|
||||
: previousScrollTop + heightDelta;
|
||||
}
|
||||
|
||||
export type ScrollLayoutMetrics = {
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
};
|
||||
|
||||
export function hasScrollLayoutMetricsChanged(
|
||||
previous: ScrollLayoutMetrics,
|
||||
next: ScrollLayoutMetrics,
|
||||
threshold = 1,
|
||||
) {
|
||||
return Math.abs(next.scrollHeight - previous.scrollHeight) > threshold
|
||||
|| Math.abs(next.clientHeight - previous.clientHeight) > threshold;
|
||||
}
|
||||
|
||||
export function hasMeasuredScrollLayout(metrics: ScrollLayoutMetrics) {
|
||||
return metrics.scrollHeight > 0 && metrics.clientHeight > 0;
|
||||
}
|
||||
|
||||
export function hasMeasuredScrollLayoutChanged(
|
||||
previous: ScrollLayoutMetrics,
|
||||
next: ScrollLayoutMetrics,
|
||||
threshold = 1,
|
||||
) {
|
||||
return hasMeasuredScrollLayout(previous)
|
||||
&& hasScrollLayoutMetricsChanged(previous, next, threshold);
|
||||
}
|
||||
|
||||
export function shouldStickToBottomOnLayoutChange(
|
||||
previous: ScrollLayoutMetrics,
|
||||
next: ScrollLayoutMetrics,
|
||||
wasStickingToBottom: boolean,
|
||||
hadRecentUserScrollIntent = false,
|
||||
threshold = 1,
|
||||
) {
|
||||
return wasStickingToBottom
|
||||
&& !hadRecentUserScrollIntent
|
||||
&& hasMeasuredScrollLayoutChanged(previous, next, threshold);
|
||||
}
|
||||
|
||||
export function shouldIgnoreScrollDepartureFromBottom(
|
||||
keepAutoScroll: boolean,
|
||||
wasStickingToBottom: boolean,
|
||||
hadRecentUserScrollIntent: boolean,
|
||||
hasLayoutChanged: boolean,
|
||||
) {
|
||||
return !keepAutoScroll && wasStickingToBottom && !hadRecentUserScrollIntent && hasLayoutChanged;
|
||||
}
|
||||
|
||||
export function shouldShowScrollToBottom(
|
||||
scrollHeight: number,
|
||||
scrollTop: number,
|
||||
clientHeight: number,
|
||||
isReversed: boolean,
|
||||
threshold = CHAT_SCROLL_TO_BOTTOM_VISIBILITY_THRESHOLD,
|
||||
) {
|
||||
if (isReversed) {
|
||||
return scrollTop < -threshold;
|
||||
}
|
||||
return scrollHeight - clientHeight - scrollTop > threshold;
|
||||
}
|
||||
|
||||
export function shouldKeepAutoScroll(
|
||||
scrollHeight: number,
|
||||
scrollTop: number,
|
||||
clientHeight: number,
|
||||
isReversed: boolean,
|
||||
threshold = CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD,
|
||||
) {
|
||||
if (isReversed) {
|
||||
return scrollTop >= -threshold;
|
||||
}
|
||||
return scrollHeight - clientHeight - scrollTop <= threshold;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { normalizeThinkTagsForMarkdown } from '@/lib/thinkTags';
|
||||
export {
|
||||
getStreamingStatusPresentation,
|
||||
type StreamActivity,
|
||||
type StreamingStatusPresentation,
|
||||
} from '@/lib/streamStatus';
|
||||
|
||||
export function getStreamingLoadingState(
|
||||
isStreaming: boolean,
|
||||
content: unknown,
|
||||
): { bubbleLoading: boolean; footerLoading: boolean } {
|
||||
const hasContent = typeof content === 'string'
|
||||
? content.trim().length > 0
|
||||
: Boolean(content);
|
||||
|
||||
return {
|
||||
bubbleLoading: isStreaming && !hasContent,
|
||||
footerLoading: isStreaming && hasContent,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldRenderAssistantMarkdownFromContent(
|
||||
isStreaming: boolean,
|
||||
streamedInCurrentSession: boolean,
|
||||
): boolean {
|
||||
return isStreaming || streamedInCurrentSession;
|
||||
}
|
||||
|
||||
export const THINKING_LOADING_MARKER = '<!--boosapi-thinking-loading-->';
|
||||
|
||||
export function closeStreamingThinkBlock(content: string, isStreaming: boolean): string {
|
||||
if (!isStreaming || content.includes(THINKING_LOADING_MARKER)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
let lastOpenIndex = -1;
|
||||
for (const match of content.matchAll(/<think\b[^>]*>/gi)) {
|
||||
lastOpenIndex = match.index ?? -1;
|
||||
}
|
||||
if (lastOpenIndex < 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
let lastCloseIndex = -1;
|
||||
for (const match of content.matchAll(/<\/think\s*>/gi)) {
|
||||
lastCloseIndex = match.index ?? -1;
|
||||
}
|
||||
if (lastCloseIndex > lastOpenIndex) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return normalizeThinkTagsForMarkdown(`${content}${THINKING_LOADING_MARKER}\n</think>\n\n`);
|
||||
}
|
||||
|
||||
export function isAssistantStreamingForRender(input: {
|
||||
isStreaming: boolean;
|
||||
messageId?: string | null;
|
||||
streamingMessageId?: string | null;
|
||||
status?: string | null;
|
||||
}): boolean {
|
||||
if (!input.isStreaming || !input.messageId) {
|
||||
return false;
|
||||
}
|
||||
return input.messageId === input.streamingMessageId || input.status === 'partial';
|
||||
}
|
||||
|
||||
export function hasModelVisibleContent(content: unknown, stripDisplayTags: (content: string) => string): boolean {
|
||||
if (typeof content !== 'string') {
|
||||
return Boolean(content);
|
||||
}
|
||||
return stripDisplayTags(content).trim().length > 0;
|
||||
}
|
||||
|
||||
export function shouldShowInitialStreamingDots(
|
||||
isStreaming: boolean,
|
||||
content: unknown,
|
||||
stripDisplayTags: (content: string) => string,
|
||||
): boolean {
|
||||
return isStreaming && !hasModelVisibleContent(content, stripDisplayTags);
|
||||
}
|
||||
|
||||
export function shouldShowInlineStreamingStatus(input: {
|
||||
isStreaming: boolean;
|
||||
hasDisplayContent: boolean;
|
||||
hasActiveThinkingOnly: boolean;
|
||||
hasRenderedModelText: boolean;
|
||||
}): boolean {
|
||||
return input.isStreaming
|
||||
&& !input.hasRenderedModelText
|
||||
&& (input.hasDisplayContent || input.hasActiveThinkingOnly);
|
||||
}
|
||||
|
||||
export function hasBoosapiDisplayContent(content: unknown): boolean {
|
||||
return typeof content === 'string'
|
||||
&& /<(?:knowledge-retrieval|memory-retrieval|web-search-query|web-search)\b[^>]*data-boosapi=["']1["'][^>]*>/i.test(content);
|
||||
}
|
||||
|
||||
const LEADING_BOOSAPI_DISPLAY_TAG_RE = /^\s*<(knowledge-retrieval|memory-retrieval|web-search-query|web-search)\b[^>]*data-boosapi=["']1["'][^>]*>[\s\S]*?<\/\1>\s*/i;
|
||||
|
||||
export function splitLeadingBoosapiDisplayContent(content: string): { prefix: string; body: string } {
|
||||
let body = content;
|
||||
let prefix = '';
|
||||
|
||||
for (;;) {
|
||||
const match = body.match(LEADING_BOOSAPI_DISPLAY_TAG_RE);
|
||||
if (!match) break;
|
||||
prefix += match[0];
|
||||
body = body.slice(match[0].length);
|
||||
}
|
||||
|
||||
return { prefix, body };
|
||||
}
|
||||
|
||||
export function stripLeadingBoosapiDisplayTags(content: string, tagNames: string[]): string {
|
||||
const tagSet = new Set(tagNames);
|
||||
let body = content;
|
||||
let keptPrefix = '';
|
||||
|
||||
for (;;) {
|
||||
const match = body.match(LEADING_BOOSAPI_DISPLAY_TAG_RE);
|
||||
if (!match) break;
|
||||
const tagName = match[1]?.toLowerCase();
|
||||
if (!tagName || !tagSet.has(tagName)) {
|
||||
keptPrefix += match[0];
|
||||
}
|
||||
body = body.slice(match[0].length);
|
||||
}
|
||||
|
||||
return keptPrefix + body;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
const UNIX_MILLISECONDS_THRESHOLD = 1_000_000_000_000;
|
||||
|
||||
export function normalizeChatTimestamp(timestamp: number): number {
|
||||
return Math.abs(timestamp) < UNIX_MILLISECONDS_THRESHOLD ? timestamp * 1000 : timestamp;
|
||||
}
|
||||
|
||||
function pad2(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
}
|
||||
|
||||
function isSameLocalDate(left: Date, right: Date): boolean {
|
||||
return left.getFullYear() === right.getFullYear()
|
||||
&& left.getMonth() === right.getMonth()
|
||||
&& left.getDate() === right.getDate();
|
||||
}
|
||||
|
||||
export function formatChatTime(timestamp: number, nowTimestamp: number = Date.now()): string {
|
||||
const date = new Date(normalizeChatTimestamp(timestamp));
|
||||
const now = new Date(normalizeChatTimestamp(nowTimestamp));
|
||||
const time = formatTime(date);
|
||||
|
||||
if (isSameLocalDate(date, now)) {
|
||||
return time;
|
||||
}
|
||||
|
||||
return `${formatDate(date)} ${time}`;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { ChatSidebar } from './ChatSidebar';
|
||||
export { ChatView } from './ChatView';
|
||||
export { InputArea } from './InputArea';
|
||||
export { ModelSelector } from './ModelSelector';
|
||||
export { CitationPanel } from './CitationPanel';
|
||||
export { ToolCallCard } from './ToolCallCard';
|
||||
export { ArtifactPanel } from './ArtifactPanel';
|
||||
export { BranchComparePanel } from './BranchComparePanel';
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Message } from '@/types';
|
||||
import { stripBoosapiTags } from '@/lib/chatMarkdown';
|
||||
import { buildSearchQueryTag, buildSearchTag, parseSearchContent } from '@/lib/searchUtils';
|
||||
import { splitLeadingBoosapiDisplayContent } from './chatStreaming';
|
||||
import { splitStreamErrorContent } from '@/lib/streamStatus';
|
||||
|
||||
const MCP_RENDER_BLOCK_CHAR_LIMIT = 20_000;
|
||||
const MCP_RENDER_TRUNCATED_NOTICE = 'MCP output truncated for rendering';
|
||||
|
||||
function hasPersistedDisplayTag(content: string): boolean {
|
||||
return /<(?:web-search-query|web-search|knowledge-retrieval|memory-retrieval)\b[^>]*data-boosapi=["']1["'][^>]*>/i.test(content);
|
||||
}
|
||||
|
||||
function sliceCodePoints(content: string, limit: number): string {
|
||||
let count = 0;
|
||||
let end = 0;
|
||||
for (const char of content) {
|
||||
if (count >= limit) break;
|
||||
end += char.length;
|
||||
count += 1;
|
||||
}
|
||||
return content.slice(0, end);
|
||||
}
|
||||
|
||||
function truncatePersistedMcpBlocks(content: string): string {
|
||||
let result = '';
|
||||
let offset = 0;
|
||||
|
||||
while (offset < content.length) {
|
||||
const opener = content.indexOf(':::mcp', offset);
|
||||
if (opener === -1) {
|
||||
result += content.slice(offset);
|
||||
break;
|
||||
}
|
||||
|
||||
const headerEnd = content.indexOf('\n', opener);
|
||||
if (headerEnd === -1) {
|
||||
result += content.slice(offset);
|
||||
break;
|
||||
}
|
||||
|
||||
const closer = content.indexOf('\n:::', headerEnd + 1);
|
||||
if (closer === -1) {
|
||||
result += content.slice(offset);
|
||||
break;
|
||||
}
|
||||
|
||||
const bodyStart = headerEnd + 1;
|
||||
const body = content.slice(bodyStart, closer);
|
||||
result += content.slice(offset, bodyStart);
|
||||
|
||||
if (Array.from(body).length > MCP_RENDER_BLOCK_CHAR_LIMIT) {
|
||||
result += sliceCodePoints(body, MCP_RENDER_BLOCK_CHAR_LIMIT);
|
||||
result += `\n\n[${MCP_RENDER_TRUNCATED_NOTICE}: showing first ${MCP_RENDER_BLOCK_CHAR_LIMIT} characters]`;
|
||||
} else {
|
||||
result += body;
|
||||
}
|
||||
|
||||
offset = closer;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildAssistantDisplayContent(message: Message, messages: Message[]): string {
|
||||
if (message.role !== 'assistant') {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
let content = truncatePersistedMcpBlocks(message.content);
|
||||
if (message.status === 'error' || hasPersistedDisplayTag(content)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const parent = message.parent_message_id
|
||||
? messages.find((item) => item.id === message.parent_message_id && item.role === 'user')
|
||||
: undefined;
|
||||
if (!parent) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const parentSearch = parseSearchContent(parent.content);
|
||||
if (!parentSearch.hasSearch) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const queryTag = parentSearch.queryStatus || parentSearch.query
|
||||
? buildSearchQueryTag(parentSearch.queryStatus ?? 'done', parentSearch.query ?? undefined, parentSearch.queryError ?? undefined)
|
||||
: '';
|
||||
const searchTag = buildSearchTag(
|
||||
parentSearch.status ?? 'done',
|
||||
parentSearch.sources.map((source) => ({ ...source, content: '' })),
|
||||
parentSearch.error ?? undefined,
|
||||
);
|
||||
content = `${queryTag}${searchTag}${content}`;
|
||||
return content;
|
||||
}
|
||||
|
||||
export function splitAssistantErrorDisplayContent(content: string): { prefix: string; message: string } {
|
||||
const streamError = splitStreamErrorContent(content);
|
||||
if (streamError) {
|
||||
return {
|
||||
prefix: streamError.prefix,
|
||||
message: stripBoosapiTags(streamError.error).trim() || streamError.error,
|
||||
};
|
||||
}
|
||||
|
||||
const split = splitLeadingBoosapiDisplayContent(content);
|
||||
const cleanBody = stripBoosapiTags(split.body).trim();
|
||||
const cleanFullContent = stripBoosapiTags(content).trim();
|
||||
|
||||
return {
|
||||
prefix: split.prefix,
|
||||
message: cleanBody || cleanFullContent || split.body.trim() || content.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldHideAssistantBubble(message: Message, displayContent: string): boolean {
|
||||
if (message.role !== 'assistant') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (displayContent.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !message.content.trim() && Boolean(message.tool_calls_json);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { Button, message, theme } from 'antd';
|
||||
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
|
||||
|
||||
export interface CopyButtonProps {
|
||||
/** Text to copy, or async function returning text to copy */
|
||||
text: string | (() => string | Promise<string>);
|
||||
/** Icon size in px (default: 14) */
|
||||
size?: number;
|
||||
/** Duration of success state in ms (default: 2000) */
|
||||
timeout?: number;
|
||||
/** If provided, shows message.success() with this string on copy */
|
||||
successMessage?: string;
|
||||
/** Called after successful copy */
|
||||
onSuccess?: () => void;
|
||||
/** Called on copy failure (clipboard error or text getter error) */
|
||||
onError?: (error: unknown) => void;
|
||||
/** Additional inline style */
|
||||
style?: React.CSSProperties;
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CopyButton = React.forwardRef<HTMLElement, CopyButtonProps>(
|
||||
function CopyButton(
|
||||
{ text, size = 14, timeout = 2000, successMessage, onSuccess, onError, style, className },
|
||||
ref,
|
||||
) {
|
||||
const { token } = theme.useToken();
|
||||
const { copy, isCopied } = useCopyToClipboard({ timeout });
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
try {
|
||||
const value = typeof text === 'function' ? await text() : text;
|
||||
const ok = await copy(value);
|
||||
if (ok) {
|
||||
if (successMessage) message.success(successMessage);
|
||||
onSuccess?.();
|
||||
}
|
||||
} catch (e) {
|
||||
onError?.(e);
|
||||
}
|
||||
}, [text, copy, successMessage, onSuccess, onError]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
type="text"
|
||||
size="small"
|
||||
icon={isCopied ? <Check size={size} style={{ color: token.colorSuccess }} /> : <Copy size={size} />}
|
||||
onClick={handleClick}
|
||||
style={style}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,237 @@
|
||||
import { Slider, InputNumber, Switch, Tooltip, Divider, theme } from 'antd';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ── Single parameter row with optional switch ──────────────
|
||||
|
||||
interface ParamRowProps {
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
value: number | null;
|
||||
defaultValue: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
marks?: Record<string | number, string>;
|
||||
inputWidth?: number;
|
||||
onChange: (v: number | null) => void;
|
||||
showSwitch?: boolean;
|
||||
showDivider?: boolean;
|
||||
}
|
||||
|
||||
function ParamRow({
|
||||
label,
|
||||
tooltip,
|
||||
value,
|
||||
defaultValue,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
marks,
|
||||
inputWidth = 70,
|
||||
onChange,
|
||||
showSwitch = true,
|
||||
showDivider = true,
|
||||
}: ParamRowProps) {
|
||||
const { token } = theme.useToken();
|
||||
const isOn = value !== null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 0' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 13 }}>
|
||||
{label}
|
||||
{tooltip && (
|
||||
<Tooltip title={tooltip}>
|
||||
<Info size={12} style={{ color: token.colorTextSecondary, cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{isOn && (
|
||||
<InputNumber
|
||||
style={{ width: inputWidth }}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value!}
|
||||
onChange={(v) => v !== null && onChange(v)}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
{showSwitch && (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={isOn}
|
||||
onChange={(checked) => onChange(checked ? defaultValue : null)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{isOn && (
|
||||
<div style={{ paddingBottom: 8 }}>
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
marks={marks}
|
||||
value={value!}
|
||||
onChange={(v) => onChange(v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showDivider && <Divider style={{ margin: 0 }} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Exported composite component ───────────────────────────
|
||||
|
||||
export interface ModelParamValues {
|
||||
temperature: number | null;
|
||||
topP: number | null;
|
||||
maxTokens: number | null;
|
||||
frequencyPenalty: number | null;
|
||||
}
|
||||
|
||||
export interface ModelParamDefaults {
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
maxTokens?: number;
|
||||
frequencyPenalty?: number;
|
||||
}
|
||||
|
||||
export interface ModelParamSlidersProps {
|
||||
values: ModelParamValues;
|
||||
onChange: (values: Partial<ModelParamValues>) => void;
|
||||
/** Default values when toggling a param ON (defaults: temp=0.7, topP=1.0, maxTokens=4096, freqPenalty=0) */
|
||||
defaults?: ModelParamDefaults;
|
||||
/** Show parameter toggle switches (default: true) */
|
||||
showSwitch?: boolean;
|
||||
/** Show dividers between rows (default: true) */
|
||||
showDividers?: boolean;
|
||||
/** Which parameters to render (default: all four) */
|
||||
visibleParams?: Array<'temperature' | 'topP' | 'maxTokens' | 'frequencyPenalty'>;
|
||||
}
|
||||
|
||||
const MAX_TOKENS_MARKS: Record<string | number, string> = {
|
||||
256: '', 32768: '32K', 131072: '128K', 1048576: '1M',
|
||||
};
|
||||
|
||||
const TEMPERATURE_MARKS: Record<string | number, string> = {
|
||||
0: '0', 0.5: '', 1: '1', 1.5: '', 2: '2',
|
||||
};
|
||||
|
||||
const TOP_P_MARKS: Record<string | number, string> = {
|
||||
0: '0', 0.5: '', 1: '1',
|
||||
};
|
||||
|
||||
const FREQ_PENALTY_MARKS: Record<string | number, string> = {
|
||||
'-2': '-2', 0: '0', 2: '2',
|
||||
};
|
||||
|
||||
const DEFAULT_DEFAULTS: Required<ModelParamDefaults> = {
|
||||
temperature: 0.7,
|
||||
topP: 1.0,
|
||||
maxTokens: 4096,
|
||||
frequencyPenalty: 0,
|
||||
};
|
||||
|
||||
const ALL_PARAMS = ['temperature', 'topP', 'maxTokens', 'frequencyPenalty'] as const;
|
||||
|
||||
export function ModelParamSliders({
|
||||
values,
|
||||
onChange,
|
||||
defaults,
|
||||
showSwitch = true,
|
||||
showDividers = true,
|
||||
visibleParams = [...ALL_PARAMS],
|
||||
}: ModelParamSlidersProps) {
|
||||
const { t } = useTranslation();
|
||||
const d = { ...DEFAULT_DEFAULTS, ...defaults };
|
||||
|
||||
const visible = new Set(visibleParams);
|
||||
const visibleList = ALL_PARAMS.filter((p) => visible.has(p));
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleList.map((param, idx) => {
|
||||
const isLast = idx === visibleList.length - 1;
|
||||
|
||||
switch (param) {
|
||||
case 'temperature':
|
||||
return (
|
||||
<ParamRow
|
||||
key="temperature"
|
||||
label={t('settings.temperature')}
|
||||
tooltip={t('settings.temperatureTooltip')}
|
||||
value={values.temperature}
|
||||
defaultValue={d.temperature}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
marks={TEMPERATURE_MARKS}
|
||||
onChange={(v) => onChange({ temperature: v })}
|
||||
showSwitch={showSwitch}
|
||||
showDivider={showDividers && !isLast}
|
||||
/>
|
||||
);
|
||||
case 'topP':
|
||||
return (
|
||||
<ParamRow
|
||||
key="topP"
|
||||
label="Top P"
|
||||
tooltip={t('settings.topPTooltip')}
|
||||
value={values.topP}
|
||||
defaultValue={d.topP}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
marks={TOP_P_MARKS}
|
||||
onChange={(v) => onChange({ topP: v })}
|
||||
showSwitch={showSwitch}
|
||||
showDivider={showDividers && !isLast}
|
||||
/>
|
||||
);
|
||||
case 'maxTokens':
|
||||
return (
|
||||
<ParamRow
|
||||
key="maxTokens"
|
||||
label={t('settings.maxTokens')}
|
||||
tooltip={t('settings.maxTokensTooltip')}
|
||||
value={values.maxTokens}
|
||||
defaultValue={d.maxTokens}
|
||||
min={256}
|
||||
max={1048576}
|
||||
step={256}
|
||||
marks={MAX_TOKENS_MARKS}
|
||||
inputWidth={90}
|
||||
onChange={(v) => onChange({ maxTokens: v })}
|
||||
showSwitch={showSwitch}
|
||||
showDivider={showDividers && !isLast}
|
||||
/>
|
||||
);
|
||||
case 'frequencyPenalty':
|
||||
return (
|
||||
<ParamRow
|
||||
key="frequencyPenalty"
|
||||
label={t('settings.frequencyPenalty')}
|
||||
tooltip={t('settings.frequencyPenaltyTooltip')}
|
||||
value={values.frequencyPenalty}
|
||||
defaultValue={d.frequencyPenalty}
|
||||
min={-2}
|
||||
max={2}
|
||||
step={0.1}
|
||||
marks={FREQ_PENALTY_MARKS}
|
||||
onChange={(v) => onChange({ frequencyPenalty: v })}
|
||||
showSwitch={showSwitch}
|
||||
showDivider={showDividers && !isLast}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { App, Button, Image, Tag, theme } from 'antd';
|
||||
import { ArrowUp, GripHorizontal, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { ClipboardEvent as ReactClipboardEvent, PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { invoke } from '@/lib/invoke';
|
||||
import { useDrawingStore } from '@/stores/drawingStore';
|
||||
import type { DrawingImage, DrawingSettings } from '@/types';
|
||||
|
||||
interface Props {
|
||||
settings: DrawingSettings;
|
||||
prompt: string;
|
||||
onPromptChange: (value: string) => void;
|
||||
onHeightChange?: (height: number) => void;
|
||||
}
|
||||
|
||||
const TEXTAREA_MIN_HEIGHT = 72;
|
||||
const TEXTAREA_MAX_HEIGHT = 260;
|
||||
const PASTED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/jpg', 'image/webp']);
|
||||
|
||||
function clampTextareaHeight(value: number) {
|
||||
return Math.min(TEXTAREA_MAX_HEIGHT, Math.max(TEXTAREA_MIN_HEIGHT, value));
|
||||
}
|
||||
|
||||
function getExtensionForImageType(type: string) {
|
||||
if (type === 'image/jpeg' || type === 'image/jpg') return 'jpg';
|
||||
if (type === 'image/webp') return 'webp';
|
||||
return 'png';
|
||||
}
|
||||
|
||||
function ensurePastedImageName(file: File, index: number) {
|
||||
if (file.name) return file;
|
||||
const type = file.type || 'image/png';
|
||||
return new File(
|
||||
[file],
|
||||
`pasted-image-${Date.now()}-${index + 1}.${getExtensionForImageType(type)}`,
|
||||
{ type, lastModified: file.lastModified },
|
||||
);
|
||||
}
|
||||
|
||||
function getPastedImageFiles(items: DataTransferItemList | undefined) {
|
||||
if (!items) return [];
|
||||
|
||||
const files: File[] = [];
|
||||
for (const item of items) {
|
||||
if (item.kind !== 'file' || !PASTED_IMAGE_TYPES.has(item.type)) continue;
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(ensurePastedImageName(file, files.length));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function DrawingEditPreview({ image, previewUrl }: { image: DrawingImage; previewUrl: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const [src, setSrc] = useState<string | null>(previewUrl);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewUrl) {
|
||||
setSrc(previewUrl);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setSrc(null);
|
||||
invoke<string>('read_attachment_preview', { filePath: image.storage_path })
|
||||
.then((data) => { if (!cancelled) setSrc(data); })
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [image.storage_path, previewUrl]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-md"
|
||||
style={{
|
||||
background: token.colorFillAlter,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{src ? (
|
||||
<Image
|
||||
src={src}
|
||||
alt={t('drawing.editPreview', '编辑预览')}
|
||||
width={36}
|
||||
height={36}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: 36,
|
||||
height: 36,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
preview={{ mask: { blur: true }, scaleStep: 0.5 }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawingComposer({ settings, prompt, onPromptChange, onHeightChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const { message } = App.useApp();
|
||||
const references = useDrawingStore((s) => s.references);
|
||||
const editSourceImage = useDrawingStore((s) => s.editSourceImage);
|
||||
const editMaskFileId = useDrawingStore((s) => s.editMaskFileId);
|
||||
const editMaskFile = useDrawingStore((s) => s.editMaskFile);
|
||||
const editPreviewUrl = useDrawingStore((s) => s.editPreviewUrl);
|
||||
const selectImageForEdit = useDrawingStore((s) => s.selectImageForEdit);
|
||||
const uploadReferenceImage = useDrawingStore((s) => s.uploadReferenceImage);
|
||||
const generateImages = useDrawingStore((s) => s.generateImages);
|
||||
const editImage = useDrawingStore((s) => s.editImage);
|
||||
const editImageWithMask = useDrawingStore((s) => s.editImageWithMask);
|
||||
const submitting = useDrawingStore((s) => s.submitting);
|
||||
const [textareaHeight, setTextareaHeight] = useState(TEXTAREA_MIN_HEIGHT);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const resizeStateRef = useRef<{ startY: number; startHeight: number } | null>(null);
|
||||
|
||||
const handleResizeStart = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
resizeStateRef.current = {
|
||||
startY: event.clientY,
|
||||
startHeight: textareaHeight,
|
||||
};
|
||||
setResizing(true);
|
||||
}, [textareaHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizing) return undefined;
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const state = resizeStateRef.current;
|
||||
if (!state) return;
|
||||
setTextareaHeight(clampTextareaHeight(state.startHeight + state.startY - event.clientY));
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
resizeStateRef.current = null;
|
||||
setResizing(false);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
}, [resizing]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = rootRef.current;
|
||||
if (!node || !onHeightChange) return undefined;
|
||||
|
||||
const reportHeight = () => {
|
||||
const height = Math.ceil(node.getBoundingClientRect().height);
|
||||
if (height > 0) onHeightChange(height);
|
||||
};
|
||||
|
||||
reportHeight();
|
||||
if (typeof ResizeObserver === 'undefined') return undefined;
|
||||
const observer = new ResizeObserver(reportHeight);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [onHeightChange, textareaHeight, editSourceImage, editMaskFileId]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!settings.providerId) {
|
||||
message.warning(t('drawing.selectProvider', '选择 OpenAI Provider'));
|
||||
return;
|
||||
}
|
||||
const promptText = prompt.trim();
|
||||
if (!promptText) {
|
||||
message.warning(t('drawing.promptRequired', '请输入提示词'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = {
|
||||
provider_id: settings.providerId,
|
||||
model_id: settings.modelId,
|
||||
prompt: promptText,
|
||||
size: settings.size,
|
||||
quality: settings.quality,
|
||||
output_format: settings.outputFormat,
|
||||
background: settings.background,
|
||||
output_compression: settings.outputCompression,
|
||||
reference_image_mode: settings.referenceImageMode,
|
||||
reference_image_format: settings.referenceImageFormat,
|
||||
reference_image_param_name: settings.referenceImageParamName,
|
||||
n: settings.n,
|
||||
reference_file_ids: references.map((item) => item.id),
|
||||
generation_api_path: settings.generationApiPath,
|
||||
edit_api_path: settings.editApiPath,
|
||||
};
|
||||
onPromptChange('');
|
||||
if (editSourceImage && editMaskFileId) {
|
||||
await editImageWithMask({
|
||||
...base,
|
||||
source_image_id: editSourceImage.id,
|
||||
mask_file_id: editMaskFile?.id ?? editMaskFileId,
|
||||
});
|
||||
} else if (editSourceImage) {
|
||||
await editImage({ ...base, source_image_id: editSourceImage.id });
|
||||
} else {
|
||||
await generateImages(base);
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = useCallback((event: ReactClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const imageFiles = getPastedImageFiles(event.clipboardData?.items);
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
void (async () => {
|
||||
try {
|
||||
await Promise.all(imageFiles.map((file) => uploadReferenceImage(file)));
|
||||
message.success?.(t('drawing.referenceAdded', '已加入参考图'));
|
||||
} catch (error) {
|
||||
message.error?.(String(error));
|
||||
}
|
||||
})();
|
||||
}, [message, t, uploadReferenceImage]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="absolute bottom-0 left-0 right-0 z-10 px-[10px] pb-5 pt-3"
|
||||
style={{
|
||||
backgroundColor: token.colorBgContainer,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-testid="drawing-composer"
|
||||
style={{
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 16,
|
||||
backgroundColor: token.colorBgContainer,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-testid="drawing-composer-resize-handle"
|
||||
onPointerDown={handleResizeStart}
|
||||
style={{
|
||||
height: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
cursor: 'ns-resize',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
<GripHorizontal size={14} style={{ color: token.colorTextQuaternary, opacity: 0.5 }} />
|
||||
</div>
|
||||
{editSourceImage && (
|
||||
<div className="flex items-center gap-2 px-3 pt-2 pb-1">
|
||||
<DrawingEditPreview image={editSourceImage} previewUrl={editPreviewUrl} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<Tag color={editMaskFileId ? 'green' : 'blue'} style={{ width: 'fit-content', marginInlineEnd: 0 }}>
|
||||
{editMaskFileId ? t('drawing.maskEditMode', '区域编辑模式') : t('drawing.editMode', '编辑模式')}
|
||||
</Tag>
|
||||
<span className="min-w-0 truncate" style={{ fontSize: 12, color: token.colorTextSecondary }}>
|
||||
{editSourceImage.storage_path}
|
||||
</span>
|
||||
</div>
|
||||
<Button size="small" type="text" icon={<X size={14} />} onClick={() => selectImageForEdit(null)} />
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className="boosapi-input-textarea"
|
||||
value={prompt}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(event) => {
|
||||
if (event.nativeEvent.isComposing || event.key === 'Process') return;
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder={t('drawing.promptPlaceholder', '输入你想生成的画面')}
|
||||
rows={2}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
padding: '4px 16px 8px',
|
||||
fontSize: token.fontSize,
|
||||
lineHeight: 1.6,
|
||||
backgroundColor: 'transparent',
|
||||
color: token.colorText,
|
||||
fontFamily: 'inherit',
|
||||
minHeight: TEXTAREA_MIN_HEIGHT,
|
||||
height: textareaHeight,
|
||||
maxHeight: TEXTAREA_MAX_HEIGHT,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between px-2 pb-2">
|
||||
<div />
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={<ArrowUp size={14} />}
|
||||
loading={submitting}
|
||||
disabled={!prompt.trim()}
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
import { Button, Dropdown, Image, Popconfirm, Tooltip, Tag, Typography, message, theme } from 'antd';
|
||||
import { AtSign, CircleStop, Clipboard, Download, Focus, FolderOpen, Pencil, RefreshCw, Save, Trash2 } from 'lucide-react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { DrawingGeneration, DrawingImage } from '@/types';
|
||||
import { describeDrawingSize } from '@/lib/drawingModels';
|
||||
import { CopyButton } from '@/components/common/CopyButton';
|
||||
import { invoke } from '@/lib/invoke';
|
||||
import { copyChatImage, saveChatImage } from '@/lib/chatImageActions';
|
||||
import { DrawingImageStrip } from './DrawingImageStrip';
|
||||
|
||||
interface Props {
|
||||
generation: DrawingGeneration;
|
||||
onEdit: (image: DrawingImage) => void;
|
||||
onMaskEdit: (image: DrawingImage) => void;
|
||||
onRetry: (generation: DrawingGeneration) => void;
|
||||
onStop?: (id: string) => void;
|
||||
onDelete: (id: string, deleteResources: boolean) => void;
|
||||
onUsePrompt: (prompt: string) => void;
|
||||
onUseAsReference?: (image: DrawingImage) => void;
|
||||
}
|
||||
|
||||
function parseParams(generation: DrawingGeneration): Record<string, any> {
|
||||
try {
|
||||
return JSON.parse(generation.parameters_json || '{}');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function describeQuality(value: string | undefined, t: (key: string, fallback: string) => string) {
|
||||
if (value === 'low') return t('drawing.option.quality.low', '低');
|
||||
if (value === 'medium') return t('drawing.option.quality.medium', '中');
|
||||
if (value === 'high') return t('drawing.option.quality.high', '高');
|
||||
return t('drawing.option.auto', '自动');
|
||||
}
|
||||
|
||||
function describeFormat(value: string | undefined) {
|
||||
return (value || 'png').toUpperCase();
|
||||
}
|
||||
|
||||
function describeBackground(value: string | undefined, t: (key: string, fallback: string) => string) {
|
||||
if (value === 'opaque') return t('drawing.option.background.opaque', '不透明');
|
||||
if (value === 'transparent') return t('drawing.option.background.transparent', '透明');
|
||||
return t('drawing.option.auto', '自动');
|
||||
}
|
||||
|
||||
function describeAction(action: DrawingGeneration['action'], t: (key: string, fallback: string) => string) {
|
||||
if (action === 'reference_generate') return t('drawing.action.referenceGenerate', '参考图生成');
|
||||
if (action === 'edit') return t('drawing.action.edit', '编辑');
|
||||
if (action === 'mask_edit') return t('drawing.action.maskEdit', '区域编辑');
|
||||
return t('drawing.action.generate', '文本生成');
|
||||
}
|
||||
|
||||
function describeSize(value: string | undefined, t: (key: string, fallback: string) => string) {
|
||||
if (!value || value === 'auto') return t('drawing.option.auto', '自动');
|
||||
return describeDrawingSize(value);
|
||||
}
|
||||
|
||||
const CONTEXT_THUMBNAIL_SIZE = 32;
|
||||
|
||||
function DrawingContextThumbnail({ filePath, label }: { filePath: string; label: string }) {
|
||||
const { token } = theme.useToken();
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSrc(null);
|
||||
invoke<string>('read_attachment_preview', { filePath })
|
||||
.then((data) => { if (!cancelled) setSrc(data); })
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [filePath]);
|
||||
|
||||
return (
|
||||
<Tooltip title={label}>
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center justify-center overflow-hidden rounded-md align-middle"
|
||||
style={{
|
||||
width: CONTEXT_THUMBNAIL_SIZE,
|
||||
height: CONTEXT_THUMBNAIL_SIZE,
|
||||
background: token.colorFillAlter,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{src ? (
|
||||
<Image
|
||||
src={src}
|
||||
alt={label}
|
||||
width={CONTEXT_THUMBNAIL_SIZE}
|
||||
height={CONTEXT_THUMBNAIL_SIZE}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: CONTEXT_THUMBNAIL_SIZE,
|
||||
height: CONTEXT_THUMBNAIL_SIZE,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
preview={{ mask: { blur: true }, scaleStep: 0.5 }}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawingImageMenuLabel({ image, label }: { image: DrawingImage; label: string }) {
|
||||
const { token } = theme.useToken();
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
invoke<string>('read_attachment_preview', { filePath: image.storage_path })
|
||||
.then((data) => { if (!cancelled) setSrc(data); })
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [image.storage_path]);
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded"
|
||||
style={{
|
||||
background: token.colorFillAlter,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={label}
|
||||
className="block h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawingGenerationItem({
|
||||
generation,
|
||||
onEdit,
|
||||
onMaskEdit,
|
||||
onRetry,
|
||||
onStop,
|
||||
onDelete,
|
||||
onUsePrompt,
|
||||
onUseAsReference,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const params = parseParams(generation);
|
||||
const firstImage = generation.images[0];
|
||||
const hasGeneratedImages = generation.images.length > 0;
|
||||
const isRunning = generation.status === 'running';
|
||||
const isStopped = generation.status === 'stopped';
|
||||
const placeholderCount = Number(params.n || generation.images.length || 1);
|
||||
const hasMultipleImages = generation.images.length > 1;
|
||||
const imageMenuItems = useMemo(() => generation.images.map((image, index) => ({
|
||||
key: image.id,
|
||||
label: (
|
||||
<DrawingImageMenuLabel
|
||||
image={image}
|
||||
label={t('drawing.imageNumber', `图${index + 1}`, { index: index + 1 })}
|
||||
/>
|
||||
),
|
||||
})), [generation.images, t]);
|
||||
const contextThumbnails = useMemo(() => [
|
||||
...(generation.source_images ?? []).map((image, index) => ({
|
||||
key: `source-${image.id}`,
|
||||
filePath: image.storage_path,
|
||||
label: t('drawing.sourceImageWithIndex', `原图 ${index + 1}`, { index: index + 1 }),
|
||||
})),
|
||||
...(generation.mask_file ? [{
|
||||
key: `mask-${generation.mask_file.id}`,
|
||||
filePath: generation.mask_file.storage_path,
|
||||
label: t('drawing.maskImage', 'Mask 图'),
|
||||
}] : []),
|
||||
...(generation.reference_files ?? []).map((file, index) => ({
|
||||
key: `ref-${file.id}`,
|
||||
filePath: file.storage_path,
|
||||
label: t('drawing.referenceImageWithIndex', `参考图 ${index + 1}`, { index: index + 1 }),
|
||||
})),
|
||||
], [generation.mask_file, generation.reference_files, generation.source_images, t]);
|
||||
const metadata = [
|
||||
{ label: t('drawing.meta.action', '类型'), value: describeAction(generation.action, t) },
|
||||
{ label: t('drawing.meta.model', '模型'), value: generation.model_id },
|
||||
{ label: t('drawing.meta.size', '尺寸'), value: describeSize(params.size, t) },
|
||||
{ label: t('drawing.meta.quality', '质量'), value: describeQuality(params.quality, t) },
|
||||
{ label: t('drawing.meta.format', '格式'), value: describeFormat(params.output_format) },
|
||||
{ label: t('drawing.meta.background', '背景'), value: describeBackground(params.background, t) },
|
||||
{ label: t('drawing.meta.count', '张数'), value: String(placeholderCount) },
|
||||
];
|
||||
|
||||
const resolveFirstImagePreview = async () => {
|
||||
if (!firstImage) throw new Error('No image to operate on.');
|
||||
return invoke<string>('read_attachment_preview', { filePath: firstImage.storage_path });
|
||||
};
|
||||
|
||||
const handleRevealImage = async () => {
|
||||
if (!firstImage) return;
|
||||
try {
|
||||
await invoke('reveal_attachment_file', { filePath: firstImage.storage_path });
|
||||
} catch (error) {
|
||||
message.error(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveImage = async () => {
|
||||
if (!firstImage) return;
|
||||
try {
|
||||
const src = await resolveFirstImagePreview();
|
||||
await saveChatImage(src, firstImage.storage_path.split('/').pop() || 'drawing.png');
|
||||
} catch (error) {
|
||||
message.error(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyImage = async () => {
|
||||
if (!firstImage) return;
|
||||
try {
|
||||
const src = await resolveFirstImagePreview();
|
||||
await copyChatImage(src);
|
||||
message.success(t('common.copySuccess', '已复制到剪贴板'));
|
||||
} catch (error) {
|
||||
message.error(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseAsReference = (image: DrawingImage) => {
|
||||
if (!onUseAsReference) return;
|
||||
try {
|
||||
onUseAsReference(image);
|
||||
message.success(t('drawing.referenceAdded', '已加入参考图'));
|
||||
} catch (error) {
|
||||
message.error(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const renderRetryAction = () => (
|
||||
<Tooltip title={t('drawing.regenerate', '再次生成')}>
|
||||
<Button
|
||||
aria-label={t('drawing.regenerate', '再次生成')}
|
||||
size="small"
|
||||
color="default"
|
||||
variant="filled"
|
||||
icon={<RefreshCw size={15} />}
|
||||
onClick={() => onRetry(generation)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const renderDirectDeleteAction = () => (
|
||||
<Popconfirm
|
||||
title={t('drawing.deleteConfirmTitle', '删除这条绘画记录?')}
|
||||
okText={t('common.confirm', '确认')}
|
||||
cancelText={t('common.cancel', '取消')}
|
||||
onConfirm={() => onDelete(generation.id, false)}
|
||||
>
|
||||
<Tooltip title={t('drawing.deleteRecord', '删除')}>
|
||||
<Button
|
||||
aria-label={t('drawing.deleteRecord', '删除')}
|
||||
size="small"
|
||||
color="danger"
|
||||
variant="filled"
|
||||
icon={<Trash2 size={15} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
);
|
||||
|
||||
const getImageById = (id: string) => generation.images.find((image) => image.id === id);
|
||||
|
||||
const renderImageAction = ({
|
||||
title,
|
||||
ariaLabel,
|
||||
icon,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string;
|
||||
ariaLabel: string;
|
||||
icon: ReactNode;
|
||||
onSelect: (image: DrawingImage) => void;
|
||||
}) => {
|
||||
const button = (
|
||||
<Tooltip title={title}>
|
||||
<Button
|
||||
aria-label={ariaLabel}
|
||||
size="small"
|
||||
color="default"
|
||||
variant="filled"
|
||||
icon={icon}
|
||||
disabled={!firstImage}
|
||||
onClick={hasMultipleImages ? undefined : () => firstImage && onSelect(firstImage)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
if (!hasMultipleImages) return button;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: imageMenuItems,
|
||||
onClick: ({ key }) => {
|
||||
const image = getImageById(String(key));
|
||||
if (image) onSelect(image);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{button}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
background: token.colorBgContainer,
|
||||
}}
|
||||
>
|
||||
<div className="mb-3 flex flex-col gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{contextThumbnails.map((item) => (
|
||||
<DrawingContextThumbnail
|
||||
key={item.key}
|
||||
filePath={item.filePath}
|
||||
label={item.label}
|
||||
/>
|
||||
))}
|
||||
<span className="drawing-prompt-line">
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="drawing-prompt-trigger rounded-md"
|
||||
aria-label={t('drawing.usePrompt', '使用提示词')}
|
||||
onClick={() => onUsePrompt(generation.prompt)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onUsePrompt(generation.prompt);
|
||||
}}
|
||||
style={{
|
||||
'--drawing-prompt-hover-bg': token.colorFillSecondary,
|
||||
} as CSSProperties}
|
||||
>
|
||||
<Typography.Text style={{ fontSize: 14, lineHeight: 1.65 }}>
|
||||
{generation.prompt}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
</span>
|
||||
<Tooltip title={t('drawing.copyPrompt', '复制提示词')}>
|
||||
<CopyButton
|
||||
className="drawing-prompt-copy"
|
||||
text={generation.prompt}
|
||||
successMessage={t('common.copySuccess', '已复制到剪贴板')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<span className="ml-2 inline-flex align-middle">
|
||||
{generation.status === 'failed' && (
|
||||
<Tag color="error" style={{ marginTop: 3 }}>{t('drawing.failed', '失败')}</Tag>
|
||||
)}
|
||||
{generation.status === 'running' && (
|
||||
<Tag color="processing" style={{ marginTop: 3 }}>{t('drawing.generating', '生成中')}</Tag>
|
||||
)}
|
||||
{isStopped && (
|
||||
<Tag color="warning" style={{ marginTop: 3 }}>{t('drawing.stopped', '主动停止')}</Tag>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{metadata.map((item) => (
|
||||
<span
|
||||
key={item.label}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1"
|
||||
style={{
|
||||
background: token.colorFillAlter,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 12,
|
||||
lineHeight: '16px',
|
||||
}}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span style={{ color: token.colorText, fontWeight: 500 }}>{item.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generation.error_message ? (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md px-3 py-2"
|
||||
style={{
|
||||
background: isStopped ? token.colorWarningBg : token.colorErrorBg,
|
||||
border: `1px solid ${isStopped ? token.colorWarningBorder : token.colorErrorBorder}`,
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
type={isStopped ? 'warning' : 'danger'}
|
||||
className="min-w-0 flex-1"
|
||||
style={{ whiteSpace: 'pre-wrap' }}
|
||||
>
|
||||
{generation.error_message}
|
||||
</Typography.Text>
|
||||
<CopyButton
|
||||
className="drawing-error-copy"
|
||||
text={generation.error_message}
|
||||
successMessage={t('common.copySuccess', '已复制到剪贴板')}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DrawingImageStrip
|
||||
images={generation.images}
|
||||
loading={isRunning}
|
||||
placeholderCount={placeholderCount}
|
||||
onEdit={onEdit}
|
||||
onMaskEdit={onMaskEdit}
|
||||
onUseAsReference={onUseAsReference ? handleUseAsReference : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isRunning && onStop && (
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Popconfirm
|
||||
title={t('drawing.stopConfirmTitle', '停止生成?')}
|
||||
description={t('drawing.stopConfirmContent', '停止后这条记录会标记为主动停止。')}
|
||||
okText={t('common.confirm', '确认')}
|
||||
cancelText={t('common.cancel', '取消')}
|
||||
onConfirm={() => onStop(generation.id)}
|
||||
>
|
||||
<Tooltip title={t('common.stop', '停止')}>
|
||||
<Button
|
||||
aria-label={t('common.stop', '停止')}
|
||||
size="small"
|
||||
color="default"
|
||||
variant="filled"
|
||||
icon={<CircleStop size={15} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRunning && !hasGeneratedImages && (
|
||||
<div className="mt-4 flex gap-2">
|
||||
{renderRetryAction()}
|
||||
{renderDirectDeleteAction()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRunning && hasGeneratedImages && (
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'reveal',
|
||||
icon: <FolderOpen size={14} />,
|
||||
label: t('drawing.openOriginalDirectory', '打开原图目录'),
|
||||
},
|
||||
{
|
||||
key: 'save',
|
||||
icon: <Save size={14} />,
|
||||
label: t('drawing.saveAs', '另存为'),
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
icon: <Clipboard size={14} />,
|
||||
label: t('drawing.copyToClipboard', '复制到剪切板'),
|
||||
},
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'reveal') void handleRevealImage();
|
||||
if (key === 'save') void handleSaveImage();
|
||||
if (key === 'copy') void handleCopyImage();
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip title={t('drawing.download', '下载')}>
|
||||
<Button
|
||||
aria-label={t('drawing.download', '下载')}
|
||||
size="small"
|
||||
color="default"
|
||||
variant="filled"
|
||||
icon={<Download size={15} />}
|
||||
disabled={!firstImage}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
{onUseAsReference && (
|
||||
renderImageAction({
|
||||
title: t('drawing.useAsReference', '作为参考图'),
|
||||
ariaLabel: t('drawing.useAsReference', '作为参考图'),
|
||||
icon: <AtSign size={15} />,
|
||||
onSelect: handleUseAsReference,
|
||||
})
|
||||
)}
|
||||
{renderImageAction({
|
||||
title: t('drawing.reEdit', '重新编辑'),
|
||||
ariaLabel: t('drawing.reEdit', '重新编辑'),
|
||||
icon: <Pencil size={15} />,
|
||||
onSelect: onEdit,
|
||||
})}
|
||||
{renderImageAction({
|
||||
title: t('drawing.maskEdit', '区域编辑'),
|
||||
ariaLabel: t('drawing.maskEdit', '区域编辑'),
|
||||
icon: <Focus size={15} />,
|
||||
onSelect: onMaskEdit,
|
||||
})}
|
||||
{renderRetryAction()}
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'record',
|
||||
label: t('drawing.deleteRecordOnly', '仅删除记录'),
|
||||
},
|
||||
{
|
||||
key: 'all',
|
||||
label: t('drawing.deleteRecordAndImages', '全部删除'),
|
||||
danger: true,
|
||||
},
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'record') onDelete(generation.id, false);
|
||||
if (key === 'all') onDelete(generation.id, true);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip title={t('drawing.deleteRecord', '删除')}>
|
||||
<Button
|
||||
aria-label={t('drawing.deleteRecord', '删除')}
|
||||
size="small"
|
||||
color="danger"
|
||||
variant="filled"
|
||||
icon={<Trash2 size={15} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Empty, Spin, theme } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDrawingStore } from '@/stores/drawingStore';
|
||||
import type { DrawingGeneration, DrawingImage, DrawingReferenceImageMode } from '@/types';
|
||||
import { DrawingGenerationItem } from './DrawingGenerationItem';
|
||||
|
||||
interface Props {
|
||||
onEdit: (image: DrawingImage) => void;
|
||||
onMaskEdit: (image: DrawingImage) => void;
|
||||
onUsePrompt: (prompt: string) => void;
|
||||
referenceImageMode: DrawingReferenceImageMode;
|
||||
}
|
||||
|
||||
export function DrawingGenerationList({ onEdit, onMaskEdit, onUsePrompt, referenceImageMode }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const generations = useDrawingStore((s) => s.generations);
|
||||
const loading = useDrawingStore((s) => s.loading);
|
||||
const retryGeneration = useDrawingStore((s) => s.retryGeneration);
|
||||
const stopGeneration = useDrawingStore((s) => s.stopGeneration);
|
||||
const deleteGeneration = useDrawingStore((s) => s.deleteGeneration);
|
||||
const useImageAsReference = useDrawingStore((s) => s.useImageAsReference);
|
||||
|
||||
if (loading && generations.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center" data-testid="drawing-generation-list">
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (generations.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-full items-center justify-center"
|
||||
data-testid="drawing-generation-list"
|
||||
style={{ color: token.colorTextSecondary }}
|
||||
>
|
||||
<Empty description={t('drawing.empty', '暂无绘画记录')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="drawing-generation-list">
|
||||
{generations.map((generation: DrawingGeneration) => (
|
||||
<DrawingGenerationItem
|
||||
key={generation.id}
|
||||
generation={generation}
|
||||
onEdit={onEdit}
|
||||
onMaskEdit={onMaskEdit}
|
||||
onRetry={(item) => retryGeneration(item, referenceImageMode).catch(() => {})}
|
||||
onStop={stopGeneration}
|
||||
onDelete={(id, deleteResources) => deleteGeneration(id, deleteResources).catch(() => {})}
|
||||
onUsePrompt={onUsePrompt}
|
||||
onUseAsReference={useImageAsReference}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { Button, Image, Spin, Tooltip, theme } from 'antd';
|
||||
import { AtSign, Focus, Pencil } from 'lucide-react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { invoke } from '@/lib/invoke';
|
||||
import type { DrawingImage } from '@/types';
|
||||
|
||||
interface Props {
|
||||
images: DrawingImage[];
|
||||
loading?: boolean;
|
||||
placeholderCount?: number;
|
||||
onUseAsReference?: (image: DrawingImage) => void;
|
||||
onEdit?: (image: DrawingImage) => void;
|
||||
onMaskEdit?: (image: DrawingImage) => void;
|
||||
}
|
||||
|
||||
const IMAGE_MAX_WIDTH = 180;
|
||||
const IMAGE_MAX_HEIGHT = 300;
|
||||
const IMAGE_CORNER_RADIUS = 6;
|
||||
|
||||
const placeholderTileStyle: CSSProperties = {
|
||||
flex: `0 0 ${IMAGE_MAX_WIDTH}px`,
|
||||
width: IMAGE_MAX_WIDTH,
|
||||
height: IMAGE_MAX_HEIGHT,
|
||||
borderRadius: IMAGE_CORNER_RADIUS,
|
||||
};
|
||||
|
||||
function getImageTileStyle(image: DrawingImage): CSSProperties {
|
||||
const width = image.width && image.width > 0 ? image.width : 1;
|
||||
const height = image.height && image.height > 0 ? image.height : 1;
|
||||
const ratio = width / height;
|
||||
const maxRatio = IMAGE_MAX_WIDTH / IMAGE_MAX_HEIGHT;
|
||||
const tileWidth = ratio >= maxRatio
|
||||
? IMAGE_MAX_WIDTH
|
||||
: Math.max(1, Math.round(IMAGE_MAX_HEIGHT * ratio));
|
||||
const tileHeight = ratio >= maxRatio
|
||||
? Math.max(1, Math.round(IMAGE_MAX_WIDTH / ratio))
|
||||
: IMAGE_MAX_HEIGHT;
|
||||
|
||||
return {
|
||||
flex: `0 0 ${tileWidth}px`,
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
borderRadius: IMAGE_CORNER_RADIUS,
|
||||
background: 'transparent',
|
||||
};
|
||||
}
|
||||
|
||||
function DrawingPreviewImage({
|
||||
image,
|
||||
groupedPreview,
|
||||
onOpenGroupedPreview,
|
||||
onPreviewSourceReady,
|
||||
onUseAsReference,
|
||||
onEdit,
|
||||
onMaskEdit,
|
||||
}: {
|
||||
image: DrawingImage;
|
||||
groupedPreview?: boolean;
|
||||
onOpenGroupedPreview?: (image: DrawingImage) => void;
|
||||
onPreviewSourceReady?: (imageId: string, src: string) => void;
|
||||
onUseAsReference?: (image: DrawingImage) => void;
|
||||
onEdit?: (image: DrawingImage) => void;
|
||||
onMaskEdit?: (image: DrawingImage) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const tileStyle = useMemo(() => getImageTileStyle(image), [image.height, image.width]);
|
||||
const tileRef = useRef<HTMLDivElement>(null);
|
||||
const [shouldLoad, setShouldLoad] = useState(false);
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = tileRef.current;
|
||||
if (!node) return undefined;
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
setShouldLoad(true);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||
setShouldLoad(true);
|
||||
observer.disconnect();
|
||||
}, { rootMargin: '160px 0px' });
|
||||
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldLoad) return undefined;
|
||||
let cancelled = false;
|
||||
invoke<string>('read_attachment_preview', { filePath: image.storage_path })
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setSrc(data);
|
||||
onPreviewSourceReady?.(image.id, data);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [image.id, image.storage_path, onPreviewSourceReady, shouldLoad]);
|
||||
|
||||
const previewConfig = groupedPreview
|
||||
? {
|
||||
mask: { blur: true },
|
||||
scaleStep: 0.5,
|
||||
open: false,
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (open) onOpenGroupedPreview?.(image);
|
||||
},
|
||||
}
|
||||
: { mask: { blur: true }, scaleStep: 0.5 };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={tileRef}
|
||||
className="drawing-preview-tile group relative overflow-hidden"
|
||||
style={tileStyle}
|
||||
>
|
||||
{src ? (
|
||||
<Image
|
||||
src={src}
|
||||
width="100%"
|
||||
height="100%"
|
||||
loading="lazy"
|
||||
styles={{
|
||||
root: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
borderRadius: IMAGE_CORNER_RADIUS,
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
objectFit: 'contain',
|
||||
borderRadius: IMAGE_CORNER_RADIUS,
|
||||
},
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
objectFit: 'contain',
|
||||
borderRadius: IMAGE_CORNER_RADIUS,
|
||||
}}
|
||||
preview={previewConfig}
|
||||
/>
|
||||
) : shouldLoad ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full w-full" />
|
||||
)}
|
||||
{src && (onUseAsReference || onEdit || onMaskEdit) && (
|
||||
<div className="drawing-image-hover-actions pointer-events-none absolute right-2 top-2 z-20 flex gap-1">
|
||||
{onUseAsReference && (
|
||||
<Tooltip title={t('drawing.useAsReference', '作为参考图')}>
|
||||
<Button
|
||||
aria-label={t('drawing.useAsReference', '作为参考图')}
|
||||
className="drawing-image-action-button pointer-events-auto"
|
||||
size="small"
|
||||
shape="circle"
|
||||
color="default"
|
||||
variant="filled"
|
||||
icon={<AtSign size={15} strokeWidth={2.4} />}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onUseAsReference(image);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Tooltip title={t('drawing.reEdit', '重新编辑')}>
|
||||
<Button
|
||||
aria-label={t('drawing.reEdit', '重新编辑')}
|
||||
className="drawing-image-action-button pointer-events-auto"
|
||||
size="small"
|
||||
shape="circle"
|
||||
color="default"
|
||||
variant="filled"
|
||||
icon={<Pencil size={14} />}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEdit(image);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onMaskEdit && (
|
||||
<Tooltip title={t('drawing.maskEdit', '区域编辑')}>
|
||||
<Button
|
||||
aria-label={t('drawing.maskEdit', '区域编辑')}
|
||||
className="drawing-image-action-button pointer-events-auto"
|
||||
size="small"
|
||||
shape="circle"
|
||||
color="default"
|
||||
variant="filled"
|
||||
icon={<Focus size={14} />}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onMaskEdit(image);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawingImagePlaceholder() {
|
||||
const { token } = theme.useToken();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="drawing-image-placeholder relative overflow-hidden"
|
||||
style={{
|
||||
...placeholderTileStyle,
|
||||
background: token.colorFillAlter,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
animation: 'boosapi-drawing-shimmer 1.35s linear infinite',
|
||||
background: `linear-gradient(110deg, ${token.colorFillAlter} 8%, ${token.colorFillSecondary} 18%, ${token.colorFillAlter} 33%)`,
|
||||
backgroundSize: '220% 100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawingImageStrip({
|
||||
images,
|
||||
loading,
|
||||
placeholderCount = 1,
|
||||
onUseAsReference,
|
||||
onEdit,
|
||||
onMaskEdit,
|
||||
}: Props) {
|
||||
const previewCacheRef = useRef(new Map<string, string>());
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewCurrent, setPreviewCurrent] = useState(0);
|
||||
const [previewItems, setPreviewItems] = useState<string[]>([]);
|
||||
const groupedPreview = images.length > 1;
|
||||
const placeholders = useMemo(
|
||||
() => Array.from({ length: Math.max(placeholderCount, images.length, 1) }),
|
||||
[images.length, placeholderCount],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const imageIds = new Set(images.map((image) => image.id));
|
||||
for (const imageId of previewCacheRef.current.keys()) {
|
||||
if (!imageIds.has(imageId)) previewCacheRef.current.delete(imageId);
|
||||
}
|
||||
}, [images]);
|
||||
|
||||
const handlePreviewSourceReady = useCallback((imageId: string, src: string) => {
|
||||
previewCacheRef.current.set(imageId, src);
|
||||
}, []);
|
||||
|
||||
const readPreviewSource = useCallback(async (image: DrawingImage) => {
|
||||
const cached = previewCacheRef.current.get(image.id);
|
||||
if (cached) return cached;
|
||||
|
||||
const data = await invoke<string>('read_attachment_preview', { filePath: image.storage_path });
|
||||
previewCacheRef.current.set(image.id, data);
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
const handleOpenGroupedPreview = useCallback(async (selectedImage: DrawingImage) => {
|
||||
const selectedIndex = Math.max(0, images.findIndex((image) => image.id === selectedImage.id));
|
||||
const sources = await Promise.all(images.map(async (image, index) => {
|
||||
try {
|
||||
return { index, src: await readPreviewSource(image) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
const availableSources = sources.filter((source): source is { index: number; src: string } => Boolean(source));
|
||||
if (availableSources.length === 0) return;
|
||||
|
||||
setPreviewItems(availableSources.map((source) => source.src));
|
||||
setPreviewCurrent(Math.max(0, availableSources.findIndex((source) => source.index === selectedIndex)));
|
||||
setPreviewOpen(true);
|
||||
}, [images, readPreviewSource]);
|
||||
|
||||
if (loading && images.length === 0) {
|
||||
return (
|
||||
<div className="drawing-image-strip flex w-full overflow-x-auto overflow-y-hidden rounded-md" style={{ gap: 7 }}>
|
||||
{placeholders.map((_, index) => (
|
||||
<DrawingImagePlaceholder key={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{groupedPreview && (
|
||||
<Image.PreviewGroup
|
||||
items={previewItems}
|
||||
preview={{
|
||||
open: previewOpen,
|
||||
current: previewCurrent,
|
||||
mask: { blur: true },
|
||||
scaleStep: 0.5,
|
||||
onOpenChange: (open) => setPreviewOpen(open),
|
||||
onChange: (current) => setPreviewCurrent(current),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="drawing-image-strip flex w-full overflow-x-auto overflow-y-hidden rounded-md" style={{ gap: 7 }}>
|
||||
{images.map((image) => (
|
||||
<DrawingPreviewImage
|
||||
key={image.id}
|
||||
image={image}
|
||||
groupedPreview={groupedPreview}
|
||||
onOpenGroupedPreview={handleOpenGroupedPreview}
|
||||
onPreviewSourceReady={handlePreviewSourceReady}
|
||||
onUseAsReference={onUseAsReference}
|
||||
onEdit={onEdit}
|
||||
onMaskEdit={onMaskEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { App, Button, Modal, Slider, Space, theme } from 'antd';
|
||||
import { Eraser, RotateCcw, Undo2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { invoke } from '@/lib/invoke';
|
||||
import type { DrawingImage, DrawingStoredFile } from '@/types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
image: DrawingImage | null;
|
||||
onApply: (image: DrawingImage, maskFile: DrawingStoredFile, previewUrl: string | null) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MASK_MODAL_EDGE_GAP = 24;
|
||||
const MASK_MODAL_WIDTH = `min(1080px, calc(100vw - ${MASK_MODAL_EDGE_GAP * 2}px))`;
|
||||
const MASK_MODAL_BODY_MAX_HEIGHT = 'calc(100vh - 160px)';
|
||||
const MASK_EDITOR_SURFACE_MAX_HEIGHT = 'calc(100vh - 220px)';
|
||||
const MASK_PAINT_ALPHA = 0.42;
|
||||
const MASK_ERASER_ALPHA = 1;
|
||||
const MASK_ERASER_FEEDBACK_ALPHA = 0.24;
|
||||
const MASK_ERASER_FEEDBACK_CLEAR_DELAY = 420;
|
||||
|
||||
export function getMaskBrushStyle(erasing: boolean): {
|
||||
compositeOperation: GlobalCompositeOperation;
|
||||
fillStyle: string;
|
||||
globalAlpha: number;
|
||||
};
|
||||
export function getMaskBrushStyle(erasing: boolean, themeColor: string): {
|
||||
compositeOperation: GlobalCompositeOperation;
|
||||
fillStyle: string;
|
||||
globalAlpha: number;
|
||||
};
|
||||
export function getMaskBrushStyle(erasing: boolean, themeColor = '#1677ff') {
|
||||
return {
|
||||
compositeOperation: erasing ? 'destination-out' : 'source-over',
|
||||
fillStyle: erasing ? '#000000' : themeColor,
|
||||
globalAlpha: erasing ? MASK_ERASER_ALPHA : MASK_PAINT_ALPHA,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEraserFeedbackStyle(themeColor = '#1677ff'): {
|
||||
compositeOperation: GlobalCompositeOperation;
|
||||
fillStyle: string;
|
||||
globalAlpha: number;
|
||||
} {
|
||||
return {
|
||||
compositeOperation: 'source-over',
|
||||
fillStyle: themeColor,
|
||||
globalAlpha: MASK_ERASER_FEEDBACK_ALPHA,
|
||||
};
|
||||
}
|
||||
|
||||
export function overlayAlphaToMaskAlpha(alpha: number): number {
|
||||
return alpha > 0 ? 0 : 255;
|
||||
}
|
||||
|
||||
export function DrawingMaskEditor({ open, image, onApply, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { message } = App.useApp();
|
||||
const { token } = theme.useToken();
|
||||
const maskCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const feedbackCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
const erasingRef = useRef(false);
|
||||
const feedbackTimerRef = useRef<number | null>(null);
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
const [brushSize, setBrushSize] = useState(36);
|
||||
const [erasing, setErasing] = useState(false);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
|
||||
const clearFeedback = () => {
|
||||
if (feedbackTimerRef.current !== null) {
|
||||
window.clearTimeout(feedbackTimerRef.current);
|
||||
feedbackTimerRef.current = null;
|
||||
}
|
||||
const feedbackCanvas = feedbackCanvasRef.current;
|
||||
feedbackCanvas?.getContext('2d')?.clearRect(0, 0, feedbackCanvas.width, feedbackCanvas.height);
|
||||
};
|
||||
|
||||
const scheduleFeedbackClear = () => {
|
||||
if (feedbackTimerRef.current !== null) window.clearTimeout(feedbackTimerRef.current);
|
||||
feedbackTimerRef.current = window.setTimeout(() => {
|
||||
feedbackTimerRef.current = null;
|
||||
const feedbackCanvas = feedbackCanvasRef.current;
|
||||
feedbackCanvas?.getContext('2d')?.clearRect(0, 0, feedbackCanvas.width, feedbackCanvas.height);
|
||||
}, MASK_ERASER_FEEDBACK_CLEAR_DELAY);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) return;
|
||||
setSrc(null);
|
||||
setHistory([]);
|
||||
setErasing(false);
|
||||
erasingRef.current = false;
|
||||
setDrawing(false);
|
||||
imgRef.current = null;
|
||||
const maskCanvas = maskCanvasRef.current;
|
||||
maskCanvas?.getContext('2d')?.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
clearFeedback();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !image) return;
|
||||
let cancelled = false;
|
||||
invoke<string>('read_attachment_preview', { filePath: image.storage_path })
|
||||
.then((data) => { if (!cancelled) setSrc(data); })
|
||||
.catch((e) => message.error(String(e)));
|
||||
return () => { cancelled = true; };
|
||||
}, [open, image, message]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!src || !maskCanvasRef.current) return;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const maskCanvas = maskCanvasRef.current;
|
||||
const feedbackCanvas = feedbackCanvasRef.current;
|
||||
if (!maskCanvas) return;
|
||||
maskCanvas.width = img.naturalWidth;
|
||||
maskCanvas.height = img.naturalHeight;
|
||||
if (feedbackCanvas) {
|
||||
feedbackCanvas.width = img.naturalWidth;
|
||||
feedbackCanvas.height = img.naturalHeight;
|
||||
}
|
||||
const ctx = maskCanvas.getContext('2d');
|
||||
ctx?.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
clearFeedback();
|
||||
imgRef.current = img;
|
||||
setHistory([]);
|
||||
};
|
||||
img.src = src;
|
||||
}, [src]);
|
||||
|
||||
const pointerPoint = (event: React.PointerEvent<HTMLCanvasElement>, canvas: HTMLCanvasElement) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: ((event.clientX - rect.left) / rect.width) * canvas.width,
|
||||
y: ((event.clientY - rect.top) / rect.height) * canvas.height,
|
||||
};
|
||||
};
|
||||
|
||||
const drawCircle = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
point: { x: number; y: number },
|
||||
style: ReturnType<typeof getMaskBrushStyle>,
|
||||
) => {
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = style.compositeOperation;
|
||||
ctx.globalAlpha = style.globalAlpha;
|
||||
ctx.fillStyle = style.fillStyle;
|
||||
ctx.beginPath();
|
||||
ctx.arc(point.x, point.y, brushSize, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
const drawEraserFeedback = (point: { x: number; y: number }) => {
|
||||
const feedbackCanvas = feedbackCanvasRef.current;
|
||||
if (!feedbackCanvas) return;
|
||||
const ctx = feedbackCanvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
drawCircle(ctx, point, getEraserFeedbackStyle(token.colorPrimary));
|
||||
};
|
||||
|
||||
const drawAt = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const maskCanvas = maskCanvasRef.current;
|
||||
if (!maskCanvas) return;
|
||||
const ctx = maskCanvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const point = pointerPoint(event, maskCanvas);
|
||||
const isErasing = erasingRef.current;
|
||||
drawCircle(ctx, point, getMaskBrushStyle(isErasing, token.colorPrimary));
|
||||
if (isErasing) drawEraserFeedback(point);
|
||||
};
|
||||
|
||||
const exportMaskPreviewUrl = () => {
|
||||
const overlay = maskCanvasRef.current;
|
||||
const image = imgRef.current;
|
||||
if (!overlay || !image) return src;
|
||||
const preview = document.createElement('canvas');
|
||||
preview.width = overlay.width;
|
||||
preview.height = overlay.height;
|
||||
const previewCtx = preview.getContext('2d');
|
||||
if (!previewCtx) return src;
|
||||
previewCtx.save();
|
||||
previewCtx.drawImage(image, 0, 0, overlay.width, overlay.height);
|
||||
previewCtx.drawImage(overlay, 0, 0);
|
||||
previewCtx.restore();
|
||||
return preview.toDataURL('image/png');
|
||||
};
|
||||
|
||||
const exportMaskBase64 = () => {
|
||||
const overlay = maskCanvasRef.current;
|
||||
if (!overlay) throw new Error('Mask canvas is not ready');
|
||||
const mask = document.createElement('canvas');
|
||||
mask.width = overlay.width;
|
||||
mask.height = overlay.height;
|
||||
const maskCtx = mask.getContext('2d');
|
||||
if (!maskCtx) throw new Error('Cannot create mask canvas');
|
||||
const overlayCtx = overlay.getContext('2d');
|
||||
if (!overlayCtx) throw new Error('Cannot read mask overlay');
|
||||
const source = overlayCtx.getImageData(0, 0, overlay.width, overlay.height);
|
||||
const output = maskCtx.createImageData(mask.width, mask.height);
|
||||
for (let index = 0; index < source.data.length; index += 4) {
|
||||
output.data[index] = 255;
|
||||
output.data[index + 1] = 255;
|
||||
output.data[index + 2] = 255;
|
||||
output.data[index + 3] = overlayAlphaToMaskAlpha(source.data[index + 3]);
|
||||
}
|
||||
maskCtx.putImageData(output, 0, 0);
|
||||
return mask.toDataURL('image/png').split(',')[1] || '';
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!image) return;
|
||||
try {
|
||||
const data = exportMaskBase64();
|
||||
const mask = await invoke<DrawingStoredFile>('upload_drawing_reference', {
|
||||
input: {
|
||||
data,
|
||||
file_name: `mask-${image.id}.png`,
|
||||
mime_type: 'image/png',
|
||||
},
|
||||
});
|
||||
onApply(image, mask, exportMaskPreviewUrl());
|
||||
} catch (e) {
|
||||
message.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('drawing.maskEdit', '区域编辑')}
|
||||
width={MASK_MODAL_WIDTH}
|
||||
style={{
|
||||
top: MASK_MODAL_EDGE_GAP,
|
||||
maxWidth: `calc(100vw - ${MASK_MODAL_EDGE_GAP * 2}px)`,
|
||||
paddingBottom: MASK_MODAL_EDGE_GAP,
|
||||
}}
|
||||
onCancel={onClose}
|
||||
onOk={handleSubmit}
|
||||
okText={t('drawing.submitMaskEdit', '提交区域编辑')}
|
||||
styles={{
|
||||
mask: { backdropFilter: 'blur(4px)' },
|
||||
body: {
|
||||
maxHeight: MASK_MODAL_BODY_MAX_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4 overflow-hidden" style={{ maxHeight: MASK_MODAL_BODY_MAX_HEIGHT }}>
|
||||
<div
|
||||
data-testid="drawing-mask-editor-surface"
|
||||
className="flex min-w-0 flex-1 items-center justify-center overflow-hidden rounded-md"
|
||||
style={{
|
||||
background: token.colorFillAlter,
|
||||
maxHeight: MASK_EDITOR_SURFACE_MAX_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
height: 'fit-content',
|
||||
maxWidth: '100%',
|
||||
maxHeight: MASK_EDITOR_SURFACE_MAX_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{src && (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
style={{
|
||||
display: 'block',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
maxWidth: '100%',
|
||||
maxHeight: MASK_EDITOR_SURFACE_MAX_HEIGHT,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<canvas
|
||||
ref={maskCanvasRef}
|
||||
className="absolute inset-0"
|
||||
style={{ width: '100%', height: '100%', pointerEvents: 'none' }}
|
||||
/>
|
||||
<canvas
|
||||
ref={feedbackCanvasRef}
|
||||
className="absolute inset-0 cursor-crosshair"
|
||||
style={{ width: '100%', height: '100%', touchAction: 'none' }}
|
||||
onPointerDown={(event) => {
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
const canvas = maskCanvasRef.current;
|
||||
if (canvas) {
|
||||
setHistory((items) => [...items.slice(-12), canvas.toDataURL('image/png')]);
|
||||
}
|
||||
if (erasingRef.current) clearFeedback();
|
||||
setDrawing(true);
|
||||
drawAt(event);
|
||||
}}
|
||||
onPointerMove={(event) => { if (drawing) drawAt(event); }}
|
||||
onPointerUp={(event) => {
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
setDrawing(false);
|
||||
if (erasingRef.current) scheduleFeedbackClear();
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
setDrawing(false);
|
||||
if (erasingRef.current) scheduleFeedbackClear();
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setDrawing(false);
|
||||
if (erasingRef.current) scheduleFeedbackClear();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 overflow-hidden" style={{ width: 180, maxHeight: MASK_EDITOR_SURFACE_MAX_HEIGHT }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Button
|
||||
block
|
||||
type={erasing ? 'primary' : 'default'}
|
||||
icon={<Eraser size={16} />}
|
||||
onClick={() => setErasing((value) => {
|
||||
const next = !value;
|
||||
erasingRef.current = next;
|
||||
if (!next) clearFeedback();
|
||||
return next;
|
||||
})}
|
||||
>
|
||||
{t('drawing.eraser', '橡皮')}
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
icon={<Undo2 size={16} />}
|
||||
disabled={history.length === 0}
|
||||
onClick={() => {
|
||||
const last = history[history.length - 1];
|
||||
const canvas = maskCanvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (!last || !canvas || !ctx) return;
|
||||
clearFeedback();
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
};
|
||||
img.src = last;
|
||||
setHistory((items) => items.slice(0, -1));
|
||||
}}
|
||||
>
|
||||
{t('drawing.undo', '撤销')}
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
icon={<RotateCcw size={16} />}
|
||||
onClick={() => {
|
||||
const canvas = maskCanvasRef.current;
|
||||
canvas?.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height);
|
||||
clearFeedback();
|
||||
setHistory([]);
|
||||
}}
|
||||
>
|
||||
{t('drawing.reset', '重置')}
|
||||
</Button>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: token.colorTextSecondary }}>{t('drawing.brushSize', '画笔大小')}</div>
|
||||
<Slider min={4} max={96} value={brushSize} onChange={setBrushSize} />
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { App, Button, Image, Upload, theme } from 'antd';
|
||||
import { ImagePlus, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDrawingStore } from '@/stores/drawingStore';
|
||||
import { invoke } from '@/lib/invoke';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function ReferenceThumb({ storagePath }: { storagePath: string }) {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
invoke<string>('read_attachment_preview', { filePath: storagePath })
|
||||
.then((data) => { if (!cancelled) setSrc(data); })
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [storagePath]);
|
||||
if (!src) return <div style={{ width: 48, height: 48 }} />;
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
width={48}
|
||||
height={48}
|
||||
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||
preview={{ mask: { blur: true }, scaleStep: 0.5 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawingReferenceUploader() {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const { message } = App.useApp();
|
||||
const references = useDrawingStore((s) => s.references);
|
||||
const uploadReferenceImage = useDrawingStore((s) => s.uploadReferenceImage);
|
||||
const removeReference = useDrawingStore((s) => s.removeReference);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
showUploadList={false}
|
||||
accept="image/png,image/jpeg,image/jpg,image/webp"
|
||||
beforeUpload={(file) => {
|
||||
uploadReferenceImage(file)
|
||||
.catch((e) => message.error(String(e)));
|
||||
return false;
|
||||
}}
|
||||
style={{
|
||||
background: token.colorFillAlter,
|
||||
borderColor: token.colorBorderSecondary,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1 py-2">
|
||||
<ImagePlus size={18} style={{ color: token.colorPrimary }} />
|
||||
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>
|
||||
{t('drawing.referenceUpload', '上传参考图')}
|
||||
</span>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
|
||||
{references.length > 0 && (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{references.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2"
|
||||
style={{ border: `1px solid ${token.colorBorderSecondary}`, borderRadius: 8, padding: 6 }}
|
||||
>
|
||||
<ReferenceThumb storagePath={item.storage_path} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate" style={{ fontSize: 12 }}>{item.original_name}</div>
|
||||
<div style={{ fontSize: 11, color: token.colorTextSecondary }}>
|
||||
{Math.round(item.size_bytes / 1024)} KB
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={() => removeReference(item.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { Form, Input, InputNumber, Select, Slider, Switch, Typography, theme } from 'antd';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
getDrawingParamConfig,
|
||||
getDrawingModelOptions,
|
||||
getDrawingProvidersForModel,
|
||||
normalizeDrawingSettingsByConfig,
|
||||
} from '@/lib/drawingModels';
|
||||
import { SmartProviderIcon } from '@/lib/providerIcons';
|
||||
import type { DrawingSettings, ProviderConfig } from '@/types';
|
||||
import { DrawingReferenceUploader } from './DrawingReferenceUploader';
|
||||
import type {
|
||||
DrawingParamField,
|
||||
DrawingParamOption,
|
||||
DrawingParamRenderContext,
|
||||
} from './params/types';
|
||||
|
||||
export type { DrawingSettings };
|
||||
|
||||
interface Props {
|
||||
settings: DrawingSettings;
|
||||
providers: ProviderConfig[];
|
||||
onChange: (settings: DrawingSettings) => void;
|
||||
}
|
||||
|
||||
export function DrawingSettingsPanel({ settings, providers, onChange }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const translateOption = (key: string, fallback: string) => t(key, fallback);
|
||||
const paramConfig = getDrawingParamConfig(settings.modelId);
|
||||
const basicFields = paramConfig.groups.find((group) => group.id === 'basic')?.fields ?? [];
|
||||
const advancedFields = paramConfig.groups.find((group) => group.id === 'advanced')?.fields ?? [];
|
||||
const modelOptions = getDrawingModelOptions();
|
||||
const compatibleProviders = getDrawingProvidersForModel(providers, settings.modelId);
|
||||
const paramModelOptions: DrawingParamOption[] = modelOptions.map((option) => ({
|
||||
fallbackLabel: option.label,
|
||||
value: option.value,
|
||||
}));
|
||||
const paramProviderOptions: DrawingParamOption[] = compatibleProviders.map((provider) => ({
|
||||
fallbackLabel: provider.name,
|
||||
value: provider.id,
|
||||
}));
|
||||
const providerSelectOptions = compatibleProviders.map((provider) => ({
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<SmartProviderIcon provider={provider} size={18} type="avatar" />
|
||||
<span>{provider.name}</span>
|
||||
</span>
|
||||
),
|
||||
value: provider.id,
|
||||
}));
|
||||
|
||||
const renderContext: DrawingParamRenderContext = {
|
||||
settings,
|
||||
providers,
|
||||
modelOptions: paramModelOptions,
|
||||
providerOptions: paramProviderOptions,
|
||||
t: translateOption,
|
||||
getProvidersForModel: (modelId) => getDrawingProvidersForModel(providers, modelId),
|
||||
};
|
||||
|
||||
const visibleBasicFields = basicFields.filter((field) => isFieldVisible(field, renderContext));
|
||||
const visibleAdvancedFields = advancedFields.filter((field) => isFieldVisible(field, renderContext));
|
||||
|
||||
const patch = (next: Partial<DrawingSettings>) => {
|
||||
onChange(normalizeDrawingSettingsByConfig({ ...settings, ...next }));
|
||||
};
|
||||
|
||||
const patchField = (field: DrawingParamField, value: unknown) => {
|
||||
const next = field.normalizeOnChange
|
||||
? field.normalizeOnChange(value, renderContext)
|
||||
: field.key
|
||||
? ({ [field.key]: value } as Partial<DrawingSettings>)
|
||||
: {};
|
||||
patch(next);
|
||||
};
|
||||
|
||||
const renderField = (field: DrawingParamField) => {
|
||||
const label = t(field.labelKey, field.fallbackLabel);
|
||||
switch (field.type) {
|
||||
case 'modelSelect':
|
||||
return (
|
||||
<Form.Item key={field.id} label={label}>
|
||||
<Select
|
||||
value={settings.modelId}
|
||||
options={toSelectOptions(paramModelOptions, translateOption)}
|
||||
placeholder={t('drawing.selectModel', '选择绘图模型')}
|
||||
onChange={(modelId) => patchField(field, modelId)}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
case 'providerSelect':
|
||||
return (
|
||||
<Form.Item key={field.id} label={label}>
|
||||
<Select
|
||||
value={settings.providerId || undefined}
|
||||
placeholder={t('drawing.selectProvider', '选择服务商')}
|
||||
options={providerSelectOptions}
|
||||
optionLabelProp="label"
|
||||
onChange={(providerId) => patchField(field, providerId)}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
case 'select':
|
||||
return (
|
||||
<Form.Item key={field.id} label={label}>
|
||||
<Select
|
||||
value={field.key ? settings[field.key] : undefined}
|
||||
options={toSelectOptions(resolveOptions(field, renderContext), translateOption)}
|
||||
onChange={(value) => patchField(field, value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<Form.Item key={field.id} label={label}>
|
||||
<InputNumber
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
value={field.key ? Number(settings[field.key]) : field.defaultValue}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => patchField(field, value ?? field.defaultValue ?? field.min ?? 0)}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
case 'text':
|
||||
return (
|
||||
<Form.Item key={field.id} label={label}>
|
||||
<Input
|
||||
value={field.key ? String(settings[field.key] ?? '') : ''}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(event) => patchField(field, event.target.value)}
|
||||
/>
|
||||
{field.fallbackHint && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t(field.hintKey ?? field.id, field.fallbackHint)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Form.Item>
|
||||
);
|
||||
case 'compression':
|
||||
return (
|
||||
<Form.Item key={field.id} label={label}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={settings.outputCompression !== undefined}
|
||||
onChange={(checked) => patchField(field, checked ? field.defaultValue ?? 90 : undefined)}
|
||||
/>
|
||||
<Slider
|
||||
min={field.min ?? 0}
|
||||
max={field.max ?? 100}
|
||||
disabled={settings.outputCompression === undefined}
|
||||
value={settings.outputCompression ?? field.defaultValue ?? 90}
|
||||
onChange={(outputCompression) => patchField(field, outputCompression)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
);
|
||||
case 'referenceUploader':
|
||||
return (
|
||||
<div key={field.id}>
|
||||
<Typography.Text style={{ fontSize: 12, color: token.colorTextSecondary }}>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
<div className="mb-4 mt-2">
|
||||
<DrawingReferenceUploader />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="h-full overflow-y-auto"
|
||||
style={{
|
||||
width: 304,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
background: token.colorBgContainer,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
{visibleBasicFields.map(renderField)}
|
||||
</Form>
|
||||
{visibleAdvancedFields.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
aria-expanded={advancedOpen}
|
||||
className="mb-3 flex w-full items-center justify-between transition-colors"
|
||||
style={{
|
||||
height: 44,
|
||||
border: 'none',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
background: 'transparent',
|
||||
color: token.colorText,
|
||||
padding: 0,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span>{t('drawing.advancedSettings', '高级设置')}</span>
|
||||
<span
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 6,
|
||||
background: token.colorFillAlter,
|
||||
color: token.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
{advancedOpen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||
</span>
|
||||
</button>
|
||||
{advancedOpen && (
|
||||
<Form layout="vertical">
|
||||
{visibleAdvancedFields.map(renderField)}
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function isFieldVisible(field: DrawingParamField, context: DrawingParamRenderContext): boolean {
|
||||
return field.visibleWhen ? field.visibleWhen(context) : true;
|
||||
}
|
||||
|
||||
function resolveOptions(
|
||||
field: DrawingParamField,
|
||||
context: DrawingParamRenderContext,
|
||||
): readonly DrawingParamOption[] {
|
||||
if (!field.options) return [];
|
||||
return typeof field.options === 'function' ? field.options(context) : field.options;
|
||||
}
|
||||
|
||||
function toSelectOptions(options: readonly DrawingParamOption[], t: (key: string, fallback: string) => string) {
|
||||
return options.map((option) => ({
|
||||
label: option.labelKey ? t(option.labelKey, option.fallbackLabel) : option.fallbackLabel,
|
||||
value: option.value,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { App } from 'antd';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useDrawingStore } from '@/stores/drawingStore';
|
||||
import type { DrawingImage } from '@/types';
|
||||
import type { DrawingSettings } from '../DrawingSettingsPanel';
|
||||
import { DrawingComposer } from '../DrawingComposer';
|
||||
|
||||
const invokeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/invoke', () => ({
|
||||
invoke: invokeMock,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}));
|
||||
|
||||
function imageFixture(): 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,
|
||||
};
|
||||
}
|
||||
|
||||
const settingsFixture: DrawingSettings = {
|
||||
providerId: 'provider-1',
|
||||
modelId: 'gpt-image-2',
|
||||
size: 'auto',
|
||||
quality: 'auto',
|
||||
outputFormat: 'png',
|
||||
background: 'auto',
|
||||
outputCompression: undefined,
|
||||
referenceImageMode: 'base64',
|
||||
referenceImageFormat: 'object',
|
||||
referenceImageParamName: 'images',
|
||||
n: 1,
|
||||
generationApiPath: '/images/generations',
|
||||
editApiPath: '/images/edits',
|
||||
};
|
||||
|
||||
describe('DrawingComposer', () => {
|
||||
beforeEach(() => {
|
||||
useDrawingStore.setState({
|
||||
references: [],
|
||||
submitting: false,
|
||||
editSourceImage: null,
|
||||
editMaskFileId: null,
|
||||
editMaskFile: null,
|
||||
editPreviewUrl: null,
|
||||
});
|
||||
invokeMock.mockReset();
|
||||
});
|
||||
|
||||
it('submits a pending mask edit through editImageWithMask after the prompt is entered', async () => {
|
||||
const editImage = vi.fn().mockResolvedValue({});
|
||||
const editImageWithMask = vi.fn().mockResolvedValue({});
|
||||
const generateImages = vi.fn().mockResolvedValue({});
|
||||
const onPromptChange = vi.fn();
|
||||
|
||||
useDrawingStore.setState({
|
||||
editSourceImage: imageFixture(),
|
||||
editMaskFileId: 'mask-1',
|
||||
editMaskFile: {
|
||||
id: 'mask-1',
|
||||
original_name: 'mask.png',
|
||||
mime_type: 'image/png',
|
||||
size_bytes: 128,
|
||||
storage_path: 'images/mask.png',
|
||||
},
|
||||
editPreviewUrl: 'data:image/png;base64,masked-preview',
|
||||
editImage,
|
||||
editImageWithMask,
|
||||
generateImages,
|
||||
});
|
||||
|
||||
render(
|
||||
<App>
|
||||
<DrawingComposer
|
||||
settings={settingsFixture}
|
||||
prompt="替换涂抹区域"
|
||||
onPromptChange={onPromptChange}
|
||||
/>
|
||||
</App>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('区域编辑模式')).toBeDefined();
|
||||
expect(screen.getByAltText('编辑预览')).toBeDefined();
|
||||
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('输入你想生成的画面'), {
|
||||
key: 'Enter',
|
||||
shiftKey: false,
|
||||
nativeEvent: { isComposing: false },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editImageWithMask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
source_image_id: 'image-1',
|
||||
mask_file_id: 'mask-1',
|
||||
prompt: '替换涂抹区域',
|
||||
reference_image_mode: 'base64',
|
||||
reference_image_format: 'object',
|
||||
reference_image_param_name: 'images',
|
||||
}));
|
||||
});
|
||||
expect(editImage).not.toHaveBeenCalled();
|
||||
expect(generateImages).not.toHaveBeenCalled();
|
||||
expect(onPromptChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('loads the source image thumbnail for normal edit mode', async () => {
|
||||
invokeMock.mockResolvedValue('data:image/png;base64,source-preview');
|
||||
|
||||
useDrawingStore.setState({
|
||||
editSourceImage: imageFixture(),
|
||||
editMaskFileId: null,
|
||||
editMaskFile: null,
|
||||
editPreviewUrl: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<App>
|
||||
<DrawingComposer
|
||||
settings={settingsFixture}
|
||||
prompt=""
|
||||
onPromptChange={() => {}}
|
||||
/>
|
||||
</App>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText('编辑预览')).toBeDefined();
|
||||
});
|
||||
expect(invokeMock).toHaveBeenCalledWith('read_attachment_preview', { filePath: 'images/drawing.png' });
|
||||
});
|
||||
|
||||
it('reports composer height so the history list can reserve enough bottom space', async () => {
|
||||
const onHeightChange = vi.fn();
|
||||
const rectSpy = vi
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockReturnValue({
|
||||
width: 600,
|
||||
height: 232,
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 600,
|
||||
bottom: 232,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
|
||||
render(
|
||||
<App>
|
||||
<DrawingComposer
|
||||
settings={settingsFixture}
|
||||
prompt=""
|
||||
onPromptChange={() => {}}
|
||||
onHeightChange={onHeightChange}
|
||||
/>
|
||||
</App>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onHeightChange).toHaveBeenCalledWith(232));
|
||||
rectSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,435 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { DrawingGeneration } from '@/types';
|
||||
import { DrawingGenerationItem } from '../DrawingGenerationItem';
|
||||
|
||||
const invokeMock = vi.hoisted(() => vi.fn());
|
||||
const copyChatImageMock = vi.hoisted(() => vi.fn());
|
||||
const saveChatImageMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/invoke', () => ({
|
||||
invoke: invokeMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/chatImageActions', () => ({
|
||||
copyChatImage: copyChatImageMock,
|
||||
saveChatImage: saveChatImageMock,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/common/CopyButton', () => ({
|
||||
CopyButton: ({ className, text }: { className?: string; text: string }) => (
|
||||
<button type="button" className={className} data-copy-text={text}>copy-button</button>
|
||||
),
|
||||
}));
|
||||
|
||||
function generationFixture(overrides: Partial<DrawingGeneration>): DrawingGeneration {
|
||||
return {
|
||||
id: 'generation-1',
|
||||
parent_generation_id: null,
|
||||
provider_id: 'provider-1',
|
||||
key_id: 'key-1',
|
||||
model_id: 'gpt-image-2',
|
||||
api_kind: 'image_api',
|
||||
action: 'generate',
|
||||
prompt: '测试提示词',
|
||||
parameters_json: JSON.stringify({ n: 3, size: '1024x1024' }),
|
||||
reference_file_ids_json: '[]',
|
||||
source_image_ids_json: '[]',
|
||||
mask_file_id: null,
|
||||
status: 'running',
|
||||
error_message: null,
|
||||
response_id: null,
|
||||
usage_json: null,
|
||||
created_at: 1,
|
||||
completed_at: null,
|
||||
images: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DrawingGenerationItem', () => {
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
invokeMock.mockResolvedValue('data:image/png;base64,abc');
|
||||
copyChatImageMock.mockReset();
|
||||
copyChatImageMock.mockResolvedValue(undefined);
|
||||
saveChatImageMock.mockReset();
|
||||
saveChatImageMock.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('renders one shimmer placeholder per requested image while running', () => {
|
||||
const { container } = render(
|
||||
<DrawingGenerationItem
|
||||
generation={generationFixture({ parameters_json: JSON.stringify({ n: 3, size: '1024x1024' }) })}
|
||||
onEdit={() => {}}
|
||||
onMaskEdit={() => {}}
|
||||
onRetry={() => {}}
|
||||
onDelete={() => {}}
|
||||
onUsePrompt={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll('.drawing-image-placeholder')).toHaveLength(3);
|
||||
expect(screen.queryByRole('button', { name: '再次生成' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a confirmed stop action while running', async () => {
|
||||
const onStop = vi.fn();
|
||||
render(
|
||||
<DrawingGenerationItem
|
||||
generation={generationFixture({ parameters_json: JSON.stringify({ n: 2, size: '1024x1024' }) })}
|
||||
onEdit={() => {}}
|
||||
onMaskEdit={() => {}}
|
||||
onRetry={() => {}}
|
||||
onDelete={() => {}}
|
||||
onStop={onStop}
|
||||
onUsePrompt={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '确 认' }));
|
||||
|
||||
expect(onStop).toHaveBeenCalledWith('generation-1');
|
||||
});
|
||||
|
||||
it('shows stopped generations as a warning with retry and direct delete actions', async () => {
|
||||
const onRetry = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
render(
|
||||
<DrawingGenerationItem
|
||||
generation={generationFixture({
|
||||
status: 'stopped',
|
||||
error_message: '主动停止',
|
||||
completed_at: 2,
|
||||
images: [],
|
||||
})}
|
||||
onEdit={() => {}}
|
||||
onMaskEdit={() => {}}
|
||||
onRetry={onRetry}
|
||||
onDelete={onDelete}
|
||||
onStop={() => {}}
|
||||
onUsePrompt={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText('主动停止').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.queryByRole('button', { name: '下载' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '重新编辑' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '区域编辑' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '再次生成' }));
|
||||
expect(onRetry).toHaveBeenCalledWith(expect.objectContaining({ id: 'generation-1' }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除' }));
|
||||
expect(screen.queryByText('仅删除记录')).toBeNull();
|
||||
fireEvent.click(await screen.findByRole('button', { name: '确 认' }));
|
||||
expect(onDelete).toHaveBeenCalledWith('generation-1', false);
|
||||
});
|
||||
|
||||
it('renders source, mask, and reference thumbnails before the prompt', async () => {
|
||||
render(
|
||||
<DrawingGenerationItem
|
||||
generation={generationFixture({
|
||||
status: 'succeeded',
|
||||
source_images: [{
|
||||
id: 'source-image-1',
|
||||
generation_id: 'source-generation',
|
||||
stored_file_id: 'source-file-1',
|
||||
storage_path: 'images/source.png',
|
||||
mime_type: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
revised_prompt: null,
|
||||
created_at: 1,
|
||||
}],
|
||||
mask_file: {
|
||||
id: 'mask-file-1',
|
||||
original_name: 'mask.png',
|
||||
mime_type: 'image/png',
|
||||
size_bytes: 128,
|
||||
storage_path: 'images/mask.png',
|
||||
},
|
||||
reference_files: [{
|
||||
id: 'ref-file-1',
|
||||
original_name: 'ref.png',
|
||||
mime_type: 'image/png',
|
||||
size_bytes: 256,
|
||||
storage_path: 'images/ref.png',
|
||||
}],
|
||||
images: [{
|
||||
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,
|
||||
}],
|
||||
})}
|
||||
onEdit={() => {}}
|
||||
onMaskEdit={() => {}}
|
||||
onRetry={() => {}}
|
||||
onDelete={() => {}}
|
||||
onUsePrompt={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText('原图 1')).toHaveStyle({ width: '32px', height: '32px' });
|
||||
expect(screen.getByAltText('Mask 图')).toHaveStyle({ width: '32px', height: '32px' });
|
||||
expect(screen.getByAltText('参考图 1')).toHaveStyle({ width: '32px', height: '32px' });
|
||||
});
|
||||
expect(invokeMock).toHaveBeenCalledWith('read_attachment_preview', { filePath: 'images/source.png' });
|
||||
expect(invokeMock).toHaveBeenCalledWith('read_attachment_preview', { filePath: 'images/mask.png' });
|
||||
expect(invokeMock).toHaveBeenCalledWith('read_attachment_preview', { filePath: 'images/ref.png' });
|
||||
});
|
||||
|
||||
it('fills the composer prompt when the prompt text is clicked and exposes CopyButton', () => {
|
||||
const onUsePrompt = vi.fn();
|
||||
render(
|
||||
<DrawingGenerationItem
|
||||
generation={generationFixture({ prompt: '点击复制这个提示词' })}
|
||||
onEdit={() => {}}
|
||||
onMaskEdit={() => {}}
|
||||
onRetry={() => {}}
|
||||
onDelete={() => {}}
|
||||
onUsePrompt={onUsePrompt}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '使用提示词' }));
|
||||
|
||||
expect(onUsePrompt).toHaveBeenCalledWith('点击复制这个提示词');
|
||||
expect(document.querySelector('.drawing-prompt-copy')).toHaveAttribute('data-copy-text', '点击复制这个提示词');
|
||||
expect(document.querySelector('.drawing-prompt-trigger .drawing-prompt-copy')).toBeNull();
|
||||
expect(document.querySelector('.drawing-prompt-inline')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders generation parameters as structured metadata chips', () => {
|
||||
render(
|
||||
<DrawingGenerationItem
|
||||
generation={generationFixture({
|
||||
parameters_json: JSON.stringify({
|
||||
n: 4,
|
||||
size: '1024x1024',
|
||||
quality: 'auto',
|
||||
output_format: 'png',
|
||||
background: 'auto',
|
||||
}),
|
||||
})}
|
||||
onEdit={() => {}}
|
||||
onMaskEdit={() => {}}
|
||||
onRetry={() => {}}
|
||||
onDelete={() => {}}
|
||||
onUsePrompt={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('模型')).toBeDefined();
|
||||
expect(screen.getByText('gpt-image-2')).toBeDefined();
|
||||
expect(screen.getByText('尺寸')).toBeDefined();
|
||||
expect(screen.getByText('1:1 | 1K')).toBeDefined();
|
||||
expect(screen.getByText('质量')).toBeDefined();
|
||||
expect(screen.getAllByText('自动').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('格式')).toBeDefined();
|
||||
expect(screen.getByText('PNG')).toBeDefined();
|
||||
expect(screen.getByText('张数')).toBeDefined();
|
||||
expect(screen.getByText('4')).toBeDefined();
|
||||
});
|
||||
|
||||
it('localizes the automatic size value in generation metadata', () => {
|
||||
render(
|
||||
<DrawingGenerationItem
|
||||
generation={generationFixture({
|
||||
parameters_json: JSON.stringify({
|
||||
n: 1,
|
||||
size: 'auto',
|
||||
quality: 'auto',
|
||||
output_format: 'png',
|
||||
background: 'auto',
|
||||
}),
|
||||
})}
|
||||
onEdit={() => {}}
|
||||
onMaskEdit={() => {}}
|
||||
onRetry={() => {}}
|
||||
onDelete={() => {}}
|
||||
onUsePrompt={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('尺寸')).toBeDefined();
|
||||
expect(screen.getAllByText('自动').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.queryByText('auto')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows failed generation errors with a copy action', () => {
|
||||
const onDelete = vi.fn();
|
||||
const { container } = render(
|
||||
<DrawingGenerationItem
|
||||
generation={generationFixture({
|
||||
status: 'failed',
|
||||
error_message: 'OpenAI image API error 400',
|
||||
})}
|
||||
onEdit={() => {}}
|
||||
onMaskEdit={() => {}}
|
||||
onRetry={() => {}}
|
||||
onDelete={onDelete}
|
||||
onUsePrompt={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('OpenAI image API error 400')).toBeDefined();
|
||||
expect(screen.getAllByText('copy-button').length).toBeGreaterThanOrEqual(1);
|
||||
expect(container.querySelector('.drawing-image-placeholder')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除' }));
|
||||
expect(screen.queryByText('仅删除记录')).toBeNull();
|
||||
});
|
||||
|
||||
it('runs bottom image actions directly when there is only one image and exposes both delete modes', async () => {
|
||||
const onDelete = vi.fn();
|
||||
const onUseAsReference = vi.fn();
|
||||
const onEdit = vi.fn();
|
||||
const onMaskEdit = vi.fn();
|
||||
const { container } = render(
|
||||
<DrawingGenerationItem
|
||||
generation={generationFixture({
|
||||
status: 'succeeded',
|
||||
images: [{
|
||||
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,
|
||||
}],
|
||||
})}
|
||||
onEdit={onEdit}
|
||||
onMaskEdit={onMaskEdit}
|
||||
onRetry={() => {}}
|
||||
onDelete={onDelete}
|
||||
onUsePrompt={() => {}}
|
||||
onUseAsReference={onUseAsReference}
|
||||
/>,
|
||||
);
|
||||
|
||||
const actionButtons = Array.from(container.querySelectorAll('.mt-4 .ant-btn'));
|
||||
expect(container.querySelectorAll('.mt-4 .ant-btn-variant-filled')).toHaveLength(6);
|
||||
expect(container.querySelectorAll('.mt-4 .ant-btn-sm')).toHaveLength(6);
|
||||
expect(actionButtons.every((button) => button.textContent === '')).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '作为参考图' }));
|
||||
expect(onUseAsReference).toHaveBeenCalledWith(expect.objectContaining({ id: 'image-1' }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新编辑' }));
|
||||
expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 'image-1' }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '区域编辑' }));
|
||||
expect(onMaskEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 'image-1' }));
|
||||
expect(screen.queryByText('图1')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除' }));
|
||||
fireEvent.click(await screen.findByText('仅删除记录'));
|
||||
expect(onDelete).toHaveBeenCalledWith('generation-1', false);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除' }));
|
||||
fireEvent.click(await screen.findByText('全部删除'));
|
||||
expect(onDelete).toHaveBeenCalledWith('generation-1', true);
|
||||
});
|
||||
|
||||
it('uses image dropdowns for bottom image actions when there are multiple images', async () => {
|
||||
const onEdit = vi.fn();
|
||||
render(
|
||||
<DrawingGenerationItem
|
||||
generation={generationFixture({
|
||||
status: 'succeeded',
|
||||
images: [
|
||||
{
|
||||
id: 'image-1',
|
||||
generation_id: 'generation-1',
|
||||
stored_file_id: 'file-1',
|
||||
storage_path: 'images/one.png',
|
||||
mime_type: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
revised_prompt: null,
|
||||
created_at: 1,
|
||||
},
|
||||
{
|
||||
id: 'image-2',
|
||||
generation_id: 'generation-1',
|
||||
stored_file_id: 'file-2',
|
||||
storage_path: 'images/two.png',
|
||||
mime_type: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
revised_prompt: null,
|
||||
created_at: 2,
|
||||
},
|
||||
],
|
||||
})}
|
||||
onEdit={onEdit}
|
||||
onMaskEdit={() => {}}
|
||||
onRetry={() => {}}
|
||||
onDelete={() => {}}
|
||||
onUsePrompt={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新编辑' }));
|
||||
fireEvent.click(await screen.findByText('图2'));
|
||||
|
||||
expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 'image-2' }));
|
||||
});
|
||||
|
||||
it('moves image actions to the bottom download dropdown', async () => {
|
||||
render(
|
||||
<DrawingGenerationItem
|
||||
generation={generationFixture({
|
||||
status: 'succeeded',
|
||||
images: [{
|
||||
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,
|
||||
}],
|
||||
})}
|
||||
onEdit={() => {}}
|
||||
onMaskEdit={() => {}}
|
||||
onRetry={() => {}}
|
||||
onDelete={() => {}}
|
||||
onUsePrompt={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载' }));
|
||||
fireEvent.click(await screen.findByText('打开原图目录'));
|
||||
expect(invokeMock).toHaveBeenCalledWith('reveal_attachment_file', { filePath: 'images/drawing.png' });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载' }));
|
||||
fireEvent.click(await screen.findByText('另存为'));
|
||||
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith('read_attachment_preview', { filePath: 'images/drawing.png' }));
|
||||
await waitFor(() => expect(saveChatImageMock).toHaveBeenCalledWith('data:image/png;base64,abc', 'drawing.png'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载' }));
|
||||
fireEvent.click(await screen.findByText('复制到剪切板'));
|
||||
await waitFor(() => expect(copyChatImageMock).toHaveBeenCalledWith('data:image/png;base64,abc'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { DrawingImage } from '@/types';
|
||||
import { DrawingImageStrip } from '../DrawingImageStrip';
|
||||
|
||||
const invokeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/invoke', () => ({
|
||||
invoke: invokeMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/chatImageActions', () => ({
|
||||
saveChatImage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}));
|
||||
|
||||
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: 1536,
|
||||
revised_prompt: null,
|
||||
created_at: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DrawingImageStrip', () => {
|
||||
let intersectionCallback: IntersectionObserverCallback | null = null;
|
||||
let originalIntersectionObserver: typeof IntersectionObserver | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
invokeMock.mockResolvedValue('data:image/png;base64,abc');
|
||||
originalIntersectionObserver = globalThis.IntersectionObserver;
|
||||
|
||||
class MockIntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = '0px';
|
||||
readonly thresholds = [];
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
intersectionCallback = callback;
|
||||
}
|
||||
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.IntersectionObserver = MockIntersectionObserver as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.IntersectionObserver = originalIntersectionObserver as typeof IntersectionObserver;
|
||||
intersectionCallback = null;
|
||||
});
|
||||
|
||||
it('does not read image previews until the image tile enters the viewport', async () => {
|
||||
const { container } = render(
|
||||
<DrawingImageStrip
|
||||
images={[imageFixture()]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(invokeMock).not.toHaveBeenCalled();
|
||||
|
||||
const tile = container.querySelector('.drawing-preview-tile');
|
||||
expect(tile).toBeTruthy();
|
||||
act(() => {
|
||||
intersectionCallback?.([
|
||||
{ isIntersecting: true, target: tile } as IntersectionObserverEntry,
|
||||
], {} as IntersectionObserver);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith('read_attachment_preview', {
|
||||
filePath: 'images/drawing.png',
|
||||
}));
|
||||
});
|
||||
|
||||
it('sizes the tile to the image ratio so there is no outer frame around the image', async () => {
|
||||
const { container } = render(
|
||||
<DrawingImageStrip
|
||||
images={[imageFixture()]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tile = container.querySelector('.drawing-preview-tile');
|
||||
act(() => {
|
||||
intersectionCallback?.([
|
||||
{ isIntersecting: true, target: tile } as IntersectionObserverEntry,
|
||||
], {} as IntersectionObserver);
|
||||
});
|
||||
|
||||
const image = await screen.findByRole('img');
|
||||
const strip = container.querySelector('.drawing-image-strip');
|
||||
|
||||
expect(image).toHaveStyle({ objectFit: 'contain' });
|
||||
expect(strip).toHaveClass('overflow-x-auto');
|
||||
expect(strip).toHaveClass('w-full');
|
||||
expect(image).toHaveAttribute('loading', 'lazy');
|
||||
expect(tile).toHaveStyle({
|
||||
width: '180px',
|
||||
height: '270px',
|
||||
borderRadius: '6px',
|
||||
background: 'transparent',
|
||||
});
|
||||
expect(container.querySelector('.drawing-image-actions')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows hover actions for reference, edit, and mask edit on each image', async () => {
|
||||
const onUseAsReference = vi.fn();
|
||||
const onEdit = vi.fn();
|
||||
const onMaskEdit = vi.fn();
|
||||
const { container } = render(
|
||||
<DrawingImageStrip
|
||||
images={[imageFixture()]}
|
||||
onUseAsReference={onUseAsReference}
|
||||
onEdit={onEdit}
|
||||
onMaskEdit={onMaskEdit}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tile = container.querySelector('.drawing-preview-tile');
|
||||
act(() => {
|
||||
intersectionCallback?.([
|
||||
{ isIntersecting: true, target: tile } as IntersectionObserverEntry,
|
||||
], {} as IntersectionObserver);
|
||||
});
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '作为参考图' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新编辑' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '区域编辑' }));
|
||||
|
||||
expect(container.querySelector('.drawing-image-hover-actions')).toBeTruthy();
|
||||
expect(onUseAsReference).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 'image-1',
|
||||
storage_path: 'images/drawing.png',
|
||||
}));
|
||||
expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 'image-1' }));
|
||||
expect(onMaskEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 'image-1' }));
|
||||
});
|
||||
|
||||
it('uses a wider four-pixel gap between batch images', () => {
|
||||
const { container } = render(
|
||||
<DrawingImageStrip
|
||||
images={[
|
||||
imageFixture({ id: 'image-1', storage_path: 'images/one.png' }),
|
||||
imageFixture({ id: 'image-2', storage_path: 'images/two.png' }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const strip = container.querySelector('.drawing-image-strip');
|
||||
expect(strip).toHaveStyle({ gap: '7px' });
|
||||
});
|
||||
|
||||
it('renders shimmer placeholders without the old bottom progress bar', () => {
|
||||
const { container } = render(
|
||||
<DrawingImageStrip
|
||||
images={[]}
|
||||
loading
|
||||
placeholderCount={1}
|
||||
/>,
|
||||
);
|
||||
|
||||
const placeholder = container.querySelector('.drawing-image-placeholder');
|
||||
expect(placeholder).toHaveStyle({
|
||||
width: '180px',
|
||||
height: '300px',
|
||||
});
|
||||
expect(placeholder?.querySelectorAll(':scope > div')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { App } from 'antd';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { DrawingImage } from '@/types';
|
||||
import {
|
||||
DrawingMaskEditor,
|
||||
getEraserFeedbackStyle,
|
||||
getMaskBrushStyle,
|
||||
overlayAlphaToMaskAlpha,
|
||||
} from '../DrawingMaskEditor';
|
||||
|
||||
const invokeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/invoke', () => ({
|
||||
invoke: invokeMock,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}));
|
||||
|
||||
function imageFixture(): 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,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DrawingMaskEditor', () => {
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
});
|
||||
|
||||
it('keeps the modal and image editor within the viewport margins', () => {
|
||||
invokeMock.mockResolvedValue('data:image/png;base64,abc');
|
||||
|
||||
render(
|
||||
<App>
|
||||
<DrawingMaskEditor
|
||||
open
|
||||
image={imageFixture()}
|
||||
onApply={() => {}}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</App>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.ant-modal')).toHaveStyle({
|
||||
top: '24px',
|
||||
maxWidth: 'calc(100vw - 48px)',
|
||||
});
|
||||
expect(document.querySelector('.ant-modal-body')).toHaveStyle({
|
||||
maxHeight: 'calc(100vh - 160px)',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
expect(screen.getByTestId('drawing-mask-editor-surface')).toHaveStyle({
|
||||
maxHeight: 'calc(100vh - 220px)',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the theme color for painted mask pixels and keeps eraser removal fully opaque', () => {
|
||||
expect(getMaskBrushStyle(false)).toEqual({
|
||||
compositeOperation: 'source-over',
|
||||
fillStyle: '#1677ff',
|
||||
globalAlpha: 0.42,
|
||||
});
|
||||
expect(getMaskBrushStyle(false, '#22c55e')).toEqual({
|
||||
compositeOperation: 'source-over',
|
||||
fillStyle: '#22c55e',
|
||||
globalAlpha: 0.42,
|
||||
});
|
||||
expect(getMaskBrushStyle(true, '#22c55e')).toEqual({
|
||||
compositeOperation: 'destination-out',
|
||||
fillStyle: '#000000',
|
||||
globalAlpha: 1,
|
||||
});
|
||||
expect(getEraserFeedbackStyle('#22c55e')).toEqual({
|
||||
compositeOperation: 'source-over',
|
||||
fillStyle: '#22c55e',
|
||||
globalAlpha: 0.24,
|
||||
});
|
||||
});
|
||||
|
||||
it('exports painted overlay pixels as transparent mask pixels and erased pixels as opaque mask pixels', () => {
|
||||
expect(overlayAlphaToMaskAlpha(0)).toBe(255);
|
||||
expect(overlayAlphaToMaskAlpha(1)).toBe(0);
|
||||
expect(overlayAlphaToMaskAlpha(107)).toBe(0);
|
||||
expect(overlayAlphaToMaskAlpha(255)).toBe(0);
|
||||
});
|
||||
|
||||
it('uploads the mask and hands it back without generating immediately', 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, 0, 0, 0, 0]),
|
||||
})),
|
||||
createImageData: vi.fn(() => ({
|
||||
data: new Uint8ClampedArray(8),
|
||||
})),
|
||||
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');
|
||||
const onApply = vi.fn();
|
||||
invokeMock.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'upload_drawing_reference') {
|
||||
return Promise.resolve({
|
||||
id: 'mask-1',
|
||||
original_name: 'mask-image-1.png',
|
||||
mime_type: 'image/png',
|
||||
size_bytes: 128,
|
||||
storage_path: 'images/mask-image-1.png',
|
||||
});
|
||||
}
|
||||
return Promise.resolve('data:image/png;base64,abc');
|
||||
});
|
||||
|
||||
render(
|
||||
<App>
|
||||
<DrawingMaskEditor
|
||||
open
|
||||
image={imageFixture()}
|
||||
onApply={onApply}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</App>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交区域编辑' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onApply).toHaveBeenCalled();
|
||||
});
|
||||
expect(onApply.mock.calls[0]?.[0]).toEqual(imageFixture());
|
||||
expect(onApply.mock.calls[0]?.[1]).toMatchObject({
|
||||
id: 'mask-1',
|
||||
storage_path: 'images/mask-image-1.png',
|
||||
});
|
||||
expect(invokeMock).toHaveBeenCalledWith('upload_drawing_reference', {
|
||||
input: {
|
||||
data: 'mask-data',
|
||||
file_name: 'mask-image-1.png',
|
||||
mime_type: 'image/png',
|
||||
},
|
||||
});
|
||||
expect(invokeMock).not.toHaveBeenCalledWith('edit_drawing_image_with_mask', expect.anything());
|
||||
|
||||
getContextSpy.mockRestore();
|
||||
toDataUrlSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { DrawingSettings, ProviderConfig } from '@/types';
|
||||
import { DrawingSettingsPanel } from '../DrawingSettingsPanel';
|
||||
|
||||
vi.mock('@/lib/providerIcons', () => ({
|
||||
SmartProviderIcon: () => <span>provider-icon</span>,
|
||||
}));
|
||||
|
||||
vi.mock('../DrawingReferenceUploader', () => ({
|
||||
DrawingReferenceUploader: () => <div data-testid="drawing-reference-uploader">上传参考图</div>,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const settingsFixture: DrawingSettings = {
|
||||
providerId: 'provider-1',
|
||||
modelId: 'gpt-image-2',
|
||||
size: 'auto',
|
||||
quality: 'auto',
|
||||
outputFormat: 'png',
|
||||
background: 'auto',
|
||||
outputCompression: undefined,
|
||||
referenceImageMode: 'base64',
|
||||
referenceImageFormat: 'object',
|
||||
referenceImageParamName: 'images',
|
||||
n: 1,
|
||||
generationApiPath: '/images/generations',
|
||||
editApiPath: '/images/edits',
|
||||
};
|
||||
|
||||
const providersFixture: ProviderConfig[] = [{
|
||||
id: 'provider-1',
|
||||
name: 'OpenAI',
|
||||
provider_type: 'openai',
|
||||
api_host: 'https://api.openai.com',
|
||||
api_path: null,
|
||||
enabled: true,
|
||||
models: [{
|
||||
provider_id: 'provider-1',
|
||||
model_id: 'gpt-image-2',
|
||||
name: 'gpt-image-2',
|
||||
model_type: 'Image',
|
||||
capabilities: [],
|
||||
max_tokens: null,
|
||||
enabled: true,
|
||||
param_overrides: null,
|
||||
}],
|
||||
keys: [],
|
||||
proxy_config: null,
|
||||
custom_headers: null,
|
||||
icon: null,
|
||||
builtin_id: null,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
}];
|
||||
|
||||
describe('DrawingSettingsPanel', () => {
|
||||
it('keeps basic controls and references outside the advanced section', () => {
|
||||
render(
|
||||
<DrawingSettingsPanel
|
||||
settings={settingsFixture}
|
||||
providers={providersFixture}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('基础设置')).toBeNull();
|
||||
expect(screen.getByText('模型')).toBeDefined();
|
||||
expect(screen.getByText('Provider')).toBeDefined();
|
||||
expect(screen.getByText('批量张数')).toBeDefined();
|
||||
expect(screen.getByTestId('drawing-reference-uploader')).toBeDefined();
|
||||
expect(screen.queryByText('生图接口')).toBeNull();
|
||||
|
||||
const referenceLabel = screen.getByText('参考图');
|
||||
const advancedHeader = screen.getByText('高级设置');
|
||||
expect(
|
||||
referenceLabel.compareDocumentPosition(advancedHeader)
|
||||
& Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
|
||||
const advancedButton = screen.getByRole('button', { name: '高级设置' });
|
||||
expect(advancedButton).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
fireEvent.click(advancedButton);
|
||||
|
||||
expect(advancedButton).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText('生图接口')).toBeDefined();
|
||||
expect(screen.queryByText('压缩')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import type {
|
||||
DrawingBackground,
|
||||
DrawingModelId,
|
||||
DrawingOutputFormat,
|
||||
DrawingQuality,
|
||||
DrawingReferenceImageFormat,
|
||||
DrawingReferenceImageMode,
|
||||
DrawingSettings,
|
||||
} from '@/types';
|
||||
import type {
|
||||
DrawingParamConfig,
|
||||
DrawingParamField,
|
||||
DrawingParamOption,
|
||||
DrawingTranslate,
|
||||
} from './types';
|
||||
|
||||
export const GPT_IMAGE_MODELS: Array<{ id: DrawingModelId; name: string }> = [
|
||||
{ id: 'gpt-image-2', name: 'gpt-image-2' },
|
||||
{ id: 'gpt-image-1.5', name: 'gpt-image-1.5' },
|
||||
{ id: 'gpt-image-1', name: 'gpt-image-1' },
|
||||
{ id: 'gpt-image-1-mini', name: 'gpt-image-1-mini' },
|
||||
];
|
||||
|
||||
export const GPT_IMAGE_SIZE_OPTIONS = [
|
||||
'auto',
|
||||
'1024x1024',
|
||||
'1536x1024',
|
||||
'1024x1536',
|
||||
'2048x2048',
|
||||
'2048x1152',
|
||||
'3840x2160',
|
||||
];
|
||||
|
||||
export const GPT_IMAGE_REFERENCE_IMAGE_MODES: DrawingReferenceImageMode[] = ['multipart', 'base64'];
|
||||
|
||||
const QUALITY_OPTIONS: Array<DrawingParamOption & { value: DrawingQuality }> = [
|
||||
{ labelKey: 'drawing.option.auto', fallbackLabel: 'Auto', value: 'auto' },
|
||||
{ labelKey: 'drawing.option.quality.low', fallbackLabel: 'Low', value: 'low' },
|
||||
{ labelKey: 'drawing.option.quality.medium', fallbackLabel: 'Medium', value: 'medium' },
|
||||
{ labelKey: 'drawing.option.quality.high', fallbackLabel: 'High', value: 'high' },
|
||||
];
|
||||
|
||||
const OUTPUT_FORMAT_OPTIONS: Array<DrawingParamOption & { value: DrawingOutputFormat }> = [
|
||||
{ labelKey: 'drawing.option.outputFormat.png', fallbackLabel: 'PNG', value: 'png' },
|
||||
{ labelKey: 'drawing.option.outputFormat.jpeg', fallbackLabel: 'JPEG', value: 'jpeg' },
|
||||
{ labelKey: 'drawing.option.outputFormat.webp', fallbackLabel: 'WEBP', value: 'webp' },
|
||||
];
|
||||
|
||||
const BACKGROUND_OPTIONS: Array<DrawingParamOption & { value: DrawingBackground }> = [
|
||||
{ labelKey: 'drawing.option.auto', fallbackLabel: 'Auto', value: 'auto' },
|
||||
{ labelKey: 'drawing.option.background.opaque', fallbackLabel: 'Opaque', value: 'opaque' },
|
||||
{ labelKey: 'drawing.option.background.transparent', fallbackLabel: 'Transparent', value: 'transparent' },
|
||||
];
|
||||
|
||||
const REFERENCE_IMAGE_MODE_OPTIONS: Array<DrawingParamOption & { value: DrawingReferenceImageMode }> = [
|
||||
{ labelKey: 'drawing.option.referenceImageMode.multipart', fallbackLabel: 'Multipart', value: 'multipart' },
|
||||
{ labelKey: 'drawing.option.referenceImageMode.base64', fallbackLabel: 'Base64', value: 'base64' },
|
||||
];
|
||||
|
||||
const REFERENCE_IMAGE_FORMAT_OPTIONS: Array<DrawingParamOption & { value: DrawingReferenceImageFormat }> = [
|
||||
{ labelKey: 'drawing.referenceImageFormat.object', fallbackLabel: '对象数组', value: 'object' },
|
||||
{ labelKey: 'drawing.referenceImageFormat.string', fallbackLabel: '字符串数组', value: 'string' },
|
||||
];
|
||||
|
||||
export function isGptImageTransparentBackgroundSupported(modelId?: DrawingModelId): boolean {
|
||||
return modelId !== 'gpt-image-2';
|
||||
}
|
||||
|
||||
export function isGptImageOutputCompressionSupported(
|
||||
modelId: DrawingModelId,
|
||||
outputFormat: DrawingOutputFormat,
|
||||
): boolean {
|
||||
return modelId !== 'gpt-image-2' && (outputFormat === 'jpeg' || outputFormat === 'webp');
|
||||
}
|
||||
|
||||
export function getGptImageSizeOptions(t: DrawingTranslate): DrawingParamOption[] {
|
||||
return GPT_IMAGE_SIZE_OPTIONS.map((size) => ({
|
||||
fallbackLabel: size === 'auto' ? t('drawing.option.auto', 'Auto') : size,
|
||||
value: size,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getGptImageQualityOptions(t: DrawingTranslate): DrawingParamOption[] {
|
||||
return localizeOptions(QUALITY_OPTIONS, t);
|
||||
}
|
||||
|
||||
export function getGptImageOutputFormatOptions(t: DrawingTranslate): DrawingParamOption[] {
|
||||
return localizeOptions(OUTPUT_FORMAT_OPTIONS, t);
|
||||
}
|
||||
|
||||
export function getGptImageBackgroundOptions(
|
||||
t: DrawingTranslate,
|
||||
modelId?: DrawingModelId,
|
||||
): DrawingParamOption[] {
|
||||
const options = isGptImageTransparentBackgroundSupported(modelId)
|
||||
? BACKGROUND_OPTIONS
|
||||
: BACKGROUND_OPTIONS.filter((option) => option.value !== 'transparent');
|
||||
return localizeOptions(options, t);
|
||||
}
|
||||
|
||||
export function getGptImageReferenceImageModeOptions(t: DrawingTranslate): DrawingParamOption[] {
|
||||
return localizeOptions(REFERENCE_IMAGE_MODE_OPTIONS, t);
|
||||
}
|
||||
|
||||
export function getGptImageReferenceImageFormatOptions(t: DrawingTranslate): DrawingParamOption[] {
|
||||
return localizeOptions(REFERENCE_IMAGE_FORMAT_OPTIONS, t);
|
||||
}
|
||||
|
||||
function localizeOptions(
|
||||
options: readonly DrawingParamOption[],
|
||||
t: DrawingTranslate,
|
||||
): DrawingParamOption[] {
|
||||
return options.map((option) => ({
|
||||
...option,
|
||||
fallbackLabel: option.labelKey
|
||||
? t(option.labelKey, option.fallbackLabel)
|
||||
: option.fallbackLabel,
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeGptImageSettings(settings: DrawingSettings): DrawingSettings {
|
||||
return {
|
||||
...settings,
|
||||
background: isGptImageTransparentBackgroundSupported(settings.modelId) || settings.background !== 'transparent'
|
||||
? settings.background
|
||||
: 'auto',
|
||||
outputCompression: isGptImageOutputCompressionSupported(settings.modelId, settings.outputFormat)
|
||||
? settings.outputCompression
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const basicFields: DrawingParamField[] = [
|
||||
{
|
||||
id: 'model',
|
||||
key: 'modelId',
|
||||
type: 'modelSelect',
|
||||
labelKey: 'drawing.model',
|
||||
fallbackLabel: '模型',
|
||||
normalizeOnChange: (value, context) => {
|
||||
const modelId = value as DrawingModelId;
|
||||
const nextProviders = context.getProvidersForModel(modelId);
|
||||
const providerId = nextProviders.some((provider) => provider.id === context.settings.providerId)
|
||||
? context.settings.providerId
|
||||
: nextProviders[0]?.id ?? '';
|
||||
return { modelId, providerId };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'provider',
|
||||
key: 'providerId',
|
||||
type: 'providerSelect',
|
||||
labelKey: 'drawing.provider',
|
||||
fallbackLabel: 'Provider',
|
||||
},
|
||||
{
|
||||
id: 'size',
|
||||
key: 'size',
|
||||
type: 'select',
|
||||
labelKey: 'drawing.size',
|
||||
fallbackLabel: '尺寸',
|
||||
options: (context) => getGptImageSizeOptions(context.t),
|
||||
},
|
||||
{
|
||||
id: 'quality',
|
||||
key: 'quality',
|
||||
type: 'select',
|
||||
labelKey: 'drawing.quality',
|
||||
fallbackLabel: '质量',
|
||||
options: (context) => getGptImageQualityOptions(context.t),
|
||||
},
|
||||
{
|
||||
id: 'outputFormat',
|
||||
key: 'outputFormat',
|
||||
type: 'select',
|
||||
labelKey: 'drawing.outputFormat',
|
||||
fallbackLabel: '输出格式',
|
||||
options: (context) => getGptImageOutputFormatOptions(context.t),
|
||||
},
|
||||
{
|
||||
id: 'background',
|
||||
key: 'background',
|
||||
type: 'select',
|
||||
labelKey: 'drawing.background',
|
||||
fallbackLabel: '背景',
|
||||
options: (context) => getGptImageBackgroundOptions(context.t, context.settings.modelId),
|
||||
},
|
||||
{
|
||||
id: 'batchCount',
|
||||
key: 'n',
|
||||
type: 'number',
|
||||
labelKey: 'drawing.batchCount',
|
||||
fallbackLabel: '批量张数',
|
||||
min: 1,
|
||||
max: 10,
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
id: 'references',
|
||||
type: 'referenceUploader',
|
||||
labelKey: 'drawing.references',
|
||||
fallbackLabel: '参考图',
|
||||
},
|
||||
];
|
||||
|
||||
const advancedFields: DrawingParamField[] = [
|
||||
{
|
||||
id: 'generationApiPath',
|
||||
key: 'generationApiPath',
|
||||
type: 'text',
|
||||
labelKey: 'drawing.generationApiPath',
|
||||
fallbackLabel: '生图接口',
|
||||
placeholder: '/images/generations',
|
||||
},
|
||||
{
|
||||
id: 'editApiPath',
|
||||
key: 'editApiPath',
|
||||
type: 'text',
|
||||
labelKey: 'drawing.editApiPath',
|
||||
fallbackLabel: '编辑接口',
|
||||
placeholder: '/images/edits',
|
||||
},
|
||||
{
|
||||
id: 'referenceImageMode',
|
||||
key: 'referenceImageMode',
|
||||
type: 'select',
|
||||
labelKey: 'drawing.referenceImageMode',
|
||||
fallbackLabel: '参考图发送方式',
|
||||
options: (context) => getGptImageReferenceImageModeOptions(context.t),
|
||||
},
|
||||
{
|
||||
id: 'referenceImageFormat',
|
||||
key: 'referenceImageFormat',
|
||||
type: 'select',
|
||||
labelKey: 'drawing.referenceImageFormat',
|
||||
fallbackLabel: '参考图数据格式',
|
||||
options: (context) => getGptImageReferenceImageFormatOptions(context.t),
|
||||
},
|
||||
{
|
||||
id: 'referenceImageParamName',
|
||||
key: 'referenceImageParamName',
|
||||
type: 'text',
|
||||
labelKey: 'drawing.referenceImageParamName',
|
||||
fallbackLabel: '第三方图片参数名',
|
||||
placeholder: 'images',
|
||||
hintKey: 'drawing.referenceImageParamName.hint',
|
||||
fallbackHint: '仅用于第三方兼容接口;官方 OpenAI 会自动使用 images/image[]',
|
||||
},
|
||||
{
|
||||
id: 'compression',
|
||||
key: 'outputCompression',
|
||||
type: 'compression',
|
||||
labelKey: 'drawing.compression',
|
||||
fallbackLabel: '压缩',
|
||||
min: 0,
|
||||
max: 100,
|
||||
defaultValue: 90,
|
||||
visibleWhen: (context) => isGptImageOutputCompressionSupported(
|
||||
context.settings.modelId,
|
||||
context.settings.outputFormat,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export const GPT_IMAGE_PARAM_CONFIG: DrawingParamConfig = {
|
||||
id: 'gpt-image',
|
||||
modelIds: GPT_IMAGE_MODELS.map((model) => model.id),
|
||||
groups: [
|
||||
{ id: 'basic', fields: basicFields },
|
||||
{ id: 'advanced', fields: advancedFields },
|
||||
],
|
||||
normalizeSettings: normalizeGptImageSettings,
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { DrawingModelId, DrawingSettings, ProviderConfig } from '@/types';
|
||||
|
||||
export type DrawingParamFieldType =
|
||||
| 'select'
|
||||
| 'number'
|
||||
| 'text'
|
||||
| 'compression'
|
||||
| 'referenceUploader'
|
||||
| 'providerSelect'
|
||||
| 'modelSelect';
|
||||
|
||||
export type DrawingParamGroupId = 'basic' | 'advanced';
|
||||
export type DrawingParamSettingKey = keyof DrawingSettings;
|
||||
export type DrawingParamOptionValue = string | number;
|
||||
export type DrawingTranslate = (key: string, fallback: string) => string;
|
||||
|
||||
export interface DrawingParamOption {
|
||||
labelKey?: string;
|
||||
fallbackLabel: string;
|
||||
value: DrawingParamOptionValue;
|
||||
}
|
||||
|
||||
export interface DrawingParamRenderContext {
|
||||
settings: DrawingSettings;
|
||||
providers: ProviderConfig[];
|
||||
modelOptions: DrawingParamOption[];
|
||||
providerOptions: DrawingParamOption[];
|
||||
t: DrawingTranslate;
|
||||
getProvidersForModel: (modelId: DrawingModelId) => ProviderConfig[];
|
||||
}
|
||||
|
||||
export type DrawingParamOptionSource =
|
||||
| readonly DrawingParamOption[]
|
||||
| ((context: DrawingParamRenderContext) => readonly DrawingParamOption[]);
|
||||
|
||||
export interface DrawingParamField {
|
||||
id: string;
|
||||
key?: DrawingParamSettingKey;
|
||||
type: DrawingParamFieldType;
|
||||
labelKey: string;
|
||||
fallbackLabel: string;
|
||||
placeholder?: string;
|
||||
hintKey?: string;
|
||||
fallbackHint?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
defaultValue?: number;
|
||||
options?: DrawingParamOptionSource;
|
||||
visibleWhen?: (context: DrawingParamRenderContext) => boolean;
|
||||
normalizeOnChange?: (
|
||||
value: unknown,
|
||||
context: DrawingParamRenderContext,
|
||||
) => Partial<DrawingSettings>;
|
||||
}
|
||||
|
||||
export interface DrawingParamGroup {
|
||||
id: DrawingParamGroupId;
|
||||
fields: readonly DrawingParamField[];
|
||||
}
|
||||
|
||||
export interface DrawingParamConfig {
|
||||
id: string;
|
||||
modelIds: readonly DrawingModelId[];
|
||||
groups: readonly DrawingParamGroup[];
|
||||
normalizeSettings?: (settings: DrawingSettings) => DrawingSettings;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/** FileList renders file rows in an antd Table with built-in multi-column sorting. */
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Empty, Image, Popconfirm, Table, Tag, theme } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { FolderOpen, Image as ImageIcon, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { invoke } from '@/lib/invoke';
|
||||
import type { FileCategory, FileRow } from '@/types';
|
||||
|
||||
interface FileListProps {
|
||||
rows?: FileRow[];
|
||||
category?: FileCategory;
|
||||
selectedRowKeys?: string[];
|
||||
onSelectionChange?: (keys: string[]) => void;
|
||||
onReveal?: (path: string) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string {
|
||||
if (bytes == null) return '—';
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** Load base64 data URL for an image thumbnail via Rust command. */
|
||||
function useThumbnailSrc(storagePath: string | undefined, missing: boolean | undefined): string | undefined {
|
||||
const [src, setSrc] = useState<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!storagePath || missing) return;
|
||||
let cancelled = false;
|
||||
invoke<string>('read_attachment_preview', { filePath: storagePath })
|
||||
.then((dataUrl) => { if (!cancelled) setSrc(dataUrl); })
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [storagePath, missing]);
|
||||
return src;
|
||||
}
|
||||
|
||||
function ImageThumbnail({ record }: { record: FileRow }) {
|
||||
const { token } = theme.useToken();
|
||||
// Extract relative storage path from the full resolved path — use the row's raw storage_path if available
|
||||
const src = useThumbnailSrc(record.storagePath, record.missing);
|
||||
return (
|
||||
<div
|
||||
className="h-10 w-10 overflow-hidden rounded-md border flex items-center justify-center"
|
||||
style={{
|
||||
borderColor: token.colorBorderSecondary,
|
||||
backgroundColor: src ? token.colorBgContainer : token.colorFillQuaternary,
|
||||
}}
|
||||
>
|
||||
{src ? (
|
||||
<Image
|
||||
src={src}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
style={{ objectFit: 'cover' }}
|
||||
preview={{ mask: { blur: true }, scaleStep: 0.5 }}
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon size={16} style={{ color: token.colorTextSecondary }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileList({ rows = [], category, selectedRowKeys = [], onSelectionChange, onReveal, onDelete }: FileListProps) {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useTranslation();
|
||||
const showThumbnails = category === 'images';
|
||||
|
||||
const columns: ColumnsType<FileRow> = [];
|
||||
|
||||
if (showThumbnails) {
|
||||
columns.push({
|
||||
title: '',
|
||||
key: 'thumbnail',
|
||||
width: 56,
|
||||
render: (_: unknown, record: FileRow) => <ImageThumbnail record={record} />,
|
||||
});
|
||||
}
|
||||
|
||||
columns.push(
|
||||
{
|
||||
title: t('files.columnName'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
sorter: { compare: (a, b) => a.name.localeCompare(b.name), multiple: 1 },
|
||||
ellipsis: true,
|
||||
render: (name: string) => (
|
||||
<span className="text-sm font-medium" title={name}>{name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('files.columnSize'),
|
||||
dataIndex: 'size',
|
||||
key: 'size',
|
||||
width: 100,
|
||||
sorter: { compare: (a, b) => (a.size ?? 0) - (b.size ?? 0), multiple: 2 },
|
||||
render: (size: number | undefined) => (
|
||||
<span className="text-xs" style={{ color: token.colorTextSecondary }}>{formatSize(size)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('files.columnCreatedAt'),
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
sorter: { compare: (a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''), multiple: 3 },
|
||||
defaultSortOrder: 'descend',
|
||||
render: (date: string | undefined) => (
|
||||
<span className="text-xs" style={{ color: token.colorTextSecondary }}>{date ?? '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('files.columnActions'),
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render: (_: unknown, record: FileRow) => {
|
||||
if (record.missing) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag color="error" bordered={false}>{t('files.missing')}</Tag>
|
||||
{onDelete && (
|
||||
<Popconfirm
|
||||
title={t('files.deleteConfirm')}
|
||||
onConfirm={() => onDelete(record.id)}
|
||||
okText={t('files.confirmYes')}
|
||||
cancelText={t('files.confirmNo')}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<Trash2 size={14} />}
|
||||
aria-label={`${t('files.delete')} ${record.name}`}
|
||||
>
|
||||
{t('files.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{onReveal && record.path && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<FolderOpen size={14} />}
|
||||
onClick={() => onReveal(record.path)}
|
||||
aria-label={`${t('files.open')} ${record.name}`}
|
||||
>
|
||||
{t('files.open')}
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Popconfirm
|
||||
title={t('files.deleteConfirm')}
|
||||
onConfirm={() => onDelete(record.id)}
|
||||
okText={t('files.confirmYes')}
|
||||
cancelText={t('files.confirmNo')}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<Trash2 size={14} />}
|
||||
aria-label={`${t('files.delete')} ${record.name}`}
|
||||
>
|
||||
{t('files.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(keys: React.Key[]) => { onSelectionChange?.(keys as string[]); },
|
||||
[onSelectionChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Table<FileRow>
|
||||
dataSource={rows}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: handleSelectionChange,
|
||||
}}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['15', '30', '50'],
|
||||
showTotal: (total) => t('files.totalItems', { total }),
|
||||
}}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('files.empty')} /> }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Alert, Button, Input, App, Popconfirm, Space } from 'antd';
|
||||
import { Search, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FILE_CATEGORIES, type FileCategory } from './fileCategories';
|
||||
import { FileList } from './FileList';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
|
||||
interface FilesContentProps {
|
||||
activeCategory: FileCategory;
|
||||
}
|
||||
|
||||
export function FilesContent({ activeCategory }: FilesContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const { message } = App.useApp();
|
||||
const meta = FILE_CATEGORIES.find((c) => c.id === activeCategory);
|
||||
if (!meta) {
|
||||
throw new Error(`Unhandled file category: ${activeCategory}`);
|
||||
}
|
||||
|
||||
const { rows, search, error, loadCategory, setSearch, setSortKey, clearError, revealEntry, cleanupMissingEntry } =
|
||||
useFileStore();
|
||||
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearch('');
|
||||
setSortKey('createdAt');
|
||||
setSelectedRowKeys([]);
|
||||
void loadCategory(activeCategory);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
void loadCategory(activeCategory);
|
||||
};
|
||||
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
if (selectedRowKeys.length === 0) return;
|
||||
try {
|
||||
for (const key of selectedRowKeys) {
|
||||
await cleanupMissingEntry(key);
|
||||
}
|
||||
setSelectedRowKeys([]);
|
||||
message.success(t('files.batchDeleteSuccess', { count: selectedRowKeys.length }));
|
||||
void loadCategory(activeCategory);
|
||||
} catch (e) {
|
||||
message.error(String(e));
|
||||
}
|
||||
}, [selectedRowKeys, activeCategory, loadCategory, cleanupMissingEntry, message, t]);
|
||||
|
||||
const handleDeleteEntry = useCallback(async (id: string) => {
|
||||
try {
|
||||
await cleanupMissingEntry(id);
|
||||
setSelectedRowKeys((prev) => prev.filter((k) => k !== id));
|
||||
message.success(t('files.deleteSuccess'));
|
||||
void loadCategory(activeCategory);
|
||||
} catch (e) {
|
||||
message.error(String(e));
|
||||
}
|
||||
}, [activeCategory, loadCategory, cleanupMissingEntry, message, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="files-content"
|
||||
data-category={activeCategory}
|
||||
className="h-full px-2 pt-0 pb-4 flex flex-col gap-3"
|
||||
>
|
||||
{error !== null && (
|
||||
<Alert
|
||||
data-testid="files-error-alert"
|
||||
type="error"
|
||||
message={error}
|
||||
closable
|
||||
onClose={clearError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toolbar: batch delete (left) + search (right) */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Space>
|
||||
<Popconfirm
|
||||
title={t('files.batchDeleteConfirm', { count: selectedRowKeys.length })}
|
||||
onConfirm={() => void handleBatchDelete()}
|
||||
okText={t('files.confirmYes')}
|
||||
cancelText={t('files.confirmNo')}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<Trash2 size={14} />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
{t('files.batchDelete', { count: selectedRowKeys.length })}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
<div data-testid="category-search" data-category={activeCategory} style={{ maxWidth: 300 }}>
|
||||
<Input
|
||||
prefix={<Search size={14} />}
|
||||
placeholder={t('files.searchPlaceholder', { category: t(meta.labelKey) })}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
handleSearchChange(e.target.value);
|
||||
}}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FileList
|
||||
rows={rows}
|
||||
category={activeCategory}
|
||||
selectedRowKeys={selectedRowKeys}
|
||||
onSelectionChange={setSelectedRowKeys}
|
||||
onReveal={(path) => void revealEntry(path)}
|
||||
onDelete={(id) => void handleDeleteEntry(id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Menu, theme } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FILE_CATEGORIES, type FileCategory } from './fileCategories';
|
||||
|
||||
interface FilesSidebarProps {
|
||||
activeCategory: FileCategory;
|
||||
onSelect: (category: FileCategory) => void;
|
||||
}
|
||||
|
||||
export function FilesSidebar({ activeCategory, onSelect }: FilesSidebarProps) {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items = FILE_CATEGORIES.map(({ id, labelKey, icon: Icon }) => ({
|
||||
key: id,
|
||||
icon: <Icon size={16} />,
|
||||
label: t(labelKey),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="files-sidebar"
|
||||
className="h-full"
|
||||
style={{ backgroundColor: token.colorBgContainer }}
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[activeCategory]}
|
||||
items={items}
|
||||
onClick={({ key }) => onSelect(key as FileCategory)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { FilesContent } from '../FilesContent';
|
||||
|
||||
const mockStoreBase = {
|
||||
rows: [],
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
search: '',
|
||||
sortKey: 'createdAt' as const,
|
||||
loadCategory: vi.fn().mockResolvedValue(undefined),
|
||||
setSearch: vi.fn(),
|
||||
setSortKey: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
openEntry: vi.fn().mockResolvedValue(undefined),
|
||||
revealEntry: vi.fn().mockResolvedValue(undefined),
|
||||
cleanupMissingEntry: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
vi.mock('@/stores/fileStore', () => ({
|
||||
useFileStore: vi.fn(() => ({ ...mockStoreBase })),
|
||||
}));
|
||||
|
||||
// Re-import after mock is set up.
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
|
||||
const mockUseFileStore = vi.mocked(useFileStore);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseFileStore.mockReturnValue({ ...mockStoreBase, error: null });
|
||||
});
|
||||
|
||||
describe('FilesContent — error visibility', () => {
|
||||
it('does not render an error alert when error is null', () => {
|
||||
render(<FilesContent activeCategory="images" />);
|
||||
expect(screen.queryByTestId('files-error-alert')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a visible error alert when the store has an error', () => {
|
||||
mockUseFileStore.mockReturnValue({
|
||||
...mockStoreBase,
|
||||
error: 'Failed to open file: permission denied',
|
||||
});
|
||||
|
||||
render(<FilesContent activeCategory="images" />);
|
||||
|
||||
const alert = screen.getByTestId('files-error-alert');
|
||||
expect(alert).toBeDefined();
|
||||
expect(alert.textContent).toContain('Failed to open file: permission denied');
|
||||
});
|
||||
|
||||
it('calls clearError when the alert close button is clicked', async () => {
|
||||
const clearError = vi.fn();
|
||||
mockUseFileStore.mockReturnValue({
|
||||
...mockStoreBase,
|
||||
error: 'reveal failed',
|
||||
clearError,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<FilesContent activeCategory="images" />);
|
||||
|
||||
const closeBtn = screen.getByRole('button', { name: /close/i });
|
||||
await user.click(closeBtn);
|
||||
|
||||
expect(clearError).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows errors for all three categories (not just images)', () => {
|
||||
mockUseFileStore.mockReturnValue({
|
||||
...mockStoreBase,
|
||||
error: 'cleanup failed',
|
||||
});
|
||||
|
||||
render(<FilesContent activeCategory="files" />);
|
||||
expect(screen.getByTestId('files-error-alert').textContent).toContain('cleanup failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ContentArea } from '@/components/layout/ContentArea';
|
||||
import { FilesPage } from '@/pages/FilesPage';
|
||||
|
||||
vi.mock('@/pages/ChatPage', () => ({ ChatPage: () => <div>chat</div> }));
|
||||
vi.mock('@/pages/GatewayPage', () => ({ GatewayPage: () => <div>gateway</div> }));
|
||||
vi.mock('@/pages/SettingsPage', () => ({ SettingsPage: () => <div>settings</div> }));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Task 3: list layout + controls
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('FilesPage list layout', () => {
|
||||
it('all categories render a list presentation', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FilesPage />);
|
||||
const sidebar = screen.getByTestId('files-sidebar');
|
||||
|
||||
// images (default)
|
||||
expect(screen.getByTestId('file-list')).toBeDefined();
|
||||
|
||||
// switch to 文件
|
||||
await user.click(within(sidebar).getByText('文件'));
|
||||
expect(screen.getByTestId('file-list')).toBeDefined();
|
||||
|
||||
// switch to 备份
|
||||
await user.click(within(sidebar).getByText('备份'));
|
||||
expect(screen.getByTestId('file-list')).toBeDefined();
|
||||
});
|
||||
|
||||
it('right pane shows sort controls for 创建时间, 大小, 文件名', () => {
|
||||
render(<FilesPage />);
|
||||
const content = screen.getByTestId('files-content');
|
||||
expect(within(content).getByText('创建时间')).toBeDefined();
|
||||
expect(within(content).getByText('大小')).toBeDefined();
|
||||
expect(within(content).getByText('文件名')).toBeDefined();
|
||||
});
|
||||
|
||||
it('search input is scoped to the active category', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FilesPage />);
|
||||
|
||||
const sidebar = screen.getByTestId('files-sidebar');
|
||||
|
||||
// default category is images
|
||||
expect(screen.getByTestId('category-search')).toHaveAttribute('data-category', 'images');
|
||||
|
||||
// switch to 文件 → search scope updates
|
||||
await user.click(within(sidebar).getByText('文件'));
|
||||
expect(screen.getByTestId('category-search')).toHaveAttribute('data-category', 'files');
|
||||
|
||||
// switch to 备份 → search scope updates
|
||||
await user.click(within(sidebar).getByText('备份'));
|
||||
expect(screen.getByTestId('category-search')).toHaveAttribute('data-category', 'backups');
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Task 3 code-review fix: sort/search reset on category switch
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
describe('FilesPage — sort and search reset on category switch', () => {
|
||||
it('search input is cleared when switching category', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FilesPage />);
|
||||
|
||||
// Type into the search input on the default (images) category
|
||||
const searchInput = screen.getByPlaceholderText('搜索图片…');
|
||||
await user.type(searchInput, 'hello');
|
||||
expect(searchInput).toHaveValue('hello');
|
||||
|
||||
// Switch to 文件
|
||||
await user.click(within(screen.getByTestId('files-sidebar')).getByText('文件'));
|
||||
|
||||
// Search should be cleared for the new category
|
||||
const newSearchInput = screen.getByPlaceholderText('搜索文件…');
|
||||
expect(newSearchInput).toHaveValue('');
|
||||
});
|
||||
|
||||
it('sort selection resets to 创建时间 when switching category', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FilesPage />);
|
||||
|
||||
// Click a non-default sort button
|
||||
await user.click(screen.getByText('大小'));
|
||||
expect(screen.getByText('大小').closest('button')).toHaveClass('ant-btn-primary');
|
||||
expect(screen.getByText('创建时间').closest('button')).not.toHaveClass('ant-btn-primary');
|
||||
|
||||
// Switch to 文件
|
||||
await user.click(within(screen.getByTestId('files-sidebar')).getByText('文件'));
|
||||
|
||||
// Sort should be back to 创建时间
|
||||
expect(screen.getByText('创建时间').closest('button')).toHaveClass('ant-btn-primary');
|
||||
expect(screen.getByText('大小').closest('button')).not.toHaveClass('ant-btn-primary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContentArea routing — files', () => {
|
||||
it('renders FilesPage when activePage is "files"', () => {
|
||||
render(<ContentArea activePage="files" />);
|
||||
// The real FilesPage renders a sidebar; verify it is present
|
||||
expect(screen.getByTestId('files-sidebar')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FilesPage two-pane shell', () => {
|
||||
it('renders secondary sidebar with 图片, 文件, 备份 categories', () => {
|
||||
render(<FilesPage />);
|
||||
const sidebar = screen.getByTestId('files-sidebar');
|
||||
expect(within(sidebar).getByText('图片')).toBeDefined();
|
||||
expect(within(sidebar).getByText('文件')).toBeDefined();
|
||||
expect(within(sidebar).getByText('备份')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders the right-pane content shell', () => {
|
||||
render(<FilesPage />);
|
||||
expect(screen.getByTestId('files-content')).toBeDefined();
|
||||
});
|
||||
|
||||
it('selects 图片 as the default category', () => {
|
||||
render(<FilesPage />);
|
||||
expect(screen.getByTestId('files-content')).toHaveAttribute('data-category', 'images');
|
||||
});
|
||||
|
||||
it('switching category updates the right pane while staying inside FilesPage', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FilesPage />);
|
||||
|
||||
const sidebar = screen.getByTestId('files-sidebar');
|
||||
|
||||
// sidebar and content both present before switch
|
||||
expect(sidebar).toBeDefined();
|
||||
expect(screen.getByTestId('files-content')).toHaveAttribute('data-category', 'images');
|
||||
|
||||
await user.click(within(sidebar).getByText('文件'));
|
||||
|
||||
// content updated, sidebar still present
|
||||
expect(screen.getByTestId('files-content')).toHaveAttribute('data-category', 'files');
|
||||
expect(screen.getByTestId('files-sidebar')).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Image, FileText } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { FileCategory } from '@/types';
|
||||
|
||||
export type { FileCategory };
|
||||
|
||||
export interface FileCategoryMeta {
|
||||
id: FileCategory;
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const FILE_CATEGORIES: FileCategoryMeta[] = [
|
||||
{ id: 'images', labelKey: 'files.images', icon: Image },
|
||||
{ id: 'files', labelKey: 'files.files', icon: FileText },
|
||||
];
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Alert, Checkbox, Modal, Space, Table, Typography, theme } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type {
|
||||
CodexSessionVisibilityStatus,
|
||||
CodexSessionVisibilityStatusRow,
|
||||
} from '@/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type StatusRow = CodexSessionVisibilityStatusRow & {
|
||||
key: string;
|
||||
};
|
||||
|
||||
interface CodexSessionVisibilityModalProps {
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
repairing: boolean;
|
||||
status: CodexSessionVisibilityStatus | null;
|
||||
createBackup: boolean;
|
||||
onCreateBackupChange: (checked: boolean) => void;
|
||||
onCancel: () => void;
|
||||
onRepair: () => void;
|
||||
}
|
||||
|
||||
export function CodexSessionVisibilityModal({
|
||||
open,
|
||||
loading,
|
||||
repairing,
|
||||
status,
|
||||
createBackup,
|
||||
onCreateBackupChange,
|
||||
onCancel,
|
||||
onRepair,
|
||||
}: CodexSessionVisibilityModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const rows = useMemo<StatusRow[]>(
|
||||
() => status?.statusRows.map((row, index) => ({
|
||||
...row,
|
||||
key: `${row.scope}-${row.provider ?? 'workspace'}-${index}`,
|
||||
})) ?? [],
|
||||
[status],
|
||||
);
|
||||
|
||||
const columns = useMemo<ColumnsType<StatusRow>>(
|
||||
() => [
|
||||
{
|
||||
title: t('gateway.codexRepairVisibilityTableScope'),
|
||||
dataIndex: 'scope',
|
||||
key: 'scope',
|
||||
},
|
||||
{
|
||||
title: t('gateway.codexRepairVisibilityTableProvider'),
|
||||
dataIndex: 'provider',
|
||||
key: 'provider',
|
||||
render: (value: string | null) => value || '-',
|
||||
},
|
||||
{
|
||||
title: t('gateway.codexRepairVisibilityTableCount'),
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
width: 96,
|
||||
},
|
||||
{
|
||||
title: t('gateway.codexRepairVisibilityTableMismatch'),
|
||||
dataIndex: 'mismatchedCount',
|
||||
key: 'mismatchedCount',
|
||||
width: 112,
|
||||
},
|
||||
{
|
||||
title: t('gateway.codexRepairVisibilityTableStatus'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 132,
|
||||
render: (_: string, row) => (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
padding: '2px 8px',
|
||||
border: `1px solid ${row.mismatchedCount > 0 ? token.colorWarningBorder : token.colorSuccessBorder}`,
|
||||
background: row.mismatchedCount > 0 ? token.colorWarningBg : token.colorSuccessBg,
|
||||
color: row.mismatchedCount > 0 ? token.colorWarningText : token.colorSuccessText,
|
||||
}}>
|
||||
{row.mismatchedCount > 0
|
||||
? t('gateway.codexRepairVisibilityStatusNeedsRepair')
|
||||
: t('gateway.codexRepairVisibilityStatusOk')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, token],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('gateway.codexRepairVisibilityModalTitle')}
|
||||
aria-label={t('gateway.codexRepairVisibilityModalTitle')}
|
||||
open={open}
|
||||
width={760}
|
||||
onCancel={onCancel}
|
||||
onOk={onRepair}
|
||||
okText={t('gateway.codexRepairVisibilityConfirmOk')}
|
||||
cancelText={t('gateway.codexRepairVisibilityConfirmCancel')}
|
||||
confirmLoading={repairing}
|
||||
okButtonProps={{ disabled: loading || !status }}
|
||||
>
|
||||
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||
<div style={{
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
background: token.colorFillTertiary,
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
}}>
|
||||
<Text strong>{t('gateway.codexRepairVisibilityCurrentStatus')}</Text>
|
||||
<div style={{ display: 'grid', gap: 4, marginTop: 8 }}>
|
||||
<Text type="secondary">
|
||||
{t('gateway.codexRepairVisibilityTargetProvider', {
|
||||
provider: status?.targetProvider ?? '-',
|
||||
})}
|
||||
</Text>
|
||||
<Text type="secondary">{status?.codexHome ?? '-'}</Text>
|
||||
<Text>
|
||||
{t('gateway.codexRepairVisibilityMismatchSummary', {
|
||||
sessions: status?.mismatchedSessionFiles ?? 0,
|
||||
total: status?.totalSessionFiles ?? 0,
|
||||
sqlite: status?.sqliteMismatchedRows ?? 0,
|
||||
workspace: status?.workspaceRootsNeedingUpdate ?? 0,
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table<StatusRow>
|
||||
size="small"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
locale={{ emptyText: t('gateway.codexRepairVisibilityNoStatusRows') }}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
title={t('gateway.codexRepairVisibilityRiskTitle')}
|
||||
description={t('gateway.codexRepairVisibilityRiskContent')}
|
||||
/>
|
||||
|
||||
{status?.encryptedContentWarning && (
|
||||
<Alert type="warning" showIcon description={status.encryptedContentWarning} />
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
checked={createBackup}
|
||||
onChange={(event) => onCreateBackupChange(event.target.checked)}
|
||||
>
|
||||
{t('gateway.codexRepairVisibilityCreateBackup')}
|
||||
</Checkbox>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Table, Button, Tag, Empty, Popconfirm, Modal } from 'antd';
|
||||
import { RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { useGatewayStore } from '@/stores/gatewayStore';
|
||||
import type { GatewayRequestLog } from '@/types';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { formatTokenCount } from './tokenFormat';
|
||||
|
||||
export function GatewayDiagnostics() {
|
||||
const { t } = useTranslation();
|
||||
const { requestLogs, requestLogsLoading, fetchRequestLogs, clearRequestLogs } = useGatewayStore();
|
||||
const [selectedErrorMessage, setSelectedErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchRequestLogs();
|
||||
}, [fetchRequestLogs]);
|
||||
|
||||
const columns: ColumnsType<GatewayRequestLog> = [
|
||||
{
|
||||
title: t('gateway.logTime'),
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (ts: number) => {
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleString();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('gateway.logMethod'),
|
||||
dataIndex: 'method',
|
||||
key: 'method',
|
||||
width: 80,
|
||||
render: (method: string) => (
|
||||
<Tag color="blue">{method}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('gateway.logPath'),
|
||||
dataIndex: 'path',
|
||||
key: 'path',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t('gateway.logModel'),
|
||||
dataIndex: 'model',
|
||||
key: 'model',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (model: string | null) => model || '-',
|
||||
},
|
||||
{
|
||||
title: t('gateway.logStatus'),
|
||||
dataIndex: 'statusCode',
|
||||
key: 'statusCode',
|
||||
width: 80,
|
||||
render: (code: number) => (
|
||||
<Tag color={code >= 200 && code < 300 ? 'green' : code >= 400 ? 'red' : 'orange'}>
|
||||
{code}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('gateway.logDuration'),
|
||||
dataIndex: 'durationMs',
|
||||
key: 'durationMs',
|
||||
width: 100,
|
||||
render: (ms: number) => {
|
||||
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${ms}ms`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('gateway.logRequestTokens'),
|
||||
dataIndex: 'requestTokens',
|
||||
key: 'requestTokens',
|
||||
width: 140,
|
||||
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
|
||||
},
|
||||
{
|
||||
title: t('gateway.logResponseTokens'),
|
||||
dataIndex: 'responseTokens',
|
||||
key: 'responseTokens',
|
||||
width: 140,
|
||||
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
|
||||
},
|
||||
{
|
||||
title: t('gateway.totalTokens'),
|
||||
key: 'totalTokens',
|
||||
width: 120,
|
||||
render: (_: unknown, record: GatewayRequestLog) => {
|
||||
const total = record.requestTokens + record.responseTokens;
|
||||
return total === 0 ? '-' : formatTokenCount(total);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('gateway.logKey'),
|
||||
dataIndex: 'keyName',
|
||||
key: 'keyName',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t('gateway.logError'),
|
||||
dataIndex: 'errorMessage',
|
||||
key: 'errorMessage',
|
||||
ellipsis: true,
|
||||
render: (msg: string | null) =>
|
||||
msg ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedErrorMessage(msg)}
|
||||
style={{
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
background: 'transparent',
|
||||
color: '#ff4d4f',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{msg}
|
||||
</button>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
<Popconfirm
|
||||
title={t('gateway.clearLogsConfirm')}
|
||||
onConfirm={clearRequestLogs}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<Button
|
||||
icon={<Trash2 size={14} />}
|
||||
danger
|
||||
disabled={requestLogs.length === 0}
|
||||
>
|
||||
{t('gateway.clearLogs')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={() => void fetchRequestLogs()}
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{requestLogs.length === 0 && !requestLogsLoading ? (
|
||||
<Empty
|
||||
description={t('gateway.noLogs')}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
dataSource={requestLogs}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={requestLogsLoading}
|
||||
pagination={{
|
||||
pageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
showTotal: (total) => `${total} ${t('gateway.logTotalRecords')}`,
|
||||
}}
|
||||
size="small"
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
)}
|
||||
<Modal
|
||||
open={selectedErrorMessage != null}
|
||||
title={t('gateway.logError')}
|
||||
onCancel={() => setSelectedErrorMessage(null)}
|
||||
footer={null}
|
||||
>
|
||||
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: '#ff4d4f' }}>
|
||||
{selectedErrorMessage}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Switch,
|
||||
Modal,
|
||||
Input,
|
||||
message,
|
||||
Popconfirm,
|
||||
Typography,
|
||||
Alert,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import { Plus, Trash2, Copy, Check, Search } from 'lucide-react';
|
||||
import { useGatewayStore } from '@/stores/gatewayStore';
|
||||
import { CopyButton } from '@/components/common/CopyButton';
|
||||
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
|
||||
import type { GatewayKey } from '@/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export function GatewayKeys() {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const { keys, loading, fetchKeys, createKey, deleteKey, toggleKey, decryptKey } =
|
||||
useGatewayStore();
|
||||
const { copy: copyCreatedKey, isCopied: createdKeyCopied } = useCopyToClipboard();
|
||||
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [keyName, setKeyName] = useState('');
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchKeys();
|
||||
}, [fetchKeys]);
|
||||
|
||||
const filteredKeys = useMemo(() => {
|
||||
if (!searchText.trim()) return keys;
|
||||
const lower = searchText.toLowerCase();
|
||||
return keys.filter(
|
||||
(k) =>
|
||||
k.name.toLowerCase().includes(lower) ||
|
||||
k.key_prefix.toLowerCase().includes(lower)
|
||||
);
|
||||
}, [keys, searchText]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!keyName.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const result = await createKey(keyName.trim());
|
||||
setCreatedKey(result.plain_key);
|
||||
setKeyName('');
|
||||
} catch (e) {
|
||||
message.error(String(e));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setCreateModalOpen(false);
|
||||
setCreatedKey(null);
|
||||
setKeyName('');
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('gateway.keyName'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: t('settings.keyPrefix'),
|
||||
dataIndex: 'key_prefix',
|
||||
key: 'key_prefix',
|
||||
render: (v: string) => <Text code>{v}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('common.enabled'),
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
render: (enabled: boolean, record: GatewayKey) => (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(checked) => toggleKey(record.id, checked)}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('gateway.created'),
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (ts: number) => new Date(ts * 1000).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
title: t('gateway.lastUsed'),
|
||||
dataIndex: 'last_used_at',
|
||||
key: 'last_used_at',
|
||||
render: (ts: number | null) =>
|
||||
ts ? new Date(ts * 1000).toLocaleDateString() : '-',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
render: (_: unknown, record: GatewayKey) => (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{record.has_encrypted_key && (
|
||||
<CopyButton
|
||||
text={async () => decryptKey(record.id)}
|
||||
size={14}
|
||||
successMessage={t('common.copySuccess')}
|
||||
onError={(e) => message.error(String(e))}
|
||||
/>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={t('gateway.deleteKeyConfirm')}
|
||||
onConfirm={() => deleteKey(record.id)}
|
||||
>
|
||||
<Button type="text" danger icon={<Trash2 size={14} />} size="small" />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
{t('gateway.createKey')}
|
||||
</Button>
|
||||
<Input
|
||||
placeholder={t('gateway.searchKeys')}
|
||||
prefix={<Search size={14} style={{ opacity: 0.45 }} />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
style={{ maxWidth: 280 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
dataSource={filteredKeys}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={createdKey ? t('gateway.keyCreated') : t('gateway.createKey')}
|
||||
open={createModalOpen}
|
||||
onCancel={handleCloseModal}
|
||||
footer={
|
||||
createdKey
|
||||
? [
|
||||
<Button key="copy" icon={createdKeyCopied ? <Check size={16} style={{ color: token.colorSuccess }} /> : <Copy size={16} />} onClick={async () => {
|
||||
if (createdKey) {
|
||||
const ok = await copyCreatedKey(createdKey);
|
||||
if (ok) message.success(t('common.copySuccess'));
|
||||
}
|
||||
}}>
|
||||
{t('gateway.copyKey')}
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={handleCloseModal}>
|
||||
{t('common.confirm')}
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Button key="cancel" onClick={handleCloseModal}>
|
||||
{t('common.cancel')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="create"
|
||||
type="primary"
|
||||
onClick={handleCreate}
|
||||
loading={creating}
|
||||
disabled={!keyName.trim()}
|
||||
>
|
||||
{t('common.create')}
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
>
|
||||
{createdKey ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Alert
|
||||
message={t('gateway.copyWarning')}
|
||||
type="warning"
|
||||
showIcon
|
||||
/>
|
||||
<Input.TextArea
|
||||
value={createdKey}
|
||||
readOnly
|
||||
autoSize={{ minRows: 2 }}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={t('gateway.keyName')}
|
||||
value={keyName}
|
||||
onChange={(e) => setKeyName(e.target.value)}
|
||||
onPressEnter={handleCreate}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Table, Card } from 'antd';
|
||||
import { useGatewayStore } from '@/stores/gatewayStore';
|
||||
import { formatTokenCount } from './tokenFormat';
|
||||
|
||||
export function GatewayMetrics() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
usageByDay,
|
||||
usageByProvider,
|
||||
usageByKey,
|
||||
fetchUsageByDay,
|
||||
fetchUsageByProvider,
|
||||
fetchUsageByKey,
|
||||
} = useGatewayStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsageByDay(30);
|
||||
fetchUsageByProvider();
|
||||
fetchUsageByKey();
|
||||
}, [fetchUsageByDay, fetchUsageByProvider, fetchUsageByKey]);
|
||||
|
||||
const dayColumns = [
|
||||
{ title: t('gateway.date'), dataIndex: 'date', key: 'date' },
|
||||
{
|
||||
title: t('gateway.totalRequests'),
|
||||
dataIndex: 'request_count',
|
||||
key: 'request_count',
|
||||
},
|
||||
{
|
||||
title: t('gateway.logRequestTokens'),
|
||||
dataIndex: 'request_tokens',
|
||||
key: 'request_tokens',
|
||||
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
|
||||
},
|
||||
{
|
||||
title: t('gateway.logResponseTokens'),
|
||||
dataIndex: 'response_tokens',
|
||||
key: 'response_tokens',
|
||||
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
|
||||
},
|
||||
{
|
||||
title: t('gateway.totalTokens'),
|
||||
dataIndex: 'token_count',
|
||||
key: 'token_count',
|
||||
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
|
||||
},
|
||||
];
|
||||
|
||||
const providerColumns = [
|
||||
{ title: t('gateway.provider'), dataIndex: 'provider_name', key: 'provider_name' },
|
||||
{
|
||||
title: t('gateway.totalRequests'),
|
||||
dataIndex: 'request_count',
|
||||
key: 'request_count',
|
||||
},
|
||||
{
|
||||
title: t('gateway.logRequestTokens'),
|
||||
dataIndex: 'request_tokens',
|
||||
key: 'request_tokens',
|
||||
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
|
||||
},
|
||||
{
|
||||
title: t('gateway.logResponseTokens'),
|
||||
dataIndex: 'response_tokens',
|
||||
key: 'response_tokens',
|
||||
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
|
||||
},
|
||||
{
|
||||
title: t('gateway.totalTokens'),
|
||||
dataIndex: 'token_count',
|
||||
key: 'token_count',
|
||||
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
|
||||
},
|
||||
];
|
||||
|
||||
const keyColumns = [
|
||||
{ title: t('gateway.keyName'), dataIndex: 'key_name', key: 'key_name' },
|
||||
{
|
||||
title: t('gateway.totalRequests'),
|
||||
dataIndex: 'request_count',
|
||||
key: 'request_count',
|
||||
},
|
||||
{
|
||||
title: t('gateway.logRequestTokens'),
|
||||
dataIndex: 'request_tokens',
|
||||
key: 'request_tokens',
|
||||
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
|
||||
},
|
||||
{
|
||||
title: t('gateway.logResponseTokens'),
|
||||
dataIndex: 'response_tokens',
|
||||
key: 'response_tokens',
|
||||
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
|
||||
},
|
||||
{
|
||||
title: t('gateway.totalTokens'),
|
||||
dataIndex: 'token_count',
|
||||
key: 'token_count',
|
||||
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Card size="small" title={t('gateway.usageByDay')}>
|
||||
<Table
|
||||
dataSource={usageByDay}
|
||||
columns={dayColumns}
|
||||
rowKey="date"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title={t('gateway.usageByProvider')}>
|
||||
<Table
|
||||
dataSource={usageByProvider}
|
||||
columns={providerColumns}
|
||||
rowKey="provider_id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title={t('gateway.usageByKey')}>
|
||||
<Table
|
||||
dataSource={usageByKey}
|
||||
columns={keyColumns}
|
||||
rowKey="key_id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Card, Tag, Statistic, Row, Col, Empty, Table, theme, message } from 'antd';
|
||||
import { PlayCircle, Power, Router, ArrowDown, ArrowUp, RefreshCw } from 'lucide-react';
|
||||
import { useGatewayStore } from '@/stores/gatewayStore';
|
||||
import type { GatewayRequestLog } from '@/types';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { formatTokenCount } from './tokenFormat';
|
||||
import { CopyButton } from '@/components/common/CopyButton';
|
||||
|
||||
interface GatewayOverviewProps {
|
||||
onViewMoreLogs?: () => void;
|
||||
}
|
||||
|
||||
export function GatewayOverview({ onViewMoreLogs }: GatewayOverviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const {
|
||||
status,
|
||||
metrics,
|
||||
startGateway,
|
||||
stopGateway,
|
||||
fetchStatus,
|
||||
fetchMetrics,
|
||||
listRequestLogs,
|
||||
} = useGatewayStore();
|
||||
const [recentLogs, setRecentLogs] = useState<GatewayRequestLog[]>([]);
|
||||
const [recentLogsLoading, setRecentLogsLoading] = useState(false);
|
||||
|
||||
const loadRecentLogs = useCallback(async () => {
|
||||
setRecentLogsLoading(true);
|
||||
try {
|
||||
const logs = await listRequestLogs(10);
|
||||
setRecentLogs(logs.slice(0, 10));
|
||||
} finally {
|
||||
setRecentLogsLoading(false);
|
||||
}
|
||||
}, [listRequestLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
void loadRecentLogs();
|
||||
}, [fetchStatus, fetchMetrics, loadRecentLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchStatus, fetchMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!status.is_running) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
void loadRecentLogs();
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [loadRecentLogs, status.is_running]);
|
||||
|
||||
const gatewayHost =
|
||||
status.listen_address === '0.0.0.0'
|
||||
? '127.0.0.1'
|
||||
: status.listen_address === '::' || status.listen_address === '[::]'
|
||||
? 'localhost'
|
||||
: status.listen_address;
|
||||
|
||||
const httpUrl = `http://${gatewayHost}:${status.port}/v1`;
|
||||
const httpsUrl = status.https_port != null
|
||||
? `https://${gatewayHost}:${status.https_port}/v1`
|
||||
: null;
|
||||
|
||||
const recentLogColumns: ColumnsType<GatewayRequestLog> = [
|
||||
{
|
||||
title: t('gateway.logTime'),
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 170,
|
||||
render: (ts: number) => new Date(ts * 1000).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: t('gateway.logMethod'),
|
||||
dataIndex: 'method',
|
||||
key: 'method',
|
||||
width: 90,
|
||||
render: (method: string) => <Tag color="blue">{method}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('gateway.logPath'),
|
||||
dataIndex: 'path',
|
||||
key: 'path',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t('gateway.logStatus'),
|
||||
dataIndex: 'statusCode',
|
||||
key: 'statusCode',
|
||||
width: 90,
|
||||
render: (code: number) => (
|
||||
<Tag color={code >= 200 && code < 300 ? 'green' : code >= 400 ? 'red' : 'orange'}>
|
||||
{code}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('gateway.logDuration'),
|
||||
dataIndex: 'durationMs',
|
||||
key: 'durationMs',
|
||||
width: 110,
|
||||
render: (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`),
|
||||
},
|
||||
{
|
||||
title: t('gateway.logRequestTokens'),
|
||||
dataIndex: 'requestTokens',
|
||||
key: 'requestTokens',
|
||||
width: 140,
|
||||
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
|
||||
},
|
||||
{
|
||||
title: t('gateway.logResponseTokens'),
|
||||
dataIndex: 'responseTokens',
|
||||
key: 'responseTokens',
|
||||
width: 140,
|
||||
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
|
||||
},
|
||||
{
|
||||
title: t('gateway.totalTokens'),
|
||||
key: 'totalTokens',
|
||||
width: 120,
|
||||
render: (_: unknown, record: GatewayRequestLog) => {
|
||||
const total = record.requestTokens + record.responseTokens;
|
||||
return total === 0 ? '-' : formatTokenCount(total);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const metricCardContentStyle = {
|
||||
minHeight: 82,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
} as const;
|
||||
|
||||
const tokenValueRowStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
} as const;
|
||||
|
||||
const handleToggle = async () => {
|
||||
try {
|
||||
if (status.is_running) {
|
||||
await stopGateway();
|
||||
} else {
|
||||
await startGateway();
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
message.error(`${t('error.gatewayStartFailed')}: ${detail}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenUrl = async (url: string) => {
|
||||
try {
|
||||
const { openUrl } = await import('@tauri-apps/plugin-opener');
|
||||
await openUrl(url);
|
||||
} catch {
|
||||
// fallback for browser dev mode
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewMoreLogs = useCallback(() => {
|
||||
onViewMoreLogs?.();
|
||||
}, [onViewMoreLogs]);
|
||||
|
||||
const handleRefreshRecentLogs = useCallback(() => {
|
||||
void loadRecentLogs();
|
||||
}, [loadRecentLogs]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* Server Status */}
|
||||
<Card size="small">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Router size={24} />
|
||||
<div>
|
||||
<Tag color={status.is_running ? 'green' : 'default'}>
|
||||
{status.is_running ? t('gateway.running') : t('gateway.stopped')}
|
||||
</Tag>
|
||||
{status.is_running && (
|
||||
<div className="text-xs flex flex-col gap-0.5 mt-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => { e.preventDefault(); handleOpenUrl(httpUrl); }}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
color: token.colorPrimary,
|
||||
textDecoration: 'underline dashed',
|
||||
textUnderlineOffset: 3,
|
||||
userSelect: 'all',
|
||||
}}
|
||||
>
|
||||
{httpUrl}
|
||||
</a>
|
||||
<CopyButton
|
||||
text={httpUrl}
|
||||
size={12}
|
||||
successMessage={t('gateway.copySuccess')}
|
||||
/>
|
||||
</div>
|
||||
{status.ssl_enabled && httpsUrl && (
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => { e.preventDefault(); handleOpenUrl(httpsUrl); }}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
color: token.colorPrimary,
|
||||
textDecoration: 'underline dashed',
|
||||
textUnderlineOffset: 3,
|
||||
userSelect: 'all',
|
||||
}}
|
||||
>
|
||||
{httpsUrl}
|
||||
</a>
|
||||
<CopyButton
|
||||
text={httpsUrl}
|
||||
size={12}
|
||||
successMessage={t('gateway.copySuccess')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{status.ssl_enabled && status.force_ssl && (
|
||||
<span style={{ color: token.colorWarning, fontSize: 11 }}>
|
||||
{t('gateway.forceSslNotice')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type={status.is_running ? 'default' : 'primary'}
|
||||
danger={status.is_running}
|
||||
icon={status.is_running ? <Power size={16} /> : <PlayCircle size={16} />}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{status.is_running ? t('gateway.stop') : t('gateway.start')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick Metrics */}
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<div style={metricCardContentStyle}>
|
||||
<Statistic title={t('gateway.todayRequests')} value={metrics?.today_requests ?? 0} />
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<div style={metricCardContentStyle}>
|
||||
<div
|
||||
style={{
|
||||
color: token.colorTextDescription,
|
||||
fontSize: token.fontSizeLG,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t('gateway.todayTokens')}
|
||||
</div>
|
||||
<div style={tokenValueRowStyle}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 36,
|
||||
lineHeight: 1.1,
|
||||
fontWeight: 500,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{formatTokenCount(metrics?.today_tokens ?? 0)}
|
||||
</div>
|
||||
<Tag icon={<ArrowUp size={12} />} color="blue">
|
||||
{formatTokenCount(metrics?.today_request_tokens ?? 0)}
|
||||
</Tag>
|
||||
<Tag icon={<ArrowDown size={12} />} color="purple">
|
||||
{formatTokenCount(metrics?.today_response_tokens ?? 0)}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<div style={metricCardContentStyle}>
|
||||
<Statistic title={t('gateway.totalRequests')} value={metrics?.total_requests ?? 0} />
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<div style={metricCardContentStyle}>
|
||||
<div
|
||||
style={{
|
||||
color: token.colorTextDescription,
|
||||
fontSize: token.fontSizeLG,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t('gateway.totalTokens')}
|
||||
</div>
|
||||
<div style={tokenValueRowStyle}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 36,
|
||||
lineHeight: 1.1,
|
||||
fontWeight: 500,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{formatTokenCount(metrics?.total_tokens ?? 0)}
|
||||
</div>
|
||||
<Tag icon={<ArrowUp size={12} />} color="blue">
|
||||
{formatTokenCount(metrics?.total_request_tokens ?? 0)}
|
||||
</Tag>
|
||||
<Tag icon={<ArrowDown size={12} />} color="purple">
|
||||
{formatTokenCount(metrics?.total_response_tokens ?? 0)}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Recent Request Logs */}
|
||||
<Card
|
||||
size="small"
|
||||
title={t('gateway.recentRequestLogs')}
|
||||
extra={(
|
||||
<Button
|
||||
size="small"
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={handleRefreshRecentLogs}
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{recentLogs.length === 0 && !recentLogsLoading ? (
|
||||
<Empty description={t('gateway.noLogs')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
<Table
|
||||
dataSource={recentLogs}
|
||||
columns={recentLogColumns}
|
||||
rowKey="id"
|
||||
loading={recentLogsLoading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 1080 }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
|
||||
<Button type="link" onClick={handleViewMoreLogs}>
|
||||
{t('gateway.viewMoreLogs')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Divider, Switch, Input, InputNumber, Alert, Button, Radio, message, Tooltip, theme } from 'antd';
|
||||
import { Info, Upload as UploadIcon, ShieldAlert } from 'lucide-react';
|
||||
import { useGatewayStore } from '@/stores/gatewayStore';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
import { invoke } from '@/lib/invoke';
|
||||
|
||||
interface CertResult {
|
||||
cert_path: string;
|
||||
key_path: string;
|
||||
}
|
||||
|
||||
export function GatewaySettings() {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const { settings, fetchSettings, saveSettings } = useSettingsStore();
|
||||
const { status, fetchStatus, stopGateway } = useGatewayStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
fetchStatus();
|
||||
const interval = window.setInterval(() => {
|
||||
fetchStatus();
|
||||
}, 5000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [fetchSettings, fetchStatus]);
|
||||
|
||||
const settingsLocked = status.is_running;
|
||||
|
||||
const handleSave = async (partial: Parameters<typeof saveSettings>[0]) => {
|
||||
try {
|
||||
await saveSettings(partial);
|
||||
} catch (e) {
|
||||
message.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFile = async (field: 'gateway_ssl_cert_path' | 'gateway_ssl_key_path') => {
|
||||
if (settingsLocked) return;
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: field === 'gateway_ssl_cert_path'
|
||||
? [{ name: 'Certificate', extensions: ['pem', 'crt', 'cer'] }]
|
||||
: [{ name: 'Private Key', extensions: ['pem', 'key'] }],
|
||||
});
|
||||
if (selected) {
|
||||
await handleSave({ [field]: selected });
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateSelfSigned = async () => {
|
||||
if (settingsLocked) return;
|
||||
try {
|
||||
const result = await invoke<CertResult>('generate_self_signed_cert');
|
||||
await handleSave({
|
||||
gateway_ssl_cert_path: result.cert_path,
|
||||
gateway_ssl_key_path: result.key_path,
|
||||
});
|
||||
message.success(t('gateway.sslGenerateSuccess'));
|
||||
} catch (e) {
|
||||
message.error(t('gateway.sslGenerateFailed') + ': ' + String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const [sslPortValue, setSslPortValue] = useState<number>(settings.gateway_ssl_port ?? 8443);
|
||||
const [sslPortError, setSslPortError] = useState(false);
|
||||
const [portValue, setPortValue] = useState<number>(settings.gateway_port ?? 8080);
|
||||
const [portError, setPortError] = useState(false);
|
||||
const [listenAddressValue, setListenAddressValue] = useState<string>(settings.gateway_listen_address ?? '127.0.0.1');
|
||||
|
||||
// Track port values that were blocked from saving due to a conflict so they
|
||||
// can be persisted as soon as the conflict is resolved.
|
||||
const portPendingSave = useRef(false);
|
||||
const sslPortPendingSave = useRef(false);
|
||||
|
||||
// Each field syncs independently so an unsaved local edit in one field is never
|
||||
// clobbered when the other field's persisted value changes.
|
||||
useEffect(() => {
|
||||
setPortValue(settings.gateway_port ?? 8080);
|
||||
}, [settings.gateway_port]);
|
||||
|
||||
useEffect(() => {
|
||||
setSslPortValue(settings.gateway_ssl_port ?? 8443);
|
||||
}, [settings.gateway_ssl_port]);
|
||||
|
||||
useEffect(() => {
|
||||
setListenAddressValue(settings.gateway_listen_address ?? '127.0.0.1');
|
||||
}, [settings.gateway_listen_address]);
|
||||
|
||||
// Recompute conflict errors whenever SSL is toggled or either local port value changes.
|
||||
// If a conflict clears for a port that had a pending (blocked) save, flush it now.
|
||||
useEffect(() => {
|
||||
const sslEnabled = settings.gateway_ssl_enabled ?? false;
|
||||
const newPortError = sslEnabled && portValue === sslPortValue;
|
||||
const newSslPortError = sslEnabled && sslPortValue === portValue;
|
||||
|
||||
if (!newPortError && portPendingSave.current) {
|
||||
portPendingSave.current = false;
|
||||
handleSave({ gateway_port: portValue });
|
||||
}
|
||||
if (!newSslPortError && sslPortPendingSave.current) {
|
||||
sslPortPendingSave.current = false;
|
||||
handleSave({ gateway_ssl_port: sslPortValue });
|
||||
}
|
||||
|
||||
setPortError(newPortError);
|
||||
setSslPortError(newSslPortError);
|
||||
}, [settings.gateway_ssl_enabled, portValue, sslPortValue]);
|
||||
|
||||
const handleSslPortChange = (val: number | null) => {
|
||||
if (val == null) return;
|
||||
setSslPortValue(val);
|
||||
if (val === portValue) {
|
||||
setSslPortError(true);
|
||||
sslPortPendingSave.current = true;
|
||||
} else {
|
||||
setSslPortError(false);
|
||||
sslPortPendingSave.current = false;
|
||||
handleSave({ gateway_ssl_port: val });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePortChange = (val: number | null) => {
|
||||
if (val == null) return;
|
||||
setPortValue(val);
|
||||
if ((settings.gateway_ssl_enabled ?? false) && val === sslPortValue) {
|
||||
setPortError(true);
|
||||
portPendingSave.current = true;
|
||||
} else {
|
||||
setPortError(false);
|
||||
portPendingSave.current = false;
|
||||
handleSave({ gateway_port: val });
|
||||
}
|
||||
};
|
||||
|
||||
const handleListenAddressCommit = () => {
|
||||
const trimmed = listenAddressValue.trim();
|
||||
if (trimmed) handleSave({ gateway_listen_address: trimmed });
|
||||
};
|
||||
|
||||
const handleStopNow = async () => {
|
||||
try {
|
||||
await stopGateway();
|
||||
message.success(t('gateway.stopped'));
|
||||
} catch (e) {
|
||||
message.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-12">
|
||||
{settingsLocked && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('gateway.settingsLockedTitle')}
|
||||
description={t('gateway.settingsLockedDesc')}
|
||||
action={
|
||||
<Button danger size="small" onClick={handleStopNow}>
|
||||
{t('gateway.stopNow')}
|
||||
</Button>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card size="small" title={t('gateway.settingsService')} style={{ marginBottom: 16 }}>
|
||||
<div style={{ padding: '4px 0' }} className="flex items-center justify-between">
|
||||
<span>{t('gateway.listenAddress')}</span>
|
||||
<Input
|
||||
value={listenAddressValue}
|
||||
onChange={(e) => setListenAddressValue(e.target.value)}
|
||||
onBlur={handleListenAddressCommit}
|
||||
onPressEnter={handleListenAddressCommit}
|
||||
style={{ width: 200 }}
|
||||
disabled={settingsLocked}
|
||||
/>
|
||||
</div>
|
||||
<Divider style={{ margin: '4px 0' }} />
|
||||
<div style={{ padding: '4px 0' }} className="flex items-center justify-between">
|
||||
<span>{t('gateway.port')}</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
|
||||
<InputNumber
|
||||
value={portValue}
|
||||
onChange={handlePortChange}
|
||||
min={1}
|
||||
max={65535}
|
||||
style={{ width: 200 }}
|
||||
disabled={settingsLocked}
|
||||
status={portError ? 'error' : undefined}
|
||||
/>
|
||||
{portError && (
|
||||
<span style={{ color: token.colorError, fontSize: 12, marginTop: 4 }}>
|
||||
{t('gateway.sslPortConflict')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Divider style={{ margin: '4px 0' }} />
|
||||
<div style={{ padding: '4px 0' }} className="flex items-center justify-between">
|
||||
<span>{t('gateway.autoStart')}</span>
|
||||
<Switch
|
||||
checked={settings.gateway_auto_start ?? false}
|
||||
onChange={(checked) => handleSave({ gateway_auto_start: checked })}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title={t('gateway.settingsSsl')} style={{ marginBottom: 16 }}>
|
||||
<div style={{ padding: '4px 0' }} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{t('gateway.sslEnable')}</span>
|
||||
<Tooltip title={t('gateway.sslEnableTooltip')}>
|
||||
<Info size={12} style={{ color: token.colorTextSecondary, cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.gateway_ssl_enabled ?? false}
|
||||
onChange={(checked) => handleSave({ gateway_ssl_enabled: checked })}
|
||||
disabled={settingsLocked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{settings.gateway_ssl_enabled && (
|
||||
<>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
||||
<div style={{ padding: '4px 0' }} className="flex items-center justify-between">
|
||||
<span>{t('gateway.sslPort')}</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
|
||||
<InputNumber
|
||||
value={sslPortValue}
|
||||
onChange={handleSslPortChange}
|
||||
min={1}
|
||||
max={65535}
|
||||
style={{ width: 200 }}
|
||||
disabled={settingsLocked}
|
||||
status={sslPortError ? 'error' : undefined}
|
||||
/>
|
||||
{sslPortError && (
|
||||
<span style={{ color: token.colorError, fontSize: 12, marginTop: 4 }}>
|
||||
{t('gateway.sslPortConflict')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Divider style={{ margin: '4px 0' }} />
|
||||
|
||||
<div style={{ padding: '4px 0' }} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{t('gateway.forceSsl')}</span>
|
||||
<Tooltip title={t('gateway.forceSslTooltip')}>
|
||||
<Info size={12} style={{ color: token.colorTextSecondary, cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.gateway_force_ssl ?? false}
|
||||
onChange={(checked) => handleSave({ gateway_force_ssl: checked })}
|
||||
disabled={settingsLocked}
|
||||
/>
|
||||
</div>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
icon={<ShieldAlert size={16} />}
|
||||
message={t('gateway.sslWarning')}
|
||||
description={t('gateway.sslWarningDesc')}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
<Radio.Group
|
||||
value={settings.gateway_ssl_mode ?? 'upload'}
|
||||
onChange={(e) => handleSave({ gateway_ssl_mode: e.target.value })}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
disabled={settingsLocked}
|
||||
>
|
||||
<Radio value="upload">
|
||||
<span style={{ fontWeight: 500 }}>{t('gateway.sslUpload')}</span>
|
||||
<div style={{ color: token.colorTextSecondary, fontSize: 12, marginTop: 2 }}>
|
||||
{t('gateway.sslUploadDesc')}
|
||||
</div>
|
||||
</Radio>
|
||||
{(settings.gateway_ssl_mode ?? 'upload') === 'upload' && (
|
||||
<div style={{ paddingLeft: 24, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span style={{ width: 80, flexShrink: 0 }}>{t('gateway.sslCertFile')}</span>
|
||||
<Input
|
||||
readOnly
|
||||
value={settings.gateway_ssl_cert_path ?? ''}
|
||||
placeholder={t('gateway.sslCertFilePlaceholder')}
|
||||
style={{ flex: 1 }}
|
||||
disabled={settingsLocked}
|
||||
/>
|
||||
<Button
|
||||
icon={<UploadIcon size={14} />}
|
||||
onClick={() => handleSelectFile('gateway_ssl_cert_path')}
|
||||
disabled={settingsLocked}
|
||||
>
|
||||
{t('gateway.sslSelectFile')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span style={{ width: 80, flexShrink: 0 }}>{t('gateway.sslKeyFile')}</span>
|
||||
<Input
|
||||
readOnly
|
||||
value={settings.gateway_ssl_key_path ?? ''}
|
||||
placeholder={t('gateway.sslKeyFilePlaceholder')}
|
||||
style={{ flex: 1 }}
|
||||
disabled={settingsLocked}
|
||||
/>
|
||||
<Button
|
||||
icon={<UploadIcon size={14} />}
|
||||
onClick={() => handleSelectFile('gateway_ssl_key_path')}
|
||||
disabled={settingsLocked}
|
||||
>
|
||||
{t('gateway.sslSelectFile')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Radio value="selfsign">
|
||||
<span style={{ fontWeight: 500 }}>{t('gateway.sslSelfSign')}</span>
|
||||
<div style={{ color: token.colorTextSecondary, fontSize: 12, marginTop: 2 }}>
|
||||
{t('gateway.sslSelfSignDesc')}
|
||||
</div>
|
||||
</Radio>
|
||||
{(settings.gateway_ssl_mode ?? 'upload') === 'selfsign' && (
|
||||
<div style={{ paddingLeft: 24 }}>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('gateway.sslSelfSignWarning')}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Button type="primary" onClick={handleGenerateSelfSigned} disabled={settingsLocked}>
|
||||
{t('gateway.sslGenerateCert')}
|
||||
</Button>
|
||||
{settings.gateway_ssl_cert_path && settings.gateway_ssl_mode === 'selfsign' && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: token.colorTextSecondary }}>
|
||||
{t('gateway.sslCertFile')}: {settings.gateway_ssl_cert_path}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Radio.Group>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Button, Typography, App, Tag, Select, Dropdown } from 'antd';
|
||||
import { Zap, ZapOff, RefreshCw, AlertCircle, Wrench } from 'lucide-react';
|
||||
import { ClaudeCode } from '@lobehub/icons';
|
||||
import { Codex } from '@lobehub/icons';
|
||||
import { OpenCode } from '@lobehub/icons';
|
||||
import { Gemini } from '@lobehub/icons';
|
||||
import { Cursor } from '@lobehub/icons';
|
||||
import { CodexSessionVisibilityModal } from './CodexSessionVisibilityModal';
|
||||
import { useGatewayStore } from '@/stores/gatewayStore';
|
||||
import type { CliToolInfo, CodexSessionVisibilityStatus, QuickConnectProtocol } from '@/types';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
interface QuickConnectItem {
|
||||
key: string;
|
||||
name: string;
|
||||
avatar: (size: number) => ReactNode;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const CONNECT_ITEMS: QuickConnectItem[] = [
|
||||
{
|
||||
key: 'claude_code',
|
||||
name: 'Claude Code',
|
||||
avatar: (size) => <ClaudeCode.Avatar size={size} />,
|
||||
description: 'gateway.templateDescClaude',
|
||||
},
|
||||
{
|
||||
key: 'codex',
|
||||
name: 'Codex',
|
||||
avatar: (size) => <Codex.Avatar size={size} />,
|
||||
description: 'gateway.templateDescCodex',
|
||||
},
|
||||
{
|
||||
key: 'opencode',
|
||||
name: 'OpenCode',
|
||||
avatar: (size) => <OpenCode.Avatar size={size} />,
|
||||
description: 'gateway.templateDescOpencode',
|
||||
},
|
||||
{
|
||||
key: 'gemini',
|
||||
name: 'Gemini CLI',
|
||||
avatar: (size) => <Gemini.Avatar size={size} />,
|
||||
description: 'gateway.templateDescGemini',
|
||||
},
|
||||
{
|
||||
key: 'cursor',
|
||||
name: 'Cursor',
|
||||
avatar: (size) => <Cursor.Avatar size={size} />,
|
||||
description: 'gateway.templateDescCursor',
|
||||
},
|
||||
];
|
||||
|
||||
/** Tab icon that cycles through integration target avatars */
|
||||
export function QuickConnectCycleIcon({ size = 16 }: { size?: number }) {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setIndex((prev) => (prev + 1) % CONNECT_ITEMS.length);
|
||||
}, 2000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: size,
|
||||
height: size,
|
||||
verticalAlign: '-0.125em',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{CONNECT_ITEMS[index].avatar(size)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusTag({
|
||||
status,
|
||||
connectedProtocol,
|
||||
}: {
|
||||
status: string;
|
||||
connectedProtocol?: QuickConnectProtocol | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const displayStatus = status === 'connected' && connectedProtocol == null ? 'not_connected' : status;
|
||||
switch (displayStatus) {
|
||||
case 'connected':
|
||||
return (
|
||||
<Tag color="success">
|
||||
{connectedProtocol === 'https'
|
||||
? t('gateway.cliConnectedHttps')
|
||||
: connectedProtocol === 'http'
|
||||
? t('gateway.cliConnectedHttp')
|
||||
: t('gateway.cliNotConnected')}
|
||||
</Tag>
|
||||
);
|
||||
case 'not_connected':
|
||||
return <Tag color="default">{t('gateway.cliNotConnected')}</Tag>;
|
||||
case 'not_installed':
|
||||
return <Tag color="error">{t('gateway.cliNotInstalled')}</Tag>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ToolCard({
|
||||
item,
|
||||
toolInfo,
|
||||
selectedKeyId,
|
||||
selectedProtocol,
|
||||
quickConnectBlocked,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onOpenCodexSessionVisibilityRepair,
|
||||
connecting,
|
||||
repairing,
|
||||
}: {
|
||||
item: QuickConnectItem;
|
||||
toolInfo?: CliToolInfo;
|
||||
selectedKeyId?: string;
|
||||
selectedProtocol?: QuickConnectProtocol;
|
||||
quickConnectBlocked: boolean;
|
||||
onConnect: (toolId: string) => void;
|
||||
onDisconnect: (toolId: string, restoreBackup: boolean) => void;
|
||||
onOpenCodexSessionVisibilityRepair: () => void;
|
||||
connecting: string | null;
|
||||
repairing: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { modal } = App.useApp();
|
||||
|
||||
const status = toolInfo?.status ?? 'not_installed';
|
||||
const connectedProtocol = toolInfo?.connectedProtocol ?? null;
|
||||
const displayStatus = status === 'connected' && connectedProtocol == null ? 'not_connected' : status;
|
||||
const isConnecting = connecting === item.key;
|
||||
const isRepairing = repairing === item.key;
|
||||
const isNotInstalled = displayStatus === 'not_installed';
|
||||
const isConnected = displayStatus === 'connected';
|
||||
const needsReconnect =
|
||||
isConnected &&
|
||||
connectedProtocol != null &&
|
||||
selectedProtocol != null &&
|
||||
connectedProtocol !== selectedProtocol;
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
if (isNotInstalled) {
|
||||
modal.confirm({
|
||||
title: t('gateway.cliNotInstalledConfirmTitle'),
|
||||
content: t('gateway.cliNotInstalledConfirmContent'),
|
||||
okText: t('gateway.cliNotInstalledConfirmOk'),
|
||||
cancelText: t('gateway.cliNotInstalledConfirmCancel'),
|
||||
onOk: () => onConnect(item.key),
|
||||
});
|
||||
} else {
|
||||
onConnect(item.key);
|
||||
}
|
||||
}, [item.key, isNotInstalled, onConnect, modal, t]);
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
const hasBackup = toolInfo?.hasBackup ?? false;
|
||||
if (hasBackup) {
|
||||
modal.confirm({
|
||||
title: t('gateway.cliDisconnectTitle'),
|
||||
content: t('gateway.cliDisconnectContent'),
|
||||
okText: t('gateway.cliRestoreBackup'),
|
||||
cancelText: t('gateway.cliRemoveFieldsOnly'),
|
||||
onOk: () => onDisconnect(item.key, true),
|
||||
onCancel: () => onDisconnect(item.key, false),
|
||||
});
|
||||
} else {
|
||||
onDisconnect(item.key, false);
|
||||
}
|
||||
}, [item.key, toolInfo, onDisconnect, modal, t]);
|
||||
|
||||
const toolMenu = useMemo(() => ({
|
||||
items: [
|
||||
{
|
||||
key: 'repair_visibility',
|
||||
icon: <Wrench size={14} />,
|
||||
label: t('gateway.codexRepairVisibility'),
|
||||
},
|
||||
],
|
||||
onClick: ({ key }: { key: string }) => {
|
||||
if (key === 'repair_visibility') {
|
||||
onOpenCodexSessionVisibilityRepair();
|
||||
}
|
||||
},
|
||||
}), [onOpenCodexSessionVisibilityRepair, t]);
|
||||
|
||||
return (
|
||||
<Card key={item.key} size="small" hoverable className="gateway-quick-connect-card">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
{item.avatar(40)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Title level={5} style={{ margin: 0 }}>{item.name}</Title>
|
||||
{toolInfo?.version && !isNotInstalled && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>v{toolInfo.version.replace(/^[^\d]*/, '')}</Text>
|
||||
)}
|
||||
<StatusTag status={status} connectedProtocol={connectedProtocol} />
|
||||
</div>
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
style={{ fontSize: 13, margin: 0, marginTop: 4 }}
|
||||
ellipsis={{ rows: 1 }}
|
||||
>
|
||||
{t(item.description)}
|
||||
</Paragraph>
|
||||
{toolInfo?.configPath && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{toolInfo.configPath}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{isConnected && !needsReconnect ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<ZapOff size={14} />}
|
||||
onClick={handleDisconnect}
|
||||
loading={isConnecting}
|
||||
>
|
||||
{t('gateway.cliDisconnect')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Zap size={14} />}
|
||||
onClick={handleConnect}
|
||||
disabled={quickConnectBlocked || !selectedKeyId || !selectedProtocol}
|
||||
loading={isConnecting}
|
||||
>
|
||||
{needsReconnect ? t('gateway.cliSwitchProtocolReconnect') : t('gateway.quickConnect')}
|
||||
</Button>
|
||||
)}
|
||||
{item.key === 'codex' && (
|
||||
<Dropdown menu={toolMenu} trigger={['click']}>
|
||||
<Button icon={<Wrench size={14} />} loading={isRepairing}>
|
||||
{t('gateway.cliTools')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function GatewayTemplates() {
|
||||
const { t } = useTranslation();
|
||||
const { message } = App.useApp();
|
||||
const {
|
||||
status,
|
||||
cliTools,
|
||||
cliToolsLoading,
|
||||
keys,
|
||||
fetchStatus,
|
||||
fetchCliToolStatuses,
|
||||
connectCliTool,
|
||||
disconnectCliTool,
|
||||
repairCodexSessionVisibility,
|
||||
getCodexSessionVisibilityStatus,
|
||||
fetchKeys,
|
||||
} = useGatewayStore();
|
||||
const [connecting, setConnecting] = useState<string | null>(null);
|
||||
const [repairing, setRepairing] = useState<string | null>(null);
|
||||
const [codexVisibilityModalOpen, setCodexVisibilityModalOpen] = useState(false);
|
||||
const [codexVisibilityStatus, setCodexVisibilityStatus] =
|
||||
useState<CodexSessionVisibilityStatus | null>(null);
|
||||
const [codexVisibilityStatusLoading, setCodexVisibilityStatusLoading] = useState(false);
|
||||
const [codexVisibilityCreateBackup, setCodexVisibilityCreateBackup] = useState(true);
|
||||
const enabledKeys = keys.filter((k) => k.enabled && k.has_encrypted_key);
|
||||
const quickConnectBlocked = !status.is_running;
|
||||
const [selectedKeyId, setSelectedKeyId] = useState<string | undefined>(undefined);
|
||||
const availableProtocols = useMemo<QuickConnectProtocol[]>(() => {
|
||||
const protocols: QuickConnectProtocol[] = [];
|
||||
|
||||
if (!status.force_ssl) {
|
||||
protocols.push('http');
|
||||
}
|
||||
if (status.https_port != null) {
|
||||
protocols.push('https');
|
||||
}
|
||||
|
||||
if (protocols.length > 0) {
|
||||
return protocols;
|
||||
}
|
||||
|
||||
return status.force_ssl ? ['https'] : ['http'];
|
||||
}, [status.force_ssl, status.https_port]);
|
||||
const [selectedProtocol, setSelectedProtocol] = useState<QuickConnectProtocol | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
fetchCliToolStatuses();
|
||||
fetchKeys();
|
||||
}, [fetchStatus, fetchCliToolStatuses, fetchKeys]);
|
||||
|
||||
// Auto-select first key when keys load
|
||||
useEffect(() => {
|
||||
if (!selectedKeyId && enabledKeys.length > 0) {
|
||||
setSelectedKeyId(enabledKeys[0].id);
|
||||
}
|
||||
}, [enabledKeys, selectedKeyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProtocol || !availableProtocols.includes(selectedProtocol)) {
|
||||
setSelectedProtocol(availableProtocols[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (availableProtocols.length === 1 && selectedProtocol !== availableProtocols[0]) {
|
||||
setSelectedProtocol(availableProtocols[0]);
|
||||
}
|
||||
}, [availableProtocols, selectedProtocol]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void fetchStatus();
|
||||
void fetchCliToolStatuses();
|
||||
}, [fetchStatus, fetchCliToolStatuses]);
|
||||
|
||||
const handleConnect = useCallback(
|
||||
async (toolId: string) => {
|
||||
if (!selectedKeyId || !selectedProtocol) return;
|
||||
setConnecting(toolId);
|
||||
try {
|
||||
await connectCliTool(toolId, selectedKeyId, selectedProtocol);
|
||||
const name = CONNECT_ITEMS.find((i) => i.key === toolId)?.name;
|
||||
message.success(t('gateway.cliConnectSuccess', { name }));
|
||||
} catch (e) {
|
||||
message.error(t('gateway.cliConnectError', { error: String(e) }));
|
||||
} finally {
|
||||
setConnecting(null);
|
||||
}
|
||||
},
|
||||
[connectCliTool, message, selectedKeyId, selectedProtocol, t],
|
||||
);
|
||||
|
||||
const handleDisconnect = useCallback(
|
||||
async (toolId: string, restoreBackup: boolean) => {
|
||||
setConnecting(toolId);
|
||||
try {
|
||||
await disconnectCliTool(toolId, restoreBackup);
|
||||
const name = CONNECT_ITEMS.find((i) => i.key === toolId)?.name;
|
||||
message.success(t('gateway.cliDisconnectSuccess', { name }));
|
||||
} catch (e) {
|
||||
message.error(t('gateway.cliDisconnectError', { error: String(e) }));
|
||||
} finally {
|
||||
setConnecting(null);
|
||||
}
|
||||
},
|
||||
[disconnectCliTool, message, t],
|
||||
);
|
||||
|
||||
const loadCodexSessionVisibilityStatus = useCallback(async () => {
|
||||
setCodexVisibilityStatusLoading(true);
|
||||
try {
|
||||
const status = await getCodexSessionVisibilityStatus();
|
||||
setCodexVisibilityStatus(status);
|
||||
} catch (e) {
|
||||
message.error(t('gateway.codexRepairVisibilityError', { error: String(e) }));
|
||||
} finally {
|
||||
setCodexVisibilityStatusLoading(false);
|
||||
}
|
||||
}, [getCodexSessionVisibilityStatus, message, t]);
|
||||
|
||||
const handleOpenCodexSessionVisibilityRepair = useCallback(() => {
|
||||
setCodexVisibilityCreateBackup(true);
|
||||
setCodexVisibilityModalOpen(true);
|
||||
void loadCodexSessionVisibilityStatus();
|
||||
}, [loadCodexSessionVisibilityStatus]);
|
||||
|
||||
const handleRepairCodexSessionVisibility = useCallback(async () => {
|
||||
setRepairing('codex');
|
||||
try {
|
||||
const result = await repairCodexSessionVisibility(codexVisibilityCreateBackup);
|
||||
message.success(t('gateway.codexRepairVisibilitySuccess', {
|
||||
provider: result.targetProvider,
|
||||
sessions: result.changedSessionFiles,
|
||||
rows: result.sqliteRowsUpdated,
|
||||
}));
|
||||
if (result.encryptedContentWarning) {
|
||||
message.warning(result.encryptedContentWarning);
|
||||
}
|
||||
await fetchCliToolStatuses();
|
||||
await loadCodexSessionVisibilityStatus();
|
||||
} catch (e) {
|
||||
message.error(t('gateway.codexRepairVisibilityError', { error: String(e) }));
|
||||
} finally {
|
||||
setRepairing(null);
|
||||
}
|
||||
}, [
|
||||
codexVisibilityCreateBackup,
|
||||
fetchCliToolStatuses,
|
||||
loadCodexSessionVisibilityStatus,
|
||||
message,
|
||||
repairCodexSessionVisibility,
|
||||
t,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<Button
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={handleRefresh}
|
||||
loading={cliToolsLoading}
|
||||
>
|
||||
{t('gateway.cliRefresh')}
|
||||
</Button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Select
|
||||
style={{ width: 200 }}
|
||||
value={selectedKeyId}
|
||||
onChange={setSelectedKeyId}
|
||||
placeholder={t('gateway.cliSelectKey')}
|
||||
options={enabledKeys.map((k) => ({
|
||||
value: k.id,
|
||||
label: `${k.name} (${k.key_prefix})`,
|
||||
}))}
|
||||
/>
|
||||
<Select<QuickConnectProtocol>
|
||||
data-testid="gateway-protocol-select"
|
||||
style={{ width: 140 }}
|
||||
value={selectedProtocol}
|
||||
onChange={setSelectedProtocol}
|
||||
placeholder={t('gateway.cliSelectProtocol')}
|
||||
disabled={availableProtocols.length <= 1}
|
||||
options={availableProtocols.map((protocol) => ({
|
||||
value: protocol,
|
||||
label: protocol === 'https' ? t('gateway.cliProtocolHttps') : t('gateway.cliProtocolHttp'),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{quickConnectBlocked && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: 'var(--ant-color-warning-bg)', borderRadius: 6, marginBottom: 4 }}>
|
||||
<AlertCircle size={16} style={{ color: 'var(--ant-color-warning)' }} />
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{t('gateway.cliStartGatewayFirst')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{enabledKeys.length === 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: 'var(--ant-color-warning-bg)', borderRadius: 6, marginBottom: 4 }}>
|
||||
<AlertCircle size={16} style={{ color: 'var(--ant-color-warning)' }} />
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{t('gateway.cliNoKeys')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{CONNECT_ITEMS.map((item) => (
|
||||
<ToolCard
|
||||
key={item.key}
|
||||
item={item}
|
||||
toolInfo={cliTools.find((t) => t.id === item.key)}
|
||||
selectedKeyId={selectedKeyId}
|
||||
selectedProtocol={selectedProtocol}
|
||||
quickConnectBlocked={quickConnectBlocked}
|
||||
onConnect={handleConnect}
|
||||
onDisconnect={handleDisconnect}
|
||||
onOpenCodexSessionVisibilityRepair={handleOpenCodexSessionVisibilityRepair}
|
||||
connecting={connecting}
|
||||
repairing={repairing}
|
||||
/>
|
||||
))}
|
||||
<CodexSessionVisibilityModal
|
||||
open={codexVisibilityModalOpen}
|
||||
loading={codexVisibilityStatusLoading}
|
||||
repairing={repairing === 'codex'}
|
||||
status={codexVisibilityStatus}
|
||||
createBackup={codexVisibilityCreateBackup}
|
||||
onCreateBackupChange={setCodexVisibilityCreateBackup}
|
||||
onCancel={() => setCodexVisibilityModalOpen(false)}
|
||||
onRepair={handleRepairCodexSessionVisibility}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { GatewayDiagnostics } from '../GatewayDiagnostics';
|
||||
|
||||
const fetchRequestLogs = vi.fn();
|
||||
const clearRequestLogs = vi.fn();
|
||||
let requestLogs = [
|
||||
{
|
||||
id: 'log-1',
|
||||
keyId: 'key-1',
|
||||
keyName: 'Gateway Key',
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
model: 'deepseek-chat',
|
||||
providerId: 'provider-1',
|
||||
statusCode: 502,
|
||||
durationMs: 234,
|
||||
requestTokens: 1200,
|
||||
responseTokens: 2300,
|
||||
errorMessage: 'Upstream timeout waiting for provider response.',
|
||||
createdAt: 1_700_000_000,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/gatewayStore', () => ({
|
||||
useGatewayStore: () => ({
|
||||
requestLogs,
|
||||
requestLogsLoading: false,
|
||||
fetchRequestLogs,
|
||||
clearRequestLogs,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('GatewayDiagnostics', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requestLogs = [
|
||||
{
|
||||
id: 'log-1',
|
||||
keyId: 'key-1',
|
||||
keyName: 'Gateway Key',
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
model: 'deepseek-chat',
|
||||
providerId: 'provider-1',
|
||||
statusCode: 502,
|
||||
durationMs: 234,
|
||||
requestTokens: 1200,
|
||||
responseTokens: 2300,
|
||||
errorMessage: 'Upstream timeout waiting for provider response.',
|
||||
createdAt: 1_700_000_000,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
it('only refreshes logs on mount and manual click, without background auto refresh', async () => {
|
||||
const setIntervalSpy = vi.spyOn(global, 'setInterval');
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<GatewayDiagnostics />);
|
||||
|
||||
expect(fetchRequestLogs).toHaveBeenCalledTimes(1);
|
||||
expect(setIntervalSpy).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.refresh' }));
|
||||
expect(fetchRequestLogs).toHaveBeenCalledTimes(2);
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('shows error previews on one line and opens a modal with the full error text', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<GatewayDiagnostics />);
|
||||
|
||||
expect(screen.getAllByText('gateway.logRequestTokens').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('gateway.logResponseTokens').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('gateway.totalTokens').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('1.2k').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('2.3k').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('3.5k').length).toBeGreaterThan(0);
|
||||
|
||||
const errorText = screen.getByText('Upstream timeout waiting for provider response.');
|
||||
expect(errorText.closest('button')).not.toBeNull();
|
||||
expect(errorText).toHaveStyle({
|
||||
color: '#ff4d4f',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
|
||||
await user.click(errorText);
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Upstream timeout waiting for provider response.').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { GatewayMetrics } from '../GatewayMetrics';
|
||||
|
||||
const fetchUsageByDay = vi.fn();
|
||||
const fetchUsageByProvider = vi.fn();
|
||||
const fetchUsageByKey = vi.fn();
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/gatewayStore', () => ({
|
||||
useGatewayStore: () => ({
|
||||
usageByDay: [
|
||||
{
|
||||
date: '2025-03-24',
|
||||
request_count: 3,
|
||||
token_count: 3500,
|
||||
request_tokens: 1200,
|
||||
response_tokens: 2300,
|
||||
},
|
||||
],
|
||||
usageByProvider: [
|
||||
{
|
||||
provider_id: 'provider-1',
|
||||
provider_name: 'DeepSeek',
|
||||
request_count: 3,
|
||||
token_count: 3500,
|
||||
request_tokens: 1200,
|
||||
response_tokens: 2300,
|
||||
},
|
||||
],
|
||||
usageByKey: [
|
||||
{
|
||||
key_id: 'key-1',
|
||||
key_name: 'Gateway Key',
|
||||
request_count: 3,
|
||||
token_count: 3500,
|
||||
request_tokens: 1200,
|
||||
response_tokens: 2300,
|
||||
},
|
||||
],
|
||||
fetchUsageByDay,
|
||||
fetchUsageByProvider,
|
||||
fetchUsageByKey,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('GatewayMetrics', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders split request and response token columns for aggregate tables', () => {
|
||||
render(<GatewayMetrics />);
|
||||
|
||||
expect(screen.getAllByText('gateway.logRequestTokens').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('gateway.logResponseTokens').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('gateway.totalTokens').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('1.2k').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('2.3k').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('3.5k').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { message } from 'antd';
|
||||
import { GatewayOverview } from '../GatewayOverview';
|
||||
|
||||
let status = {
|
||||
is_running: false,
|
||||
listen_address: '127.0.0.1',
|
||||
port: 8000,
|
||||
ssl_enabled: true,
|
||||
started_at: null,
|
||||
https_port: 8443,
|
||||
force_ssl: false,
|
||||
};
|
||||
const startGateway = vi.fn();
|
||||
const stopGateway = vi.fn();
|
||||
const fetchStatus = vi.fn();
|
||||
const fetchMetrics = vi.fn();
|
||||
const fetchRequestLogs = vi.fn();
|
||||
const listRequestLogs = vi.fn();
|
||||
let metrics: Record<string, unknown> | null = null;
|
||||
let requestLogs: Array<Record<string, unknown>> = [];
|
||||
let recentLogsResponse: Array<Record<string, unknown>> = [];
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/gatewayStore', () => ({
|
||||
useGatewayStore: () => ({
|
||||
status,
|
||||
metrics,
|
||||
requestLogs,
|
||||
requestLogsLoading: false,
|
||||
startGateway,
|
||||
stopGateway,
|
||||
fetchStatus,
|
||||
fetchMetrics,
|
||||
fetchRequestLogs,
|
||||
listRequestLogs,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('GatewayOverview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
status = {
|
||||
is_running: false,
|
||||
listen_address: '127.0.0.1',
|
||||
port: 8000,
|
||||
ssl_enabled: true,
|
||||
started_at: null,
|
||||
https_port: 8443,
|
||||
force_ssl: false,
|
||||
};
|
||||
metrics = null;
|
||||
requestLogs = [];
|
||||
recentLogsResponse = [];
|
||||
listRequestLogs.mockImplementation(() => Promise.resolve(recentLogsResponse));
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an error message when starting the gateway fails', async () => {
|
||||
startGateway.mockRejectedValueOnce(new Error('TLS cert missing'));
|
||||
const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => {
|
||||
const noop = () => {};
|
||||
return {
|
||||
then: undefined as never,
|
||||
promise: Promise.resolve(),
|
||||
close: noop,
|
||||
} as never;
|
||||
});
|
||||
|
||||
render(<GatewayOverview />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'gateway.start' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows split request and response tokens in overview cards and recent logs', async () => {
|
||||
metrics = {
|
||||
total_requests: 12,
|
||||
total_tokens: 3500,
|
||||
total_request_tokens: 1200,
|
||||
total_response_tokens: 2300,
|
||||
active_connections: 0,
|
||||
today_requests: 2,
|
||||
today_tokens: 1500,
|
||||
today_request_tokens: 900,
|
||||
today_response_tokens: 600,
|
||||
};
|
||||
requestLogs = [
|
||||
{
|
||||
id: 'log-1',
|
||||
keyId: 'key-1',
|
||||
keyName: 'Gateway Key',
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
model: 'deepseek-chat',
|
||||
providerId: 'provider-1',
|
||||
statusCode: 200,
|
||||
durationMs: 123,
|
||||
requestTokens: 900,
|
||||
responseTokens: 600,
|
||||
errorMessage: null,
|
||||
createdAt: 1_700_000_000,
|
||||
},
|
||||
];
|
||||
recentLogsResponse = [...requestLogs];
|
||||
|
||||
render(<GatewayOverview />);
|
||||
|
||||
await screen.findByText('/v1/chat/completions');
|
||||
|
||||
expect(screen.getAllByText('1.5k').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('900').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('600').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('3.5k').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows only the latest 10 overview logs and exposes a view more action', async () => {
|
||||
const onViewMoreLogs = vi.fn();
|
||||
recentLogsResponse = Array.from({ length: 12 }, (_, index) => ({
|
||||
id: `log-${index + 1}`,
|
||||
keyId: `key-${index + 1}`,
|
||||
keyName: `Gateway Key ${index + 1}`,
|
||||
method: 'POST',
|
||||
path: `/v1/test/${index + 1}`,
|
||||
model: 'deepseek-chat',
|
||||
providerId: 'provider-1',
|
||||
statusCode: 200,
|
||||
durationMs: 100 + index,
|
||||
requestTokens: 10 + index,
|
||||
responseTokens: 20 + index,
|
||||
errorMessage: null,
|
||||
createdAt: 1_700_000_000 + index,
|
||||
}));
|
||||
|
||||
render(<GatewayOverview onViewMoreLogs={onViewMoreLogs} />);
|
||||
|
||||
expect(await screen.findByText('/v1/test/1')).toBeInTheDocument();
|
||||
expect(screen.getByText('/v1/test/10')).toBeInTheDocument();
|
||||
expect(screen.queryByText('/v1/test/11')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('/v1/test/12')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'gateway.viewMoreLogs' }));
|
||||
|
||||
expect(onViewMoreLogs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not auto-refresh recent logs while the gateway is stopped but still allows manual refresh', async () => {
|
||||
const setIntervalSpy = vi.spyOn(global, 'setInterval');
|
||||
recentLogsResponse = [
|
||||
{
|
||||
id: 'log-1',
|
||||
keyId: 'key-1',
|
||||
keyName: 'Gateway Key',
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
model: 'deepseek-chat',
|
||||
providerId: 'provider-1',
|
||||
statusCode: 200,
|
||||
durationMs: 123,
|
||||
requestTokens: 900,
|
||||
responseTokens: 600,
|
||||
errorMessage: null,
|
||||
createdAt: 1_700_000_000,
|
||||
},
|
||||
];
|
||||
|
||||
render(<GatewayOverview />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listRequestLogs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(setIntervalSpy.mock.calls.filter(([, delay]) => delay === 5000)).toHaveLength(1);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'common.refresh' }));
|
||||
expect(listRequestLogs).toHaveBeenCalledTimes(2);
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
import { App } from 'antd';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { GatewayTemplates } from '../GatewayTemplates';
|
||||
|
||||
const fetchStatus = vi.fn();
|
||||
const fetchCliToolStatuses = vi.fn();
|
||||
const fetchKeys = vi.fn();
|
||||
const connectCliTool = vi.fn();
|
||||
const disconnectCliTool = vi.fn();
|
||||
const getCodexSessionVisibilityStatus = vi.fn();
|
||||
const repairCodexSessionVisibility = vi.fn();
|
||||
|
||||
let storeState: Record<string, unknown>;
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@lobehub/icons', () => {
|
||||
const avatar = {
|
||||
Avatar: ({ size }: { size: number }) => <span data-size={size}>avatar</span>,
|
||||
};
|
||||
|
||||
return {
|
||||
ClaudeCode: avatar,
|
||||
Codex: avatar,
|
||||
OpenCode: avatar,
|
||||
Gemini: avatar,
|
||||
Cursor: avatar,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/stores/gatewayStore', () => ({
|
||||
useGatewayStore: () => storeState,
|
||||
}));
|
||||
|
||||
function buildStoreState(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
status: {
|
||||
is_running: true,
|
||||
listen_address: '127.0.0.1',
|
||||
port: 8000,
|
||||
ssl_enabled: true,
|
||||
started_at: null,
|
||||
https_port: 8443,
|
||||
force_ssl: false,
|
||||
},
|
||||
cliTools: [
|
||||
{
|
||||
id: 'claude_code',
|
||||
name: 'Claude Code',
|
||||
status: 'not_connected',
|
||||
configPath: '/configs/claude.json',
|
||||
hasBackup: false,
|
||||
connectedProtocol: null,
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
status: 'connected',
|
||||
configPath: '/configs/codex.json',
|
||||
hasBackup: true,
|
||||
connectedProtocol: 'http',
|
||||
},
|
||||
{
|
||||
id: 'opencode',
|
||||
name: 'OpenCode',
|
||||
status: 'connected',
|
||||
configPath: '/configs/opencode.json',
|
||||
hasBackup: true,
|
||||
connectedProtocol: 'https',
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
name: 'Gemini CLI',
|
||||
status: 'not_installed',
|
||||
configPath: null,
|
||||
hasBackup: false,
|
||||
connectedProtocol: null,
|
||||
},
|
||||
],
|
||||
cliToolsLoading: false,
|
||||
keys: [
|
||||
{
|
||||
id: 'key-1',
|
||||
name: 'Primary Gateway Key',
|
||||
key_hash: 'hash',
|
||||
key_prefix: 'aqb_123',
|
||||
enabled: true,
|
||||
created_at: 1,
|
||||
last_used_at: null,
|
||||
has_encrypted_key: true,
|
||||
},
|
||||
],
|
||||
fetchStatus,
|
||||
fetchCliToolStatuses,
|
||||
fetchKeys,
|
||||
connectCliTool,
|
||||
disconnectCliTool,
|
||||
getCodexSessionVisibilityStatus,
|
||||
repairCodexSessionVisibility,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderWithApp() {
|
||||
return render(
|
||||
<App>
|
||||
<GatewayTemplates />
|
||||
</App>,
|
||||
);
|
||||
}
|
||||
|
||||
async function selectProtocol(protocolLabel: string) {
|
||||
const protocolSelect = screen.getByTestId('gateway-protocol-select');
|
||||
fireEvent.mouseDown(within(protocolSelect).getByRole('combobox'));
|
||||
await userEvent.click(await screen.findByText(protocolLabel));
|
||||
}
|
||||
|
||||
function getToolCard(name: string) {
|
||||
const heading = screen.getByRole('heading', { name, level: 5 });
|
||||
const card = heading.closest('.ant-card');
|
||||
expect(card).not.toBeNull();
|
||||
return card as HTMLElement;
|
||||
}
|
||||
|
||||
describe('GatewayTemplates', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
connectCliTool.mockResolvedValue(undefined);
|
||||
disconnectCliTool.mockResolvedValue(undefined);
|
||||
getCodexSessionVisibilityStatus.mockResolvedValue({
|
||||
targetProvider: 'any',
|
||||
codexHome: '/Users/test/.codex',
|
||||
totalSessionFiles: 5,
|
||||
mismatchedSessionFiles: 2,
|
||||
sqlitePresent: true,
|
||||
sqliteRows: 4,
|
||||
sqliteMismatchedRows: 1,
|
||||
sqliteUserEventRowsNeedingRepair: 1,
|
||||
sqliteCwdRowsNeedingRepair: 0,
|
||||
workspaceRootsNeedingUpdate: 1,
|
||||
statusRows: [
|
||||
{
|
||||
scope: 'sessions',
|
||||
provider: 'any',
|
||||
count: 3,
|
||||
mismatchedCount: 0,
|
||||
status: 'ok',
|
||||
},
|
||||
{
|
||||
scope: 'archived_sessions',
|
||||
provider: 'openai',
|
||||
count: 2,
|
||||
mismatchedCount: 2,
|
||||
status: 'needs_repair',
|
||||
},
|
||||
{
|
||||
scope: 'state_5.sqlite',
|
||||
provider: 'openai',
|
||||
count: 1,
|
||||
mismatchedCount: 1,
|
||||
status: 'needs_repair',
|
||||
},
|
||||
],
|
||||
encryptedContentWarning: 'encrypted_content warning',
|
||||
});
|
||||
repairCodexSessionVisibility.mockResolvedValue({
|
||||
targetProvider: 'any',
|
||||
changedSessionFiles: 2,
|
||||
skippedLockedSessionFiles: 0,
|
||||
sqliteRowsUpdated: 3,
|
||||
sqliteProviderRowsUpdated: 2,
|
||||
sqliteUserEventRowsUpdated: 1,
|
||||
sqliteCwdRowsUpdated: 0,
|
||||
updatedWorkspaceRoots: 1,
|
||||
backupDir: '/tmp/backup',
|
||||
encryptedContentWarning: null,
|
||||
});
|
||||
storeState = buildStoreState();
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches fresh status, lets users switch protocols, and passes the selected protocol to quick connect', async () => {
|
||||
renderWithApp();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(1);
|
||||
expect(fetchCliToolStatuses).toHaveBeenCalledTimes(1);
|
||||
expect(fetchKeys).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(screen.getByText('gateway.cliConnectedHttp')).toBeInTheDocument();
|
||||
expect(screen.getByText('gateway.cliConnectedHttps')).toBeInTheDocument();
|
||||
expect(screen.getByText('gateway.cliNotConnected')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('gateway.cliNotInstalled').length).toBeGreaterThan(0);
|
||||
|
||||
await selectProtocol('gateway.cliProtocolHttps');
|
||||
|
||||
const codexCard = getToolCard('Codex');
|
||||
expect(within(codexCard).getByRole('button', { name: 'gateway.cliSwitchProtocolReconnect' })).toBeInTheDocument();
|
||||
|
||||
const openCodeCard = getToolCard('OpenCode');
|
||||
expect(within(openCodeCard).getByRole('button', { name: 'gateway.cliDisconnect' })).toBeInTheDocument();
|
||||
|
||||
const claudeCard = getToolCard('Claude Code');
|
||||
const quickConnectButton = within(claudeCard).getByRole('button', { name: 'gateway.quickConnect' });
|
||||
await waitFor(() => expect(quickConnectButton).toBeEnabled());
|
||||
await userEvent.click(quickConnectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(connectCliTool).toHaveBeenCalledWith('claude_code', 'key-1', 'https');
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-selects the only available http protocol and disables switching when https is unavailable', async () => {
|
||||
storeState = buildStoreState({
|
||||
status: {
|
||||
is_running: true,
|
||||
listen_address: '127.0.0.1',
|
||||
port: 8000,
|
||||
ssl_enabled: false,
|
||||
started_at: null,
|
||||
https_port: null,
|
||||
force_ssl: false,
|
||||
},
|
||||
cliTools: [
|
||||
{
|
||||
id: 'claude_code',
|
||||
name: 'Claude Code',
|
||||
status: 'not_connected',
|
||||
configPath: '/configs/claude.json',
|
||||
hasBackup: false,
|
||||
connectedProtocol: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderWithApp();
|
||||
|
||||
const protocolSelect = await screen.findByTestId('gateway-protocol-select');
|
||||
expect(protocolSelect).toHaveClass('ant-select-disabled');
|
||||
expect(within(protocolSelect).getByText('gateway.cliProtocolHttp')).toBeInTheDocument();
|
||||
|
||||
const claudeCard = getToolCard('Claude Code');
|
||||
const quickConnectButton = within(claudeCard).getByRole('button', { name: 'gateway.quickConnect' });
|
||||
await waitFor(() => expect(quickConnectButton).toBeEnabled());
|
||||
await userEvent.click(quickConnectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(connectCliTool).toHaveBeenCalledWith('claude_code', 'key-1', 'http');
|
||||
});
|
||||
});
|
||||
|
||||
it('treats force ssl as https-only and offers reconnect when a tool is connected over http', async () => {
|
||||
storeState = buildStoreState({
|
||||
status: {
|
||||
is_running: true,
|
||||
listen_address: '127.0.0.1',
|
||||
port: 8000,
|
||||
ssl_enabled: true,
|
||||
started_at: null,
|
||||
https_port: 8443,
|
||||
force_ssl: true,
|
||||
},
|
||||
});
|
||||
|
||||
renderWithApp();
|
||||
|
||||
const protocolSelect = await screen.findByTestId('gateway-protocol-select');
|
||||
expect(protocolSelect).toHaveClass('ant-select-disabled');
|
||||
expect(within(protocolSelect).getByText('gateway.cliProtocolHttps')).toBeInTheDocument();
|
||||
|
||||
const codexCard = getToolCard('Codex');
|
||||
expect(within(codexCard).getByText('gateway.cliConnectedHttp')).toBeInTheDocument();
|
||||
const reconnectButton = within(codexCard).getByRole('button', { name: 'gateway.cliSwitchProtocolReconnect' });
|
||||
await waitFor(() => expect(reconnectButton).toBeEnabled());
|
||||
await userEvent.click(reconnectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(connectCliTool).toHaveBeenCalledWith('codex', 'key-1', 'https');
|
||||
});
|
||||
});
|
||||
|
||||
it('treats connected tools without a reported protocol as not connected in the UI', async () => {
|
||||
storeState = buildStoreState({
|
||||
cliTools: [
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
status: 'connected',
|
||||
configPath: '/configs/codex.json',
|
||||
hasBackup: true,
|
||||
connectedProtocol: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderWithApp();
|
||||
|
||||
const codexCard = getToolCard('Codex');
|
||||
expect(within(codexCard).getByText('gateway.cliNotConnected')).toBeInTheDocument();
|
||||
expect(within(codexCard).queryByText('gateway.cliConnected')).not.toBeInTheDocument();
|
||||
expect(within(codexCard).getByRole('button', { name: 'gateway.quickConnect' })).toBeInTheDocument();
|
||||
expect(within(codexCard).queryByRole('button', { name: 'gateway.cliDisconnect' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Codex-only tools and repairs session visibility from status modal', async () => {
|
||||
renderWithApp();
|
||||
|
||||
const codexCard = getToolCard('Codex');
|
||||
const claudeCard = getToolCard('Claude Code');
|
||||
const openCodeCard = getToolCard('OpenCode');
|
||||
|
||||
expect(within(codexCard).getByRole('button', { name: 'gateway.cliTools' })).toBeInTheDocument();
|
||||
expect(within(claudeCard).queryByRole('button', { name: 'gateway.cliTools' })).not.toBeInTheDocument();
|
||||
expect(within(openCodeCard).queryByRole('button', { name: 'gateway.cliTools' })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(within(codexCard).getByRole('button', { name: 'gateway.cliTools' }));
|
||||
const repairMenuItem = await screen.findByRole('menuitem', { name: 'gateway.codexRepairVisibility' });
|
||||
expect(repairMenuItem.querySelector('.lucide-wrench')).toBeInTheDocument();
|
||||
await userEvent.click(repairMenuItem);
|
||||
|
||||
expect(await screen.findByText('gateway.codexRepairVisibilityModalTitle')).toBeInTheDocument();
|
||||
expect(getCodexSessionVisibilityStatus).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('gateway.codexRepairVisibilityCurrentStatus')).toBeInTheDocument();
|
||||
expect(screen.getByText('gateway.codexRepairVisibilityMismatchSummary')).toBeInTheDocument();
|
||||
expect(screen.getByText('archived_sessions')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('openai').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('encrypted_content warning')).toBeInTheDocument();
|
||||
expect(screen.getByRole('checkbox', { name: 'gateway.codexRepairVisibilityCreateBackup' })).toBeChecked();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'gateway.codexRepairVisibilityConfirmOk' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(repairCodexSessionVisibility).toHaveBeenCalledWith(true);
|
||||
});
|
||||
await waitFor(() => expect(getCodexSessionVisibilityStatus).toHaveBeenCalledTimes(2));
|
||||
expect(fetchCliToolStatuses).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('passes the unchecked backup option when repairing Codex session visibility', async () => {
|
||||
renderWithApp();
|
||||
|
||||
const codexCard = getToolCard('Codex');
|
||||
await userEvent.click(within(codexCard).getByRole('button', { name: 'gateway.cliTools' }));
|
||||
await userEvent.click(await screen.findByText('gateway.codexRepairVisibility'));
|
||||
|
||||
const backupCheckbox = await screen.findByRole('checkbox', { name: 'gateway.codexRepairVisibilityCreateBackup' });
|
||||
await userEvent.click(backupCheckbox);
|
||||
expect(backupCheckbox).not.toBeChecked();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'gateway.codexRepairVisibilityConfirmOk' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(repairCodexSessionVisibility).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a warning and disables quick connect actions while the gateway is stopped', async () => {
|
||||
storeState = buildStoreState({
|
||||
status: {
|
||||
is_running: false,
|
||||
listen_address: '127.0.0.1',
|
||||
port: 8000,
|
||||
ssl_enabled: true,
|
||||
started_at: null,
|
||||
https_port: 8443,
|
||||
force_ssl: false,
|
||||
},
|
||||
});
|
||||
|
||||
renderWithApp();
|
||||
|
||||
expect(screen.getByText('gateway.cliStartGatewayFirst')).toBeInTheDocument();
|
||||
|
||||
const claudeCard = getToolCard('Claude Code');
|
||||
await waitFor(() => {
|
||||
expect(within(claudeCard).getByRole('button', { name: 'gateway.quickConnect' })).toBeDisabled();
|
||||
});
|
||||
|
||||
await selectProtocol('gateway.cliProtocolHttps');
|
||||
|
||||
const codexCard = getToolCard('Codex');
|
||||
await waitFor(() => {
|
||||
expect(within(codexCard).getByRole('button', { name: 'gateway.cliSwitchProtocolReconnect' })).toBeDisabled();
|
||||
});
|
||||
|
||||
const protocolSelect = screen.getByTestId('gateway-protocol-select');
|
||||
expect(protocolSelect).not.toHaveClass('ant-select-disabled');
|
||||
expect(screen.getByText('Primary Gateway Key (aqb_123)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export { GatewayOverview } from './GatewayOverview';
|
||||
export { GatewayKeys } from './GatewayKeys';
|
||||
export { GatewayMetrics } from './GatewayMetrics';
|
||||
export { GatewaySettings } from './GatewaySettings';
|
||||
export { GatewayDiagnostics } from './GatewayDiagnostics';
|
||||
export { GatewayTemplates, QuickConnectCycleIcon } from './GatewayTemplates';
|
||||
@@ -0,0 +1,29 @@
|
||||
function formatCompact(value: number, divisor: number, suffix: string): string {
|
||||
const compact = Math.round((value / divisor) * 10) / 10;
|
||||
const formatted = Number.isInteger(compact) ? compact.toFixed(0) : compact.toFixed(1);
|
||||
return `${formatted}${suffix}`;
|
||||
}
|
||||
|
||||
export function formatTokenCount(value: number): string {
|
||||
const abs = Math.abs(value);
|
||||
if (abs >= 1_000_000) return formatCompact(value, 1_000_000, 'm');
|
||||
if (abs >= 1_000) return formatCompact(value, 1_000, 'k');
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
/** Format milliseconds into human-readable duration: 100ms, 1.1s, 1m2s */
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
if (ms < 60_000) {
|
||||
const s = ms / 1000;
|
||||
return `${Number.isInteger(s) ? s.toFixed(0) : s.toFixed(1)}s`;
|
||||
}
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const s = Math.round((ms % 60_000) / 1000);
|
||||
return s > 0 ? `${m}m${s}s` : `${m}m`;
|
||||
}
|
||||
|
||||
/** Format tokens per second: 28.5 tok/s */
|
||||
export function formatSpeed(tps: number): string {
|
||||
return `${tps < 10 ? tps.toFixed(1) : Math.round(tps)} tok/s`;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { Modal, Input, List, Tag, Typography, theme } from 'antd';
|
||||
import { Search, MessageSquare, Settings, Network, Plus, PanelLeftClose, Sparkles, ImagePlus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUIStore } from '@/stores';
|
||||
import { formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
|
||||
export interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface Command {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
shortcut?: string;
|
||||
category: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export default function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const setActivePage = useUIStore((s) => s.setActivePage);
|
||||
const setSettingsSection = useUIStore((s) => s.setSettingsSection);
|
||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||
const commands = useMemo<Command[]>(() => {
|
||||
const nav = t('commandPalette.navigation');
|
||||
const actions = t('commandPalette.actions');
|
||||
const settings = t('commandPalette.settings');
|
||||
const openSettingsShortcut = formatShortcutForDisplay('CmdOrCtrl+,');
|
||||
const newConversationShortcut = formatShortcutForDisplay('CmdOrCtrl+N');
|
||||
const searchConversationsShortcut = formatShortcutForDisplay('CmdOrCtrl+F');
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'go-chat',
|
||||
label: t('commandPalette.goToChat'),
|
||||
icon: <MessageSquare size={16} />,
|
||||
category: nav,
|
||||
action: () => { setActivePage('chat'); onClose(); },
|
||||
},
|
||||
{
|
||||
id: 'go-settings',
|
||||
label: t('commandPalette.goToSettings'),
|
||||
icon: <Settings size={16} />,
|
||||
shortcut: openSettingsShortcut,
|
||||
category: nav,
|
||||
action: () => { setActivePage('settings'); onClose(); },
|
||||
},
|
||||
{
|
||||
id: 'go-drawing',
|
||||
label: t('commandPalette.goToDrawing'),
|
||||
icon: <ImagePlus size={16} />,
|
||||
category: nav,
|
||||
action: () => { setActivePage('drawing'); onClose(); },
|
||||
},
|
||||
{
|
||||
id: 'go-gateway',
|
||||
label: t('commandPalette.goToGateway'),
|
||||
icon: <Network size={16} />,
|
||||
category: nav,
|
||||
action: () => { setActivePage('gateway'); onClose(); },
|
||||
},
|
||||
{
|
||||
id: 'go-skills',
|
||||
label: t('commandPalette.goToSkills'),
|
||||
icon: <Sparkles size={16} />,
|
||||
category: nav,
|
||||
action: () => { setActivePage('skills'); onClose(); },
|
||||
},
|
||||
{
|
||||
id: 'new-conversation',
|
||||
label: t('commandPalette.newConversation'),
|
||||
icon: <Plus size={16} />,
|
||||
shortcut: newConversationShortcut,
|
||||
category: actions,
|
||||
action: () => { setActivePage('chat'); onClose(); },
|
||||
},
|
||||
{
|
||||
id: 'toggle-sidebar',
|
||||
label: t('commandPalette.toggleSidebar'),
|
||||
icon: <PanelLeftClose size={16} />,
|
||||
category: actions,
|
||||
action: () => { toggleSidebar(); onClose(); },
|
||||
},
|
||||
{
|
||||
id: 'search-conversations',
|
||||
label: t('commandPalette.searchConversations'),
|
||||
icon: <Search size={16} />,
|
||||
shortcut: searchConversationsShortcut,
|
||||
category: actions,
|
||||
action: () => { setActivePage('chat'); onClose(); },
|
||||
},
|
||||
{
|
||||
id: 'settings-search',
|
||||
label: `${t('commandPalette.goToSettings')} → ${t('settings.searchProviders.title')}`,
|
||||
icon: <Settings size={16} />,
|
||||
category: settings,
|
||||
action: () => { setActivePage('settings'); setSettingsSection('searchProviders'); onClose(); },
|
||||
},
|
||||
{
|
||||
id: 'settings-mcp',
|
||||
label: `${t('commandPalette.goToSettings')} → ${t('settings.mcpServers.title')}`,
|
||||
icon: <Settings size={16} />,
|
||||
category: settings,
|
||||
action: () => { setActivePage('settings'); setSettingsSection('mcpServers'); onClose(); },
|
||||
},
|
||||
];
|
||||
}, [t, setActivePage, setSettingsSection, toggleSidebar, onClose]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return commands;
|
||||
const q = query.toLowerCase();
|
||||
return commands.filter(
|
||||
(c) => c.label.toLowerCase().includes(q) || c.category.toLowerCase().includes(q),
|
||||
);
|
||||
}, [commands, query]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('');
|
||||
setActiveIndex(0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev + 1) % filtered.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
|
||||
} else if (e.key === 'Enter' && filtered.length > 0) {
|
||||
e.preventDefault();
|
||||
filtered[activeIndex]?.action();
|
||||
}
|
||||
},
|
||||
[filtered, activeIndex],
|
||||
);
|
||||
|
||||
// Group commands by category for display
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<string, Command[]> = {};
|
||||
for (const cmd of filtered) {
|
||||
if (!groups[cmd.category]) groups[cmd.category] = [];
|
||||
groups[cmd.category].push(cmd);
|
||||
}
|
||||
return groups;
|
||||
}, [filtered]);
|
||||
|
||||
let flatIndex = 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
mask={{ enabled: true, blur: true }}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
width={600}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<div onKeyDown={handleKeyDown}>
|
||||
<Input
|
||||
prefix={<Search size={16} />}
|
||||
placeholder={t('commandPalette.placeholder')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
variant="borderless"
|
||||
size="large"
|
||||
autoFocus
|
||||
style={{ padding: '12px 16px' }}
|
||||
/>
|
||||
<div
|
||||
ref={listRef}
|
||||
data-os-scrollbar
|
||||
style={{
|
||||
maxHeight: 400,
|
||||
overflowY: 'auto',
|
||||
borderTop: '1px solid var(--border-color)',
|
||||
}}
|
||||
>
|
||||
{Object.entries(grouped).map(([category, cmds]) => (
|
||||
<div key={category}>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: '8px 16px 4px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{category}
|
||||
</Typography.Text>
|
||||
<List
|
||||
dataSource={cmds}
|
||||
renderItem={(cmd) => {
|
||||
const idx = flatIndex++;
|
||||
const isActive = idx === activeIndex;
|
||||
return (
|
||||
<List.Item
|
||||
key={cmd.id}
|
||||
onClick={cmd.action}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: isActive ? token.colorBgTextHover : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16 }}>{cmd.icon}</span>
|
||||
<span style={{ flex: 1 }}>{cmd.label}</span>
|
||||
{cmd.shortcut && (
|
||||
<Tag style={{ margin: 0 }}>{cmd.shortcut}</Tag>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<Typography.Text type="secondary">{t('common.noData')}</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { PageKey } from '@/types';
|
||||
import { ChatPage } from '@/pages/ChatPage';
|
||||
import { DrawingPage } from '@/pages/DrawingPage';
|
||||
import { KnowledgePage } from '@/pages/KnowledgePage';
|
||||
import { MemoryPage } from '@/pages/MemoryPage';
|
||||
import { GatewayPage } from '@/pages/GatewayPage';
|
||||
import { FilesPage } from '@/pages/FilesPage';
|
||||
import { SettingsPage } from '@/pages/SettingsPage';
|
||||
import { SkillsPage } from '@/pages/SkillsPage';
|
||||
|
||||
interface ContentAreaProps {
|
||||
activePage: PageKey;
|
||||
}
|
||||
|
||||
export function ContentArea({ activePage }: ContentAreaProps) {
|
||||
switch (activePage) {
|
||||
case 'chat':
|
||||
return <ChatPage />;
|
||||
case 'drawing':
|
||||
return <DrawingPage />;
|
||||
case 'knowledge':
|
||||
return <KnowledgePage />;
|
||||
case 'memory':
|
||||
return <MemoryPage />;
|
||||
case 'gateway':
|
||||
return <GatewayPage />;
|
||||
case 'files':
|
||||
return <FilesPage />;
|
||||
case 'settings':
|
||||
return <SettingsPage />;
|
||||
case 'skills':
|
||||
return <SkillsPage />;
|
||||
default: {
|
||||
const _exhaustive: never = activePage;
|
||||
throw new Error(`Unhandled page key: ${_exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
|
||||
import { Copy, TextCursorInput, Bug, Scissors, ClipboardPaste, BoxSelect } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { theme, message } from 'antd';
|
||||
import { useConversationStore } from '@/stores';
|
||||
import {
|
||||
loadDevtoolsContextMenuEnabled,
|
||||
openDevtoolsWithDiagnostics,
|
||||
} from '@/lib/desktopCapabilities';
|
||||
|
||||
/**
|
||||
* Global right-click context menu.
|
||||
* - On textarea/input → Cut + Copy + Paste + Select All + (DevTools in dev)
|
||||
* - Text selected → Copy + (Fill to Input in chat) + (DevTools in dev)
|
||||
* - No text, dev mode → DevTools only
|
||||
* - No text, prod mode → suppress native menu
|
||||
* - Skips when a component-specific context menu already handled the event.
|
||||
*/
|
||||
export function GlobalCopyMenu() {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
const [inChatMessages, setInChatMessages] = useState(false);
|
||||
const [isTextInput, setIsTextInput] = useState(false);
|
||||
const selectedTextRef = useRef('');
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const targetInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
||||
const selectionRangeRef = useRef<{ start: number; end: number }>({ start: 0, end: 0 });
|
||||
const activeConversationId = useConversationStore((s) => s.activeConversationId);
|
||||
const isDev = import.meta.env.DEV;
|
||||
const [devtoolsEnabled, setDevtoolsEnabled] = useState(isDev);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void loadDevtoolsContextMenuEnabled(isDev).then((enabled) => {
|
||||
if (!cancelled) {
|
||||
setDevtoolsEnabled(enabled);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isDev]);
|
||||
|
||||
useEffect(() => {
|
||||
// Save textarea selection before Chromium's auto word-selection on right-click
|
||||
const savedSelRef = { start: -1, end: -1, el: null as HTMLTextAreaElement | HTMLInputElement | null };
|
||||
|
||||
const saveSelectionOnRightClick = (e: MouseEvent) => {
|
||||
if (e.button !== 2) return;
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.tagName === 'TEXTAREA' || (t.tagName === 'INPUT' && (t as HTMLInputElement).type === 'text')) {
|
||||
const input = t as HTMLTextAreaElement | HTMLInputElement;
|
||||
savedSelRef.start = input.selectionStart ?? 0;
|
||||
savedSelRef.end = input.selectionEnd ?? 0;
|
||||
savedSelRef.el = input;
|
||||
} else {
|
||||
savedSelRef.el = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
if (menuRef.current?.contains(e.target as Node)) return;
|
||||
e.preventDefault();
|
||||
|
||||
const targetEl = e.target as HTMLElement;
|
||||
const isInput = targetEl.tagName === 'TEXTAREA' || (targetEl.tagName === 'INPUT' && (targetEl as HTMLInputElement).type === 'text');
|
||||
|
||||
if (isInput) {
|
||||
const inputEl = targetEl as HTMLTextAreaElement | HTMLInputElement;
|
||||
targetInputRef.current = inputEl;
|
||||
|
||||
// Use saved selection (before auto word-select) if available
|
||||
const start = savedSelRef.el === inputEl ? savedSelRef.start : (inputEl.selectionStart ?? 0);
|
||||
const end = savedSelRef.el === inputEl ? savedSelRef.end : (inputEl.selectionEnd ?? 0);
|
||||
selectionRangeRef.current = { start, end };
|
||||
|
||||
// Restore original selection to undo Chromium's auto word-select (deferred)
|
||||
inputEl.setSelectionRange(start, end);
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.setSelectionRange(start, end);
|
||||
});
|
||||
|
||||
const sel = inputEl.value.substring(start, end);
|
||||
selectedTextRef.current = sel;
|
||||
setHasSelection(!!sel);
|
||||
setIsTextInput(true);
|
||||
setInChatMessages(false);
|
||||
setMenuPos({ x: e.clientX, y: e.clientY });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTextInput(false);
|
||||
targetInputRef.current = null;
|
||||
|
||||
const sel = window.getSelection()?.toString().trim() || '';
|
||||
selectedTextRef.current = sel;
|
||||
|
||||
// "Fill to input" only when right-clicking inside chat message area
|
||||
const inMessageArea = !!(e.target as HTMLElement).closest?.('[data-message-area]');
|
||||
setInChatMessages(inMessageArea);
|
||||
|
||||
if (sel) {
|
||||
setHasSelection(true);
|
||||
setMenuPos({ x: e.clientX, y: e.clientY });
|
||||
} else if (devtoolsEnabled) {
|
||||
setHasSelection(false);
|
||||
setMenuPos({ x: e.clientX, y: e.clientY });
|
||||
} else {
|
||||
setMenuPos(null);
|
||||
}
|
||||
};
|
||||
|
||||
const dismissHandler = () => setMenuPos(null);
|
||||
|
||||
document.addEventListener('mousedown', saveSelectionOnRightClick, true);
|
||||
document.addEventListener('contextmenu', handler);
|
||||
document.addEventListener('click', dismissHandler);
|
||||
document.addEventListener('scroll', dismissHandler, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', saveSelectionOnRightClick, true);
|
||||
document.removeEventListener('contextmenu', handler);
|
||||
document.removeEventListener('click', dismissHandler);
|
||||
document.removeEventListener('scroll', dismissHandler, true);
|
||||
};
|
||||
}, [devtoolsEnabled]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
const text = selectedTextRef.current;
|
||||
if (text) {
|
||||
void navigator.clipboard.writeText(text);
|
||||
message.success(t('common.copySuccess'));
|
||||
}
|
||||
setMenuPos(null);
|
||||
}, [t]);
|
||||
|
||||
const handleCut = useCallback(() => {
|
||||
const input = targetInputRef.current;
|
||||
if (!input) { setMenuPos(null); return; }
|
||||
const { start, end } = selectionRangeRef.current;
|
||||
const selectedText = input.value.substring(start, end);
|
||||
if (selectedText) {
|
||||
void navigator.clipboard.writeText(selectedText);
|
||||
input.focus();
|
||||
input.setSelectionRange(start, end);
|
||||
document.execCommand('delete');
|
||||
}
|
||||
setMenuPos(null);
|
||||
}, []);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const input = targetInputRef.current;
|
||||
if (!input) { setMenuPos(null); return; }
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) {
|
||||
input.focus();
|
||||
const { start, end } = selectionRangeRef.current;
|
||||
input.setSelectionRange(start, end);
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Paste failed:', err);
|
||||
}
|
||||
setMenuPos(null);
|
||||
}, []);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const input = targetInputRef.current;
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
setMenuPos(null);
|
||||
}, []);
|
||||
|
||||
const handleFillInput = useCallback(() => {
|
||||
const text = selectedTextRef.current;
|
||||
if (text) {
|
||||
window.dispatchEvent(new CustomEvent('boosapi:fill-input', { detail: text }));
|
||||
}
|
||||
setMenuPos(null);
|
||||
}, []);
|
||||
|
||||
const handleOpenDevtools = useCallback(() => {
|
||||
void openDevtoolsWithDiagnostics();
|
||||
setMenuPos(null);
|
||||
}, []);
|
||||
|
||||
// Clamp menu position to stay within viewport
|
||||
useLayoutEffect(() => {
|
||||
if (!menuRef.current || !menuPos) return;
|
||||
const el = menuRef.current;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let x = menuPos.x;
|
||||
let y = menuPos.y;
|
||||
|
||||
if (x + rect.width > vw) x = vw - rect.width - 4;
|
||||
if (y + rect.height > vh) y = vh - rect.height - 4;
|
||||
if (x < 4) x = 4;
|
||||
if (y < 4) y = 4;
|
||||
|
||||
el.style.left = x + 'px';
|
||||
el.style.top = y + 'px';
|
||||
}, [menuPos, isTextInput, hasSelection]);
|
||||
|
||||
if (!menuPos) return null;
|
||||
|
||||
interface MenuItem {
|
||||
key: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
const items: MenuItem[] = [];
|
||||
|
||||
if (isTextInput) {
|
||||
items.push(
|
||||
{ key: 'cut', icon: <Scissors size={14} />, label: t('common.cut'), onClick: handleCut, disabled: !hasSelection },
|
||||
{ key: 'copy', icon: <Copy size={14} />, label: t('common.copy'), onClick: handleCopy, disabled: !hasSelection },
|
||||
{ key: 'paste', icon: <ClipboardPaste size={14} />, label: t('common.paste'), onClick: handlePaste },
|
||||
{ key: 'selectAll', icon: <BoxSelect size={14} />, label: t('common.selectAll'), onClick: handleSelectAll, divider: true },
|
||||
);
|
||||
} else {
|
||||
if (hasSelection) {
|
||||
items.push(
|
||||
{ key: 'copy', icon: <Copy size={14} />, label: t('common.copy'), onClick: handleCopy },
|
||||
);
|
||||
if (activeConversationId && inChatMessages) {
|
||||
items.push({
|
||||
key: 'fill',
|
||||
icon: <TextCursorInput size={14} />,
|
||||
label: t('common.fillToInput'),
|
||||
onClick: handleFillInput,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (devtoolsEnabled) {
|
||||
items.push({
|
||||
key: 'devtools',
|
||||
icon: <Bug size={14} />,
|
||||
label: t('common.openDevtools'),
|
||||
onClick: handleOpenDevtools,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: menuPos.x,
|
||||
top: menuPos.y,
|
||||
zIndex: 9999,
|
||||
backgroundColor: token.colorBgElevated,
|
||||
borderRadius: 8,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
padding: '4px',
|
||||
minWidth: 120,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.key}>
|
||||
{item.divider && (
|
||||
<div style={{ height: 1, backgroundColor: token.colorBorderSecondary, margin: '4px 8px' }} />
|
||||
)}
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
color: item.disabled ? token.colorTextDisabled : token.colorText,
|
||||
cursor: item.disabled ? 'default' : 'pointer',
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
onClick={item.disabled ? undefined : item.onClick}
|
||||
onMouseEnter={(e) => { if (!item.disabled) (e.currentTarget as HTMLElement).style.backgroundColor = token.colorFillSecondary; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react';
|
||||
import { Tooltip, Avatar, theme } from 'antd';
|
||||
import { MessageSquare, BookOpen, Brain, Router, FolderOpen, User, Sparkles, ImagePlus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUIStore, useSettingsStore } from '@/stores';
|
||||
import { useUserProfileStore } from '@/stores/userProfileStore';
|
||||
import { getShortcutBinding, formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
import type { ShortcutAction } from '@/lib/shortcuts';
|
||||
import { useResolvedAvatarSrc } from '@/hooks/useResolvedAvatarSrc';
|
||||
import { UserProfileModal } from './UserProfileModal';
|
||||
import type { PageKey } from '@/types';
|
||||
|
||||
const mainNavItems: { key: PageKey; icon: React.ReactNode; labelKey: string }[] = [
|
||||
{ key: 'chat', icon: <MessageSquare size={18} />, labelKey: 'nav.chat' },
|
||||
{ key: 'drawing', icon: <ImagePlus size={18} />, labelKey: 'nav.drawing' },
|
||||
{ key: 'skills', icon: <Sparkles size={18} />, labelKey: 'nav.skills' },
|
||||
{ key: 'knowledge', icon: <BookOpen size={18} />, labelKey: 'nav.knowledge' },
|
||||
{ key: 'memory', icon: <Brain size={18} />, labelKey: 'nav.memory' },
|
||||
{ key: 'gateway', icon: <Router size={18} />, labelKey: 'nav.gateway' },
|
||||
{ key: 'files', icon: <FolderOpen size={18} />, labelKey: 'nav.files' },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const activePage = useUIStore((s) => s.activePage);
|
||||
const setActivePage = useUIStore((s) => s.setActivePage);
|
||||
const profile = useUserProfileStore((s) => s.profile);
|
||||
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
||||
const resolvedAvatarSrc = useResolvedAvatarSrc(profile.avatarType, profile.avatarValue);
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
|
||||
const NAV_SHORTCUT_MAP: Partial<Record<PageKey, ShortcutAction>> = {
|
||||
gateway: 'toggleGateway',
|
||||
};
|
||||
|
||||
const renderNavButton = (item: { key: PageKey; icon: React.ReactNode; labelKey: string }) => {
|
||||
const isActive = activePage === item.key;
|
||||
const label = t(item.labelKey);
|
||||
const action = NAV_SHORTCUT_MAP[item.key];
|
||||
const title = action
|
||||
? `${label} (${formatShortcutForDisplay(getShortcutBinding(settings, action))})`
|
||||
: label;
|
||||
return (
|
||||
<Tooltip key={item.key} title={title} placement="right">
|
||||
<button
|
||||
onClick={() => setActivePage(item.key)}
|
||||
className="flex items-center justify-center text-base transition-colors"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: isActive ? token.colorPrimaryBg : 'transparent',
|
||||
color: isActive ? token.colorPrimary : token.colorTextSecondary,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = token.colorFillSecondary;
|
||||
e.currentTarget.style.color = token.colorTextBase;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = token.colorTextSecondary;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const renderUserAvatar = () => {
|
||||
const size = 32;
|
||||
if (profile.avatarType === 'emoji' && profile.avatarValue) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: token.colorFillSecondary,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 16,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{profile.avatarValue}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if ((profile.avatarType === 'url' || profile.avatarType === 'file') && profile.avatarValue) {
|
||||
const src = profile.avatarType === 'file' ? resolvedAvatarSrc : profile.avatarValue;
|
||||
return <Avatar size={size} src={src} style={{ cursor: 'pointer' }} />;
|
||||
}
|
||||
return (
|
||||
<Avatar
|
||||
size={size}
|
||||
icon={<User size={16} />}
|
||||
style={{ cursor: 'pointer', backgroundColor: token.colorPrimary }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center h-full" style={{ paddingTop: 8, paddingBottom: 12 }}>
|
||||
<nav className="flex flex-col gap-2">
|
||||
{mainNavItems.map(renderNavButton)}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* User Avatar */}
|
||||
<Tooltip title={profile.name || t('userProfile.title')} placement="right">
|
||||
<button
|
||||
onClick={() => setProfileModalOpen(true)}
|
||||
style={{ background: 'none', border: 'none', padding: 0 }}
|
||||
>
|
||||
{renderUserAvatar()}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<UserProfileModal open={profileModalOpen} onClose={() => setProfileModalOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
import { useCallback, useRef, useEffect, useState } from 'react';
|
||||
import { Dropdown, Tooltip, App, theme, Popover, Divider, Typography, Space, Spin } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { Settings, XCircle, Sun, Moon, Monitor, Globe, Pin, PinOff, RotateCcw, CloudUpload, Github, Star, MessageSquarePlus, Bug, ArrowDownCircle, Minus, X, Square } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUIStore, useSettingsStore } from '@/stores';
|
||||
import { useBackupStore } from '@/stores/backupStore';
|
||||
import { isTauri, invoke } from '@/lib/invoke';
|
||||
import { getShortcutBinding, formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
import { useUpdateChecker } from '@/hooks/useUpdateChecker';
|
||||
import appLogo from '@/assets/image/logo.png';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
|
||||
const IS_WINDOWS = navigator.userAgent.includes('Windows');
|
||||
|
||||
/** Standard Windows "restore down" icon: two overlapping rectangles */
|
||||
const RestoreIcon = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2">
|
||||
<rect x="3" y="5" width="8" height="7" rx="0.5" />
|
||||
<path d="M5 5V3.5a.5.5 0 0 1 .5-.5H12a.5.5 0 0 1 .5.5V10a.5.5 0 0 1-.5.5h-1.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ key: 'system', icon: <Monitor size={14} />, labelKey: 'settings.themeSystem' },
|
||||
{ key: 'light', icon: <Sun size={14} />, labelKey: 'settings.themeLight' },
|
||||
{ key: 'dark', icon: <Moon size={14} />, labelKey: 'settings.themeDark' },
|
||||
] as const;
|
||||
|
||||
const THEME_ICONS: Record<string, React.ReactNode> = {
|
||||
system: <Monitor size={14} />,
|
||||
light: <Sun size={14} />,
|
||||
dark: <Moon size={14} />,
|
||||
};
|
||||
|
||||
import { LANG_OPTIONS } from '@/lib/constants';
|
||||
|
||||
|
||||
export function TitleBar() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const { modal, message } = App.useApp();
|
||||
const activePage = useUIStore((s) => s.activePage);
|
||||
const enterSettings = useUIStore((s) => s.enterSettings);
|
||||
const exitSettings = useUIStore((s) => s.exitSettings);
|
||||
const themeMode = useSettingsStore((s) => s.settings.theme_mode);
|
||||
const alwaysOnTop = useSettingsStore((s) => s.settings.always_on_top);
|
||||
const saveSettings = useSettingsStore((s) => s.saveSettings);
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
|
||||
const isInSettings = activePage === 'settings';
|
||||
const [pinned, setPinned] = useState(alwaysOnTop ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
setPinned(alwaysOnTop ?? false);
|
||||
}, [alwaysOnTop]);
|
||||
|
||||
const handlePinToggle = useCallback(async () => {
|
||||
const next = !pinned;
|
||||
setPinned(next);
|
||||
try {
|
||||
await invoke('set_always_on_top', { enabled: next });
|
||||
saveSettings({ always_on_top: next });
|
||||
} catch {
|
||||
setPinned(!next);
|
||||
}
|
||||
}, [pinned, saveSettings]);
|
||||
|
||||
const { checkForUpdate } = useUpdateChecker();
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||
const handleCheckUpdate = useCallback(async () => {
|
||||
setCheckingUpdate(true);
|
||||
try {
|
||||
await checkForUpdate();
|
||||
} finally {
|
||||
setCheckingUpdate(false);
|
||||
}
|
||||
}, [checkForUpdate]);
|
||||
|
||||
const themeMenuItems: MenuProps['items'] = THEME_OPTIONS.map((opt) => ({
|
||||
key: opt.key,
|
||||
icon: opt.icon,
|
||||
label: t(opt.labelKey),
|
||||
}));
|
||||
|
||||
const langMenuItems: MenuProps['items'] = LANG_OPTIONS.map((opt) => ({
|
||||
key: opt.key,
|
||||
icon: <span>{opt.icon}</span>,
|
||||
label: opt.label,
|
||||
}));
|
||||
|
||||
const handleThemeChange: MenuProps['onClick'] = ({ key }) => {
|
||||
saveSettings({ theme_mode: key });
|
||||
};
|
||||
|
||||
const handleLangChange: MenuProps['onClick'] = ({ key }) => {
|
||||
i18n.changeLanguage(key);
|
||||
saveSettings({ language: key });
|
||||
};
|
||||
|
||||
const handleSettingsToggle = () => {
|
||||
if (isInSettings) {
|
||||
exitSettings();
|
||||
} else {
|
||||
enterSettings();
|
||||
}
|
||||
};
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
modal.confirm({
|
||||
title: t('desktop.reloadConfirmTitle'),
|
||||
content: t('desktop.reloadConfirmContent'),
|
||||
okText: t('desktop.reloadConfirmOk'),
|
||||
cancelText: t('desktop.reloadConfirmCancel'),
|
||||
onOk: () => {
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
}, [modal, t]);
|
||||
|
||||
// Windows window controls
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_WINDOWS || !isTauri()) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
(async () => {
|
||||
const win = getCurrentWindow();
|
||||
setIsMaximized(await win.isMaximized());
|
||||
unlisten = await win.onResized(async () => {
|
||||
setIsMaximized(await win.isMaximized());
|
||||
});
|
||||
})();
|
||||
return () => { unlisten?.(); };
|
||||
}, []);
|
||||
|
||||
const handleWindowMinimize = useCallback(async () => {
|
||||
await invoke('minimize_window');
|
||||
}, []);
|
||||
|
||||
const handleWindowMaximize = useCallback(async () => {
|
||||
await invoke('toggle_maximize_window');
|
||||
}, []);
|
||||
|
||||
const handleWindowClose = useCallback(async () => {
|
||||
await getCurrentWindow().close();
|
||||
}, []);
|
||||
|
||||
// Quick Backup state
|
||||
const [backupPopoverOpen, setBackupPopoverOpen] = useState(false);
|
||||
const [backingUp, setBackingUp] = useState<'local' | 'webdav' | null>(null);
|
||||
const [lastLocalBackup, setLastLocalBackup] = useState<string | null>(null);
|
||||
const [lastWebDavSync, setLastWebDavSync] = useState<string | null>(null);
|
||||
// Timestamps (ms) for next scheduled backups
|
||||
const [nextLocalTs, setNextLocalTs] = useState<number | null>(null);
|
||||
const [nextWebDavTs, setNextWebDavTs] = useState<number | null>(null);
|
||||
// Live countdown strings (updated every second)
|
||||
const [countdownText, setCountdownText] = useState<string | null>(null);
|
||||
const [popoverLocalCountdown, setPopoverLocalCountdown] = useState<string | null>(null);
|
||||
const [popoverWebDavCountdown, setPopoverWebDavCountdown] = useState<string | null>(null);
|
||||
|
||||
const { backupSettings, loadBackupSettings } = useBackupStore();
|
||||
|
||||
const fmtCountdown = (ms: number) => {
|
||||
if (ms <= 0) return t('titlebar.now');
|
||||
const h = Math.floor(ms / 3600000);
|
||||
const m = Math.floor((ms % 3600000) / 60000);
|
||||
const s = Math.floor((ms % 60000) / 1000);
|
||||
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Fetch backup info on mount and when popover opens
|
||||
useEffect(() => {
|
||||
loadBackupSettings();
|
||||
|
||||
invoke<{ lastSyncTime: string | null }>('get_webdav_sync_status')
|
||||
.then((s) => {
|
||||
if (s.lastSyncTime) {
|
||||
const d = new Date(s.lastSyncTime);
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
setLastWebDavSync(d.toLocaleString());
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
invoke<Array<{ createdAt: string }>>('list_backups')
|
||||
.then((list) => {
|
||||
if (list.length > 0) {
|
||||
const raw = list[0].createdAt;
|
||||
const d = new Date(raw.includes('T') || raw.includes('Z') ? raw : raw + 'Z');
|
||||
if (!Number.isNaN(d.getTime())) setLastLocalBackup(d.toLocaleString());
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [backupPopoverOpen]);
|
||||
|
||||
// Calculate next WebDAV sync timestamp (re-run when settings or lastWebDavSync change)
|
||||
useEffect(() => {
|
||||
if (!lastWebDavSync) {
|
||||
setNextWebDavTs(null);
|
||||
return;
|
||||
}
|
||||
const d = new Date(lastWebDavSync);
|
||||
if (Number.isNaN(d.getTime())) return;
|
||||
const interval = settings.webdav_sync_interval_minutes ?? 60;
|
||||
if (settings.webdav_sync_enabled && interval > 0) {
|
||||
const intervalMs = interval * 60000;
|
||||
let next = d.getTime() + intervalMs;
|
||||
// If overdue, advance to the next future interval
|
||||
while (next < Date.now()) {
|
||||
next += intervalMs;
|
||||
}
|
||||
setNextWebDavTs(next);
|
||||
} else {
|
||||
setNextWebDavTs(null);
|
||||
}
|
||||
}, [settings.webdav_sync_enabled, settings.webdav_sync_interval_minutes, lastWebDavSync]);
|
||||
|
||||
// Calculate next local backup timestamp from backup settings
|
||||
useEffect(() => {
|
||||
if (!backupSettings?.enabled) {
|
||||
setNextLocalTs(null);
|
||||
return;
|
||||
}
|
||||
const intervalMs = (backupSettings.intervalHours ?? 24) * 3600000;
|
||||
if (lastLocalBackup) {
|
||||
const lastTime = new Date(lastLocalBackup).getTime();
|
||||
if (!Number.isNaN(lastTime)) {
|
||||
let next = lastTime + intervalMs;
|
||||
// If overdue, advance to the next future interval
|
||||
while (next < Date.now()) {
|
||||
next += intervalMs;
|
||||
}
|
||||
setNextLocalTs(next);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// No previous backup — next backup at now + interval
|
||||
setNextLocalTs(Date.now() + intervalMs);
|
||||
}, [backupSettings, lastLocalBackup]);
|
||||
|
||||
// Live countdown on button — tick every second while any backup is scheduled
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
const now = Date.now();
|
||||
let soonest: number | null = null;
|
||||
if (nextLocalTs) {
|
||||
if (!soonest || nextLocalTs < soonest) soonest = nextLocalTs;
|
||||
}
|
||||
if (nextWebDavTs) {
|
||||
if (!soonest || nextWebDavTs < soonest) soonest = nextWebDavTs;
|
||||
}
|
||||
if (soonest) {
|
||||
setCountdownText(fmtCountdown(soonest - now));
|
||||
} else {
|
||||
setCountdownText(null);
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [nextLocalTs, nextWebDavTs]);
|
||||
|
||||
// Live countdown in popover — tick every second only when open
|
||||
useEffect(() => {
|
||||
if (!backupPopoverOpen) return;
|
||||
const tick = () => {
|
||||
const now = Date.now();
|
||||
if (nextLocalTs && nextLocalTs > now) {
|
||||
setPopoverLocalCountdown(`${new Date(nextLocalTs).toLocaleString()} (${fmtCountdown(nextLocalTs - now)})`);
|
||||
} else {
|
||||
setPopoverLocalCountdown(null);
|
||||
}
|
||||
if (nextWebDavTs && nextWebDavTs > now) {
|
||||
setPopoverWebDavCountdown(`${new Date(nextWebDavTs).toLocaleString()} (${fmtCountdown(nextWebDavTs - now)})`);
|
||||
} else {
|
||||
setPopoverWebDavCountdown(null);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [backupPopoverOpen, nextLocalTs, nextWebDavTs]);
|
||||
|
||||
const handleQuickBackup = useCallback(async (type: 'local' | 'webdav') => {
|
||||
setBackingUp(type);
|
||||
try {
|
||||
if (type === 'local') {
|
||||
await invoke('create_backup', { format: 'sqlite' });
|
||||
} else {
|
||||
await invoke('webdav_backup');
|
||||
}
|
||||
message.success(t('backup.backupSuccess'));
|
||||
setBackupPopoverOpen(false);
|
||||
} catch (e) {
|
||||
message.error(String(e));
|
||||
} finally {
|
||||
setBackingUp(null);
|
||||
}
|
||||
}, [message, t]);
|
||||
|
||||
const GITHUB_REPO = 'https://github.com/Boos4721/boos-chat';
|
||||
const githubMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'feature',
|
||||
icon: <MessageSquarePlus size={14} />,
|
||||
label: t('titlebar.submitFeature'),
|
||||
},
|
||||
{
|
||||
key: 'bug',
|
||||
icon: <Bug size={14} />,
|
||||
label: t('titlebar.submitBug'),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'star',
|
||||
icon: <Star size={14} />,
|
||||
label: t('titlebar.giveStar'),
|
||||
},
|
||||
];
|
||||
const handleGithubClick: MenuProps['onClick'] = ({ key }) => {
|
||||
let url = GITHUB_REPO;
|
||||
if (key === 'feature') url = `${GITHUB_REPO}/issues/new?labels=enhancement&template=feature_request.yml`;
|
||||
else if (key === 'bug') url = `${GITHUB_REPO}/issues/new?labels=bug&template=bug_report.yml`;
|
||||
if (isTauri()) {
|
||||
import('@tauri-apps/plugin-opener').then(({ openUrl }) => openUrl(url)).catch(() => window.open(url, '_blank'));
|
||||
} else {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const dragTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleDragMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('button')) return;
|
||||
if (!isTauri()) return;
|
||||
e.preventDefault();
|
||||
|
||||
if (IS_WINDOWS) {
|
||||
// Delay startDragging slightly so double-click can be detected.
|
||||
// If a second mousedown arrives within the threshold,
|
||||
// the onDoubleClick handler fires and cancels the pending drag.
|
||||
if (dragTimerRef.current) clearTimeout(dragTimerRef.current);
|
||||
dragTimerRef.current = setTimeout(() => {
|
||||
getCurrentWindow().startDragging();
|
||||
}, 200);
|
||||
} else {
|
||||
getCurrentWindow().startDragging();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTitleBarDoubleClick = useCallback(() => {
|
||||
if (!IS_WINDOWS) return;
|
||||
if (dragTimerRef.current) {
|
||||
clearTimeout(dragTimerRef.current);
|
||||
dragTimerRef.current = null;
|
||||
}
|
||||
invoke('toggle_maximize_window');
|
||||
}, []);
|
||||
|
||||
const buttonBase: React.CSSProperties = {
|
||||
width: 28,
|
||||
height: 28,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: token.borderRadius,
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
};
|
||||
|
||||
const hoverHandlers = (baseColor: string) => ({
|
||||
onMouseEnter: (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.currentTarget.style.backgroundColor = token.colorFillSecondary;
|
||||
e.currentTarget.style.color = token.colorTextBase;
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = baseColor;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="title-bar-drag"
|
||||
{...(!IS_WINDOWS ? { 'data-tauri-drag-region': true } : {})}
|
||||
onMouseDown={handleDragMouseDown}
|
||||
onDoubleClick={IS_WINDOWS ? handleTitleBarDoubleClick : undefined}
|
||||
style={{
|
||||
height: 36,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingLeft: IS_WINDOWS ? 12 : 72,
|
||||
paddingRight: IS_WINDOWS ? 0 : 12,
|
||||
backgroundColor: 'transparent',
|
||||
flexShrink: 0,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{/* Left: App icon + name (Windows only) */}
|
||||
{IS_WINDOWS ? (
|
||||
<div className="title-bar-nodrag" style={{ display: 'flex', alignItems: 'center', gap: 6, marginRight: 8 }}>
|
||||
<img src={appLogo} alt="BoosAPI Desktop" style={{ width: 18, height: 18 }} draggable={false} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: token.colorTextBase, userSelect: 'none' }}>BoosAPI Desktop</span>
|
||||
</div>
|
||||
) : <div />}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
|
||||
<div className="title-bar-nodrag" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{/* Pin Toggle */}
|
||||
<Tooltip title={t('desktop.alwaysOnTop')}>
|
||||
<button
|
||||
onClick={handlePinToggle}
|
||||
style={{
|
||||
...buttonBase,
|
||||
color: pinned ? token.colorPrimary : token.colorTextSecondary,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = pinned
|
||||
? token.colorPrimaryBg
|
||||
: token.colorFillSecondary;
|
||||
e.currentTarget.style.color = pinned
|
||||
? token.colorPrimary
|
||||
: token.colorTextBase;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = pinned
|
||||
? token.colorPrimary
|
||||
: token.colorTextSecondary;
|
||||
}}
|
||||
>
|
||||
{pinned ? <Pin size={14} /> : <PinOff size={14} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Theme Dropdown */}
|
||||
<Dropdown
|
||||
menu={{ items: themeMenuItems, onClick: handleThemeChange, selectedKeys: [themeMode] }}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
destroyOnHidden
|
||||
>
|
||||
<button
|
||||
style={{ ...buttonBase, color: token.colorTextSecondary }}
|
||||
{...hoverHandlers(token.colorTextSecondary)}
|
||||
>
|
||||
{THEME_ICONS[themeMode] ?? <Monitor size={14} />}
|
||||
</button>
|
||||
</Dropdown>
|
||||
|
||||
{/* Language Dropdown */}
|
||||
<Dropdown
|
||||
menu={{ items: langMenuItems, onClick: handleLangChange, selectedKeys: [i18n.language] }}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
destroyOnHidden
|
||||
>
|
||||
<button
|
||||
style={{ ...buttonBase, color: token.colorTextSecondary }}
|
||||
{...hoverHandlers(token.colorTextSecondary)}
|
||||
>
|
||||
<Globe size={14} />
|
||||
</button>
|
||||
</Dropdown>
|
||||
|
||||
{/* Quick Backup */}
|
||||
<Popover
|
||||
open={backupPopoverOpen}
|
||||
onOpenChange={setBackupPopoverOpen}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
destroyTooltipOnHide
|
||||
content={
|
||||
<div style={{ width: 240 }}>
|
||||
<Typography.Text strong style={{ fontSize: 13 }}>
|
||||
{t('titlebar.lastBackup')}
|
||||
</Typography.Text>
|
||||
<Space direction="vertical" size={2} style={{ width: '100%', marginTop: 4 }}>
|
||||
{lastLocalBackup && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('titlebar.lastLocal')}: {lastLocalBackup}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{lastWebDavSync && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
WebDAV: {lastWebDavSync}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{!lastLocalBackup && !lastWebDavSync && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('titlebar.noBackupYet')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{(popoverLocalCountdown || popoverWebDavCountdown) && (
|
||||
<>
|
||||
<Divider style={{ margin: '6px 0' }} />
|
||||
<Typography.Text strong style={{ fontSize: 13 }}>
|
||||
{t('titlebar.nextBackup')}
|
||||
</Typography.Text>
|
||||
<Space direction="vertical" size={2} style={{ width: '100%', marginTop: 4 }}>
|
||||
{popoverLocalCountdown && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('titlebar.lastLocal')}: {popoverLocalCountdown}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{popoverWebDavCountdown && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
WebDAV: {popoverWebDavCountdown}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '6px 0' }} />
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<button
|
||||
onClick={() => handleQuickBackup('local')}
|
||||
disabled={backingUp !== null}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
borderRadius: token.borderRadius,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
backgroundColor: 'transparent',
|
||||
cursor: backingUp ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
color: token.colorText,
|
||||
}}
|
||||
>
|
||||
{backingUp === 'local' ? <Spin size="small" /> : <CloudUpload size={14} />}
|
||||
{t('titlebar.localBackup')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuickBackup('webdav')}
|
||||
disabled={backingUp !== null}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
borderRadius: token.borderRadius,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
backgroundColor: 'transparent',
|
||||
cursor: backingUp ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
color: token.colorText,
|
||||
}}
|
||||
>
|
||||
{backingUp === 'webdav' ? <Spin size="small" /> : <CloudUpload size={14} />}
|
||||
{t('titlebar.webdavBackup')}
|
||||
</button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tooltip title={t('titlebar.quickBackup')}>
|
||||
<button
|
||||
style={{
|
||||
...buttonBase,
|
||||
color: countdownText ? token.colorPrimary : token.colorTextSecondary,
|
||||
width: countdownText ? 'auto' : 28,
|
||||
paddingInline: countdownText ? 4 : 0,
|
||||
gap: 2,
|
||||
fontSize: 11,
|
||||
}}
|
||||
{...hoverHandlers(countdownText ? token.colorPrimary : token.colorTextSecondary)}
|
||||
>
|
||||
<CloudUpload size={14} />
|
||||
{countdownText && <span>({countdownText})</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
|
||||
{/* GitHub */}
|
||||
<Dropdown
|
||||
menu={{ items: githubMenuItems, onClick: handleGithubClick }}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
destroyOnHidden
|
||||
>
|
||||
<button
|
||||
style={{ ...buttonBase, color: token.colorTextSecondary }}
|
||||
{...hoverHandlers(token.colorTextSecondary)}
|
||||
>
|
||||
<Github size={14} />
|
||||
</button>
|
||||
</Dropdown>
|
||||
|
||||
{/* Check Update */}
|
||||
{isTauri() && (
|
||||
<Tooltip title={t('settings.checkUpdate')}>
|
||||
<button
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={checkingUpdate}
|
||||
style={{ ...buttonBase, color: token.colorTextSecondary, opacity: checkingUpdate ? 0.5 : 1 }}
|
||||
{...hoverHandlers(token.colorTextSecondary)}
|
||||
>
|
||||
{checkingUpdate ? <Spin size="small" /> : <ArrowDownCircle size={14} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Reload Page */}
|
||||
<Tooltip title={t('desktop.reloadPage')}>
|
||||
<button
|
||||
onClick={handleReload}
|
||||
style={{ ...buttonBase, color: token.colorTextSecondary }}
|
||||
{...hoverHandlers(token.colorTextSecondary)}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Settings Toggle */}
|
||||
<Tooltip title={`${isInSettings ? t('settings.closeSettings') : t('settings.openSettings')} (${formatShortcutForDisplay(getShortcutBinding(settings, 'openSettings'))})`}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
handleSettingsToggle();
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.blur();
|
||||
}}
|
||||
style={{
|
||||
...buttonBase,
|
||||
color: isInSettings ? token.colorError : token.colorTextSecondary,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = isInSettings
|
||||
? token.colorErrorBg
|
||||
: token.colorFillSecondary;
|
||||
e.currentTarget.style.color = isInSettings
|
||||
? token.colorError
|
||||
: token.colorTextBase;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = isInSettings
|
||||
? token.colorError
|
||||
: token.colorTextSecondary;
|
||||
}}
|
||||
>
|
||||
{isInSettings ? <XCircle size={14} /> : <Settings size={14} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Windows window controls */}
|
||||
{IS_WINDOWS && isTauri() && (
|
||||
<div className="title-bar-nodrag" style={{ display: 'flex', alignItems: 'center', marginLeft: 4 }}>
|
||||
{/* Minimize */}
|
||||
<button
|
||||
onClick={handleWindowMinimize}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 46,
|
||||
height: 36,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: token.colorTextSecondary,
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = token.colorFillSecondary; e.currentTarget.style.color = token.colorTextBase; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.color = token.colorTextSecondary; }}
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
{/* Maximize / Restore */}
|
||||
<button
|
||||
onClick={handleWindowMaximize}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 46,
|
||||
height: 36,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: token.colorTextSecondary,
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = token.colorFillSecondary; e.currentTarget.style.color = token.colorTextBase; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.color = token.colorTextSecondary; }}
|
||||
>
|
||||
{isMaximized ? <RestoreIcon /> : <Square size={14} />}
|
||||
</button>
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={handleWindowClose}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 46,
|
||||
height: 36,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: token.colorTextSecondary,
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
borderRadius: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#e81123'; e.currentTarget.style.color = '#ffffff'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.color = token.colorTextSecondary; }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user