From fbed6580fa2c804e243d258fd230e59755167d87 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 7 Jun 2026 21:05:09 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ai-chat):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E5=8A=A8=E4=BD=9C=E5=B9=B6=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E4=BE=A7=E6=A0=8F=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 缺少供应商或模型时在提示区提供可执行入口 - 历史侧栏按更新时间排序并在重新打开时重置搜索 - 替换 Drawer 废弃属性并补充定向测试 --- frontend/src/components/AIChatPanel.tsx | 21 ++++- .../components/ai/AIChatInput.notice.test.tsx | 25 ++++++ frontend/src/components/ai/AIChatInput.tsx | 24 +++++- .../components/ai/AIHistoryDrawer.test.tsx | 85 +++++++++++++++++++ .../src/components/ai/AIHistoryDrawer.tsx | 43 ++++++++-- frontend/src/utils/aiComposerNotice.ts | 17 ++++ 6 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/ai/AIHistoryDrawer.test.tsx diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 75c6588..0bf7554 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -589,6 +589,24 @@ export const AIChatPanel: React.FC = ({ } }, []); + const handleOpenSettingsFromPanel = useCallback(() => { + onOpenSettings?.(); + window.setTimeout(() => { + void loadActiveProvider(); + }, 500); + }, [loadActiveProvider, onOpenSettings]); + + const handleComposerNoticeAction = useCallback(() => { + const actionKey = composerNotice?.action?.key; + if (actionKey === 'open-settings') { + handleOpenSettingsFromPanel(); + return; + } + if (actionKey === 'reload-models') { + void fetchDynamicModels(); + } + }, [composerNotice?.action?.key, fetchDynamicModels, handleOpenSettingsFromPanel]); + useEffect(() => { if (messages.length === 0) return; messagesEndRef.current?.scrollIntoView({ behavior: sending ? 'auto' : 'smooth', block: 'end' }); @@ -1894,7 +1912,7 @@ SELECT * FROM users WHERE status = 1; createNewAISession(); setActivePanelMode('chat'); }} - onSettingsClick={() => { onOpenSettings?.(); setTimeout(loadActiveProvider, 500); }} + onSettingsClick={handleOpenSettingsFromPanel} onClose={onClose} messages={messages} sessionTitle={useStore.getState().aiChatSessions.find(s => s.id === sid)?.title || '新对话'} @@ -2019,6 +2037,7 @@ SELECT * FROM users WHERE status = 1; sendShortcutBinding={aiChatSendShortcutBinding} shortcutPlatform={activeShortcutPlatform} composerNotice={composerNotice} + onComposerNoticeAction={handleComposerNoticeAction} onModelChange={handleModelChange} onFetchModels={fetchDynamicModels} textareaRef={textareaRef} diff --git a/frontend/src/components/ai/AIChatInput.notice.test.tsx b/frontend/src/components/ai/AIChatInput.notice.test.tsx index 7ffb358..60b53d6 100644 --- a/frontend/src/components/ai/AIChatInput.notice.test.tsx +++ b/frontend/src/components/ai/AIChatInput.notice.test.tsx @@ -37,6 +37,7 @@ const renderAIChatInput = (overrides: Partial {}} onModelChange={() => {}} onFetchModels={() => {}} textareaRef={React.createRef()} @@ -71,7 +72,12 @@ describe('AIChatInput notice layout', () => { tone: 'error', title: '模型列表加载失败', description: '请检查供应商入口和 API Key。', + action: { + key: 'reload-models', + label: '重新加载模型', + }, }} + onComposerNoticeAction={() => {}} onModelChange={() => {}} onFetchModels={() => {}} textareaRef={React.createRef()} @@ -110,6 +116,7 @@ describe('AIChatInput notice layout', () => { sendShortcutBinding={{ combo: 'Meta+Enter', enabled: true }} shortcutPlatform="mac" composerNotice={null} + onComposerNoticeAction={() => {}} onModelChange={() => {}} onFetchModels={() => {}} textareaRef={React.createRef()} @@ -142,6 +149,7 @@ describe('AIChatInput notice layout', () => { loadingModels={false} sendShortcutBinding={{ combo: 'Enter', enabled: true }} composerNotice={null} + onComposerNoticeAction={() => {}} onModelChange={() => {}} onFetchModels={() => {}} textareaRef={React.createRef()} @@ -176,4 +184,21 @@ describe('AIChatInput notice layout', () => { expect(markup).not.toContain('gn-v2-ai-model-select'); expect(markup).not.toContain('gn-v2-ai-send'); }); + + it('renders an actionable composer notice button when the notice provides an action', () => { + const markup = renderAIChatInput({ + composerNotice: { + tone: 'warning', + title: '还没有可用供应商', + description: '先在 AI 设置里添加并启用一个模型供应商。', + action: { + key: 'open-settings', + label: '打开 AI 设置', + }, + }, + onComposerNoticeAction: () => {}, + }); + + expect(markup).toContain('打开 AI 设置'); + }); }); diff --git a/frontend/src/components/ai/AIChatInput.tsx b/frontend/src/components/ai/AIChatInput.tsx index 149e261..7bc97ad 100644 --- a/frontend/src/components/ai/AIChatInput.tsx +++ b/frontend/src/components/ai/AIChatInput.tsx @@ -27,6 +27,7 @@ interface AIChatInputProps { sendShortcutBinding: ShortcutPlatformBinding; shortcutPlatform?: ShortcutPlatform; composerNotice?: AIComposerNotice | null; + onComposerNoticeAction?: () => void; onModelChange: (val: string) => void; onFetchModels: () => void; textareaRef: React.RefObject; @@ -42,7 +43,7 @@ interface AIChatInputProps { export const AIChatInput: React.FC = ({ input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown, activeConnName, activeContext, activeProvider, dynamicModels, loadingModels, - sendShortcutBinding, shortcutPlatform = 'windows', composerNotice, + sendShortcutBinding, shortcutPlatform = 'windows', composerNotice, onComposerNoticeAction, onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme, contextUsageChars, maxContextChars, isV2Ui = false }) => { @@ -104,6 +105,7 @@ export const AIChatInput: React.FC = ({ iconColor: '#d48806', }; }, [composerNotice, darkMode]); + const composerNoticeActionLabel = composerNotice?.action?.label; // Slash commands const [showSlashMenu, setShowSlashMenu] = React.useState(false); @@ -314,6 +316,16 @@ export const AIChatInput: React.FC = ({
{composerNotice.description}
+ {composerNoticeActionLabel && typeof onComposerNoticeAction === 'function' && ( + + )} )} @@ -722,6 +734,16 @@ export const AIChatInput: React.FC = ({
{composerNotice.description}
+ {composerNoticeActionLabel && typeof onComposerNoticeAction === 'function' && ( + + )} )} diff --git a/frontend/src/components/ai/AIHistoryDrawer.test.tsx b/frontend/src/components/ai/AIHistoryDrawer.test.tsx new file mode 100644 index 0000000..a6cb3f0 --- /dev/null +++ b/frontend/src/components/ai/AIHistoryDrawer.test.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { readFileSync } from 'node:fs'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('antd', async () => { + const actual = await vi.importActual('antd'); + return { + ...actual, + Drawer: ({ children }: { children?: React.ReactNode }) =>
{children}
, + }; +}); + +import { AIHistoryDrawer } from './AIHistoryDrawer'; + +const setAIActiveSessionId = vi.fn(); +const deleteAISession = vi.fn(); + +let mockState = { + aiChatSessions: [] as Array<{ id: string; title: string; updatedAt: number }>, + setAIActiveSessionId, + deleteAISession, +}; + +vi.mock('../../store', () => ({ + useStore: (selector: (state: typeof mockState) => unknown) => selector(mockState), +})); + +const source = readFileSync(new URL('./AIHistoryDrawer.tsx', import.meta.url), 'utf8'); +const drawerOpenTag = source.match(//)?.[0] || ''; + +const renderHistoryDrawer = () => renderToStaticMarkup( + {}} + bgColor="#ffffff" + darkMode={false} + textColor="#162033" + mutedColor="rgba(16,24,40,0.55)" + borderColor="rgba(0,0,0,0.12)" + onCreateNew={() => {}} + sessionId="current-session" + /> +); + +describe('AIHistoryDrawer', () => { + beforeEach(() => { + setAIActiveSessionId.mockReset(); + deleteAISession.mockReset(); + mockState = { + aiChatSessions: [], + setAIActiveSessionId, + deleteAISession, + }; + }); + + it('uses antd v5 drawer style props instead of deprecated style/bodyStyle props', () => { + expect(drawerOpenTag).toContain("rootStyle={{ position: 'absolute' }}"); + expect(drawerOpenTag).toContain('styles={{'); + expect(drawerOpenTag).not.toContain('bodyStyle='); + expect(drawerOpenTag).not.toMatch(/\n\s*style=\{\{/); + }); + + it('renders recent sessions before older sessions', () => { + mockState = { + ...mockState, + aiChatSessions: [ + { id: 'older-session', title: '较早会话', updatedAt: 1710000000000 }, + { id: 'newer-session', title: '较新会话', updatedAt: 1720000000000 }, + ], + }; + + const markup = renderHistoryDrawer(); + + expect(markup.indexOf('较新会话')).toBeGreaterThanOrEqual(0); + expect(markup.indexOf('较早会话')).toBeGreaterThanOrEqual(0); + expect(markup.indexOf('较新会话')).toBeLessThan(markup.indexOf('较早会话')); + }); + + it('renders the dedicated empty state when there is no history session', () => { + const markup = renderHistoryDrawer(); + + expect(markup).toContain('还没有历史对话'); + }); +}); diff --git a/frontend/src/components/ai/AIHistoryDrawer.tsx b/frontend/src/components/ai/AIHistoryDrawer.tsx index 520d288..c905887 100644 --- a/frontend/src/components/ai/AIHistoryDrawer.tsx +++ b/frontend/src/components/ai/AIHistoryDrawer.tsx @@ -21,14 +21,31 @@ export const AIHistoryDrawer: React.FC = ({ const aiChatSessions = useStore(state => state.aiChatSessions); const setAIActiveSessionId = useStore(state => state.setAIActiveSessionId); const deleteAISession = useStore(state => state.deleteAISession); - - // 阶段4: 历史记录搜索 - const [searchText, setSearchText] = useState(''); - const filteredSessions = aiChatSessions.filter(s => - !searchText || (s.title && s.title.toLowerCase().includes(searchText.toLowerCase())) + const [searchText, setSearchText] = useState(''); + const normalizedSearchText = searchText.trim().toLowerCase(); + + React.useEffect(() => { + if (!open && searchText) { + setSearchText(''); + } + }, [open, searchText]); + + const sortedSessions = React.useMemo( + () => [...aiChatSessions].sort((left, right) => right.updatedAt - left.updatedAt), + [aiChatSessions], ); + const filteredSessions = React.useMemo( + () => sortedSessions.filter((session) => + !normalizedSearchText || (session.title && session.title.toLowerCase().includes(normalizedSearchText))), + [normalizedSearchText, sortedSessions], + ); + + const emptyStateText = aiChatSessions.length === 0 + ? '还没有历史对话' + : `没有找到匹配“${searchText.trim()}”的历史记录`; + return ( = ({ onClose={onClose} open={open} getContainer={false} - style={{ position: 'absolute', background: bgColor || (darkMode ? '#1e1e1e' : '#f8f9fa') }} + rootStyle={{ position: 'absolute' }} width={260} - bodyStyle={{ padding: 0, display: 'flex', flexDirection: 'column' }} + styles={{ + content: { + background: bgColor || (darkMode ? '#1e1e1e' : '#f8f9fa'), + }, + body: { + padding: 0, + display: 'flex', + flexDirection: 'column', + }, + }} > {/* 侧拉面板头部 */}
@@ -66,6 +92,7 @@ export const AIHistoryDrawer: React.FC = ({ } + allowClear value={searchText} onChange={e => setSearchText(e.target.value)} variant="filled" @@ -77,7 +104,7 @@ export const AIHistoryDrawer: React.FC = ({ {/* 列表容器 */}
{filteredSessions.length === 0 ? ( -
暂无匹配的对话记录
+
{emptyStateText}
) : ( filteredSessions.map(session => (
({ tone: 'warning', title: '还没有可用供应商', description: '先在 AI 设置里添加并启用一个模型供应商。', + action: { + key: 'open-settings', + label: '打开 AI 设置', + }, }); export const buildMissingModelNotice = (): AIComposerNotice => ({ tone: 'warning', title: '先选择一个模型', description: '打开下方模型下拉并选择模型;如果列表为空,请检查供应商入口和 API Key。', + action: { + key: 'reload-models', + label: '重新加载模型', + }, }); export const buildModelFetchFailedNotice = (error?: string): AIComposerNotice => ({ tone: 'error', title: '模型列表加载失败', description: String(error || '').trim() || defaultModelFetchFailedDescription, + action: { + key: 'reload-models', + label: '重新加载模型', + }, });