mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 18:39:54 +08:00
✨ feat(ai-chat): 增加提示动作并完善历史侧栏体验
- 缺少供应商或模型时在提示区提供可执行入口 - 历史侧栏按更新时间排序并在重新打开时重置搜索 - 替换 Drawer 废弃属性并补充定向测试
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 设置');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
85
frontend/src/components/ai/AIHistoryDrawer.test.tsx
Normal file
85
frontend/src/components/ai/AIHistoryDrawer.test.tsx
Normal 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('还没有历史对话');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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: '重新加载模型',
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user