feat(ai-chat): 增加提示动作并完善历史侧栏体验

- 缺少供应商或模型时在提示区提供可执行入口

- 历史侧栏按更新时间排序并在重新打开时重置搜索

- 替换 Drawer 废弃属性并补充定向测试
This commit is contained in:
Syngnat
2026-06-07 21:05:09 +08:00
parent 7cdd2bd6c0
commit fbed6580fa
6 changed files with 205 additions and 10 deletions

View File

@@ -589,6 +589,24 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
}
}, []);
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}

View File

@@ -37,6 +37,7 @@ const renderAIChatInput = (overrides: Partial<React.ComponentProps<typeof AIChat
loadingModels={false}
sendShortcutBinding={{ combo: 'Enter', enabled: true }}
composerNotice={null}
onComposerNoticeAction={() => {}}
onModelChange={() => {}}
onFetchModels={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
@@ -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<HTMLTextAreaElement>()}
@@ -110,6 +116,7 @@ describe('AIChatInput notice layout', () => {
sendShortcutBinding={{ combo: 'Meta+Enter', enabled: true }}
shortcutPlatform="mac"
composerNotice={null}
onComposerNoticeAction={() => {}}
onModelChange={() => {}}
onFetchModels={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
@@ -142,6 +149,7 @@ describe('AIChatInput notice layout', () => {
loadingModels={false}
sendShortcutBinding={{ combo: 'Enter', enabled: true }}
composerNotice={null}
onComposerNoticeAction={() => {}}
onModelChange={() => {}}
onFetchModels={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
@@ -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 设置');
});
});

View File

@@ -27,6 +27,7 @@ interface AIChatInputProps {
sendShortcutBinding: ShortcutPlatformBinding;
shortcutPlatform?: ShortcutPlatform;
composerNotice?: AIComposerNotice | null;
onComposerNoticeAction?: () => void;
onModelChange: (val: string) => void;
onFetchModels: () => void;
textareaRef: React.RefObject<HTMLTextAreaElement>;
@@ -42,7 +43,7 @@ interface AIChatInputProps {
export const AIChatInput: React.FC<AIChatInputProps> = ({
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<AIChatInputProps> = ({
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<AIChatInputProps> = ({
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5, marginTop: 2, wordBreak: 'break-word' }}>
{composerNotice.description}
</div>
{composerNoticeActionLabel && typeof onComposerNoticeAction === 'function' && (
<Button
size="small"
type="default"
onClick={onComposerNoticeAction}
style={{ marginTop: 8, borderRadius: 8 }}
>
{composerNoticeActionLabel}
</Button>
)}
</div>
</div>
)}
@@ -722,6 +734,16 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5, marginTop: 2, wordBreak: 'break-word' }}>
{composerNotice.description}
</div>
{composerNoticeActionLabel && typeof onComposerNoticeAction === 'function' && (
<Button
size="small"
type="default"
onClick={onComposerNoticeAction}
style={{ marginTop: 8, borderRadius: 8 }}
>
{composerNoticeActionLabel}
</Button>
)}
</div>
</div>
)}

View File

@@ -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<typeof import('antd')>('antd');
return {
...actual,
Drawer: ({ children }: { children?: React.ReactNode }) => <div data-testid="mock-drawer">{children}</div>,
};
});
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(/<Drawer[\s\S]*?>/)?.[0] || '';
const renderHistoryDrawer = () => renderToStaticMarkup(
<AIHistoryDrawer
open
onClose={() => {}}
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('还没有历史对话');
});
});

View File

@@ -21,14 +21,31 @@ export const AIHistoryDrawer: React.FC<AIHistoryDrawerProps> = ({
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 (
<Drawer
placement="left"
@@ -36,9 +53,18 @@ export const AIHistoryDrawer: React.FC<AIHistoryDrawerProps> = ({
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',
},
}}
>
{/* 侧拉面板头部 */}
<div style={{ padding: '16px 16px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -66,6 +92,7 @@ export const AIHistoryDrawer: React.FC<AIHistoryDrawerProps> = ({
<Input
placeholder="搜索历史记录..."
prefix={<SearchOutlined style={{ color: mutedColor }} />}
allowClear
value={searchText}
onChange={e => setSearchText(e.target.value)}
variant="filled"
@@ -77,7 +104,7 @@ export const AIHistoryDrawer: React.FC<AIHistoryDrawerProps> = ({
{/* 列表容器 */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 10px 16px' }} className="ai-history-list">
{filteredSessions.length === 0 ? (
<div style={{ padding: '30px 0', textAlign: 'center', color: mutedColor, fontSize: 12 }}></div>
<div style={{ padding: '30px 0', textAlign: 'center', color: mutedColor, fontSize: 12 }}>{emptyStateText}</div>
) : (
filteredSessions.map(session => (
<div

View File

@@ -1,9 +1,14 @@
export type AIComposerNoticeTone = 'warning' | 'error';
export type AIComposerNoticeAction = 'open-settings' | 'reload-models';
export interface AIComposerNotice {
tone: AIComposerNoticeTone;
title: string;
description: string;
action?: {
key: AIComposerNoticeAction;
label: string;
};
}
const defaultModelFetchFailedDescription = '请检查供应商入口、API Key 或账号权限,然后重新打开模型下拉。';
@@ -12,16 +17,28 @@ export const buildMissingProviderNotice = (): AIComposerNotice => ({
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: '重新加载模型',
},
});