mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 18:39:54 +08:00
✨ feat(ai-chat): 增强发送前状态提示并新增就绪探针
This commit is contained in:
@@ -6,6 +6,7 @@ import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import type {
|
||||
AIChatMessage,
|
||||
AIMCPToolDescriptor,
|
||||
AIProviderConfig,
|
||||
AISkillConfig,
|
||||
AIUserPromptSettings,
|
||||
AIToolCall,
|
||||
@@ -22,9 +23,10 @@ import { AIChatInput } from './ai/AIChatInput';
|
||||
import { AIHistoryDrawer } from './ai/AIHistoryDrawer';
|
||||
import AIMessageRenderBoundary from './ai/AIMessageRenderBoundary';
|
||||
import AIChatPanelModeContent, { type AIChatInsightItem } from './ai/AIChatPanelModeContent';
|
||||
import type { AIComposerNotice } from '../utils/aiComposerNotice';
|
||||
import type { AIComposerNotice, AIComposerNoticeAction } from '../utils/aiComposerNotice';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import {
|
||||
buildIncompleteProviderNotice,
|
||||
buildMissingModelNotice,
|
||||
buildMissingProviderNotice,
|
||||
buildModelFetchFailedNotice,
|
||||
@@ -40,6 +42,7 @@ import {
|
||||
executeLocalAIToolCall,
|
||||
type AIToolContextEntry,
|
||||
} from './ai/aiLocalToolExecutor';
|
||||
import { buildAIChatReadinessSnapshot } from './ai/aiChatReadiness';
|
||||
import { buildAISystemContextMessages } from './ai/aiSystemContextMessages';
|
||||
|
||||
interface AIChatPanelProps {
|
||||
@@ -67,7 +70,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const [input, setInput] = useState('');
|
||||
const [draftImages, setDraftImages] = useState<string[]>([]);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [activeProvider, setActiveProvider] = useState<any>(null);
|
||||
const [activeProvider, setActiveProvider] = useState<AIProviderConfig | null>(null);
|
||||
const [userPromptSettings, setUserPromptSettings] = useState<AIUserPromptSettings>(EMPTY_AI_USER_PROMPT_SETTINGS);
|
||||
const [mcpTools, setMcpTools] = useState<AIMCPToolDescriptor[]>([]);
|
||||
const [skills, setSkills] = useState<AISkillConfig[]>([]);
|
||||
@@ -417,8 +420,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
}, 500);
|
||||
}, [loadActiveProvider, onOpenSettings]);
|
||||
|
||||
const handleComposerNoticeAction = useCallback(() => {
|
||||
const actionKey = composerNotice?.action?.key;
|
||||
const handleComposerAction = useCallback((actionKey: AIComposerNoticeAction) => {
|
||||
if (actionKey === 'open-settings') {
|
||||
handleOpenSettingsFromPanel();
|
||||
return;
|
||||
@@ -426,7 +428,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
if (actionKey === 'reload-models') {
|
||||
void fetchDynamicModels();
|
||||
}
|
||||
}, [composerNotice?.action?.key, fetchDynamicModels, handleOpenSettingsFromPanel]);
|
||||
}, [fetchDynamicModels, handleOpenSettingsFromPanel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return;
|
||||
@@ -1005,12 +1007,25 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const text = input.trim();
|
||||
if ((!text && draftImages.length === 0) || sending) return;
|
||||
|
||||
// 前置校验:必须配置供应商且选择模型后才能发送
|
||||
if (!activeProvider) {
|
||||
const connectionKey = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default';
|
||||
const readiness = buildAIChatReadinessSnapshot({
|
||||
activeProvider,
|
||||
dynamicModels,
|
||||
loadingModels,
|
||||
activeContext,
|
||||
activeContextItems: aiContexts[connectionKey] || [],
|
||||
});
|
||||
|
||||
// 前置校验:必须配置供应商、补全基础参数并选择模型后才能发送
|
||||
if (readiness.status === 'missing_provider') {
|
||||
setComposerNotice(buildMissingProviderNotice());
|
||||
return;
|
||||
}
|
||||
if (!activeProvider.model || !activeProvider.model.trim()) {
|
||||
if (readiness.status === 'provider_incomplete') {
|
||||
setComposerNotice(buildIncompleteProviderNotice(readiness.issues));
|
||||
return;
|
||||
}
|
||||
if (readiness.status === 'missing_model' || readiness.status === 'loading_models') {
|
||||
setComposerNotice(buildMissingModelNotice());
|
||||
return;
|
||||
}
|
||||
@@ -1137,11 +1152,15 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
messages,
|
||||
addAIChatMessage,
|
||||
sid,
|
||||
activeContext,
|
||||
activeProvider,
|
||||
aiContexts,
|
||||
availableTools,
|
||||
buildSystemContextMessages,
|
||||
dynamicModels,
|
||||
getCurrentJVMPlanContext,
|
||||
getCurrentJVMDiagnosticPlanContext,
|
||||
loadingModels,
|
||||
]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
@@ -1473,7 +1492,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
sendShortcutBinding={aiChatSendShortcutBinding}
|
||||
shortcutPlatform={activeShortcutPlatform}
|
||||
composerNotice={composerNotice}
|
||||
onComposerNoticeAction={handleComposerNoticeAction}
|
||||
onComposerAction={handleComposerAction}
|
||||
onModelChange={handleModelChange}
|
||||
onFetchModels={fetchDynamicModels}
|
||||
textareaRef={textareaRef}
|
||||
|
||||
@@ -30,6 +30,8 @@ describe('AIBuiltinToolsCatalog', () => {
|
||||
expect(markup).toContain('inspect_ai_runtime');
|
||||
expect(markup).toContain('排查供应商与模型');
|
||||
expect(markup).toContain('inspect_ai_providers');
|
||||
expect(markup).toContain('排查聊天发送状态');
|
||||
expect(markup).toContain('inspect_ai_chat_readiness');
|
||||
expect(markup).toContain('排查 MCP 接入状态');
|
||||
expect(markup).toContain('inspect_mcp_setup');
|
||||
expect(markup).toContain('查看当前提示与 Skills');
|
||||
|
||||
@@ -47,6 +47,11 @@ const BUILTIN_TOOL_FLOWS = [
|
||||
steps: 'inspect_ai_providers → inspect_ai_runtime',
|
||||
description: '适合先确认当前到底配置了哪些供应商、哪个在生效、有没有缺密钥或没选模型,再解释为什么 AI 不能发送、为什么模型列表为空。',
|
||||
},
|
||||
{
|
||||
title: '排查聊天发送状态',
|
||||
steps: 'inspect_ai_chat_readiness → inspect_ai_providers',
|
||||
description: '适合先确认当前聊天输入区到底缺什么前置条件,例如没选活动供应商、缺密钥、缺接口地址、没选模型,避免只凭界面现象猜测。',
|
||||
},
|
||||
{
|
||||
title: '排查 MCP 接入状态',
|
||||
steps: 'inspect_mcp_setup → inspect_ai_runtime',
|
||||
|
||||
170
frontend/src/components/ai/AIChatComposerStatus.tsx
Normal file
170
frontend/src/components/ai/AIChatComposerStatus.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React from 'react';
|
||||
import { Button } from 'antd';
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
ExclamationCircleFilled,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
import type { AIComposerNoticeAction } from '../../utils/aiComposerNotice';
|
||||
import type { AIChatReadinessSnapshot } from './aiChatReadiness';
|
||||
|
||||
interface AIChatComposerStatusProps {
|
||||
snapshot: AIChatReadinessSnapshot;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
onAction?: (actionKey: AIComposerNoticeAction) => void;
|
||||
}
|
||||
|
||||
const resolvePalette = (
|
||||
severity: AIChatReadinessSnapshot['severity'],
|
||||
darkMode: boolean,
|
||||
) => {
|
||||
if (severity === 'success') {
|
||||
return darkMode
|
||||
? {
|
||||
background: 'rgba(34,197,94,0.12)',
|
||||
borderColor: 'rgba(34,197,94,0.24)',
|
||||
iconColor: '#4ade80',
|
||||
labelColor: '#86efac',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(34,197,94,0.08)',
|
||||
borderColor: 'rgba(34,197,94,0.16)',
|
||||
iconColor: '#16a34a',
|
||||
labelColor: '#166534',
|
||||
};
|
||||
}
|
||||
if (severity === 'error') {
|
||||
return darkMode
|
||||
? {
|
||||
background: 'rgba(255,120,117,0.12)',
|
||||
borderColor: 'rgba(255,120,117,0.24)',
|
||||
iconColor: '#ff7875',
|
||||
labelColor: '#ffb4b2',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(255,77,79,0.08)',
|
||||
borderColor: 'rgba(255,77,79,0.16)',
|
||||
iconColor: '#ff4d4f',
|
||||
labelColor: '#991b1b',
|
||||
};
|
||||
}
|
||||
if (severity === 'info') {
|
||||
return darkMode
|
||||
? {
|
||||
background: 'rgba(59,130,246,0.12)',
|
||||
borderColor: 'rgba(59,130,246,0.24)',
|
||||
iconColor: '#60a5fa',
|
||||
labelColor: '#93c5fd',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(59,130,246,0.08)',
|
||||
borderColor: 'rgba(59,130,246,0.14)',
|
||||
iconColor: '#2563eb',
|
||||
labelColor: '#1d4ed8',
|
||||
};
|
||||
}
|
||||
return darkMode
|
||||
? {
|
||||
background: 'rgba(250,173,20,0.12)',
|
||||
borderColor: 'rgba(250,173,20,0.22)',
|
||||
iconColor: '#ffd666',
|
||||
labelColor: '#ffe58f',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(250,173,20,0.08)',
|
||||
borderColor: 'rgba(250,173,20,0.18)',
|
||||
iconColor: '#d48806',
|
||||
labelColor: '#92400e',
|
||||
};
|
||||
};
|
||||
|
||||
const resolveIcon = (snapshot: AIChatReadinessSnapshot) => {
|
||||
if (snapshot.status === 'loading_models') {
|
||||
return <LoadingOutlined style={{ fontSize: 14 }} />;
|
||||
}
|
||||
if (snapshot.ready) {
|
||||
return <CheckCircleFilled style={{ fontSize: 14 }} />;
|
||||
}
|
||||
return <ExclamationCircleFilled style={{ fontSize: 14 }} />;
|
||||
};
|
||||
|
||||
const AIChatComposerStatus: React.FC<AIChatComposerStatusProps> = ({
|
||||
snapshot,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
onAction,
|
||||
}) => {
|
||||
const palette = resolvePalette(snapshot.severity, darkMode);
|
||||
const handleAction = () => {
|
||||
if (snapshot.action && typeof onAction === 'function') {
|
||||
onAction(snapshot.action.key);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-ai-chat-composer-status="true"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 10,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 12,
|
||||
background: palette.background,
|
||||
border: `1px solid ${palette.borderColor}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, minWidth: 0 }}>
|
||||
<div style={{ color: palette.iconColor, marginTop: 1, flexShrink: 0 }}>
|
||||
{resolveIcon(snapshot)}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
color: palette.labelColor,
|
||||
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.72)',
|
||||
}}
|
||||
>
|
||||
{snapshot.label}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: overlayTheme.titleText }}>
|
||||
{snapshot.title}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: overlayTheme.mutedText,
|
||||
lineHeight: 1.5,
|
||||
marginTop: 4,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{snapshot.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{snapshot.action && typeof onAction === 'function' && (
|
||||
<Button
|
||||
size="small"
|
||||
type="default"
|
||||
onClick={handleAction}
|
||||
style={{ borderRadius: 8, flexShrink: 0 }}
|
||||
>
|
||||
{snapshot.action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIChatComposerStatus;
|
||||
@@ -20,6 +20,19 @@ vi.mock('../../../wailsjs/go/app/App', () => ({
|
||||
DBGetColumns: vi.fn(),
|
||||
}));
|
||||
|
||||
const baseProvider = {
|
||||
id: 'provider-1',
|
||||
type: 'openai' as const,
|
||||
name: 'OpenAI 主账号',
|
||||
apiKey: '',
|
||||
hasSecret: true,
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: '',
|
||||
models: [] as string[],
|
||||
maxTokens: 32000,
|
||||
temperature: 0.2,
|
||||
};
|
||||
|
||||
const renderAIChatInput = (overrides: Partial<React.ComponentProps<typeof AIChatInput>> = {}) => renderToStaticMarkup(
|
||||
<AIChatInput
|
||||
input=""
|
||||
@@ -32,12 +45,12 @@ const renderAIChatInput = (overrides: Partial<React.ComponentProps<typeof AIChat
|
||||
handleKeyDown={() => {}}
|
||||
activeConnName=""
|
||||
activeContext={null}
|
||||
activeProvider={{ model: '', models: [] }}
|
||||
activeProvider={baseProvider}
|
||||
dynamicModels={[]}
|
||||
loadingModels={false}
|
||||
sendShortcutBinding={{ combo: 'Enter', enabled: true }}
|
||||
composerNotice={null}
|
||||
onComposerNoticeAction={() => {}}
|
||||
onComposerAction={() => {}}
|
||||
onModelChange={() => {}}
|
||||
onFetchModels={() => {}}
|
||||
textareaRef={React.createRef<HTMLTextAreaElement>()}
|
||||
@@ -64,7 +77,7 @@ describe('AIChatInput notice layout', () => {
|
||||
handleKeyDown={() => {}}
|
||||
activeConnName=""
|
||||
activeContext={null}
|
||||
activeProvider={{ model: '', models: [] }}
|
||||
activeProvider={baseProvider}
|
||||
dynamicModels={[]}
|
||||
loadingModels={false}
|
||||
sendShortcutBinding={{ combo: 'Enter', enabled: true }}
|
||||
@@ -77,7 +90,7 @@ describe('AIChatInput notice layout', () => {
|
||||
label: '重新加载模型',
|
||||
},
|
||||
}}
|
||||
onComposerNoticeAction={() => {}}
|
||||
onComposerAction={() => {}}
|
||||
onModelChange={() => {}}
|
||||
onFetchModels={() => {}}
|
||||
textareaRef={React.createRef<HTMLTextAreaElement>()}
|
||||
@@ -110,13 +123,13 @@ describe('AIChatInput notice layout', () => {
|
||||
handleKeyDown={() => {}}
|
||||
activeConnName=""
|
||||
activeContext={null}
|
||||
activeProvider={{ model: '', models: [] }}
|
||||
activeProvider={baseProvider}
|
||||
dynamicModels={[]}
|
||||
loadingModels={false}
|
||||
sendShortcutBinding={{ combo: 'Meta+Enter', enabled: true }}
|
||||
shortcutPlatform="mac"
|
||||
composerNotice={null}
|
||||
onComposerNoticeAction={() => {}}
|
||||
onComposerAction={() => {}}
|
||||
onModelChange={() => {}}
|
||||
onFetchModels={() => {}}
|
||||
textareaRef={React.createRef<HTMLTextAreaElement>()}
|
||||
@@ -144,12 +157,12 @@ describe('AIChatInput notice layout', () => {
|
||||
handleKeyDown={() => {}}
|
||||
activeConnName=""
|
||||
activeContext={null}
|
||||
activeProvider={{ model: 'gpt-5.5', models: ['gpt-5.5'] }}
|
||||
activeProvider={{ ...baseProvider, model: 'gpt-5.5', models: ['gpt-5.5'] }}
|
||||
dynamicModels={[]}
|
||||
loadingModels={false}
|
||||
sendShortcutBinding={{ combo: 'Enter', enabled: true }}
|
||||
composerNotice={null}
|
||||
onComposerNoticeAction={() => {}}
|
||||
onComposerAction={() => {}}
|
||||
onModelChange={() => {}}
|
||||
onFetchModels={() => {}}
|
||||
textareaRef={React.createRef<HTMLTextAreaElement>()}
|
||||
@@ -196,9 +209,37 @@ describe('AIChatInput notice layout', () => {
|
||||
label: '打开 AI 设置',
|
||||
},
|
||||
},
|
||||
onComposerNoticeAction: () => {},
|
||||
onComposerAction: () => {},
|
||||
});
|
||||
|
||||
expect(markup).toContain('打开 AI 设置');
|
||||
});
|
||||
|
||||
it('renders a proactive readiness status when no active provider is configured yet', () => {
|
||||
const markup = renderAIChatInput({ activeProvider: null });
|
||||
|
||||
expect(markup).toContain('data-ai-chat-composer-status="true"');
|
||||
expect(markup).toContain('还没有配置 AI 供应商');
|
||||
expect(markup).toContain('打开 AI 设置');
|
||||
});
|
||||
|
||||
it('surfaces incomplete provider state before send when base url or secret is missing', () => {
|
||||
const markup = renderAIChatInput({
|
||||
activeProvider: {
|
||||
id: 'provider-1',
|
||||
type: 'custom',
|
||||
name: '自建代理',
|
||||
apiKey: '',
|
||||
hasSecret: false,
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
models: [],
|
||||
maxTokens: 16000,
|
||||
temperature: 0.7,
|
||||
},
|
||||
});
|
||||
|
||||
expect(markup).toContain('自建代理 还缺少 密钥、接口地址');
|
||||
expect(markup).toContain('修复供应商配置');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Input, Select, Tooltip, message, Button } from 'antd';
|
||||
import { CodeOutlined, DatabaseOutlined, DownOutlined, SendOutlined, StopOutlined, TableOutlined, PictureOutlined } from '@ant-design/icons';
|
||||
import { Input, Tooltip, message, Button } from 'antd';
|
||||
import { CodeOutlined, DatabaseOutlined, SendOutlined, StopOutlined, TableOutlined, PictureOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../../store';
|
||||
import { DBGetTables, DBShowCreateTable, DBGetDatabases, DBGetColumns } from '../../../wailsjs/go/app/App';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
import type { AIComposerNotice } from '../../utils/aiComposerNotice';
|
||||
import type { AIComposerNotice, AIComposerNoticeAction } from '../../utils/aiComposerNotice';
|
||||
import type { AIProviderConfig } from '../../types';
|
||||
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
|
||||
import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool';
|
||||
import { getAIChatSendShortcutLabel } from '../../utils/aiChatSendShortcut';
|
||||
@@ -12,8 +13,11 @@ import type { ShortcutPlatform, ShortcutPlatformBinding } from '../../utils/shor
|
||||
import AIContextSelectorModal from './AIContextSelectorModal';
|
||||
import AISlashCommandMenu, { type AISlashCommandDefinition } from './AISlashCommandMenu';
|
||||
import AIChatComposerNotice from './AIChatComposerNotice';
|
||||
import AIChatComposerStatus from './AIChatComposerStatus';
|
||||
import AIChatAttachmentStrip from './AIChatAttachmentStrip';
|
||||
import AIChatContextPreview from './AIChatContextPreview';
|
||||
import AIChatProviderModelSelect from './AIChatProviderModelSelect';
|
||||
import { buildAIChatReadinessSnapshot } from './aiChatReadiness';
|
||||
import { filterAISlashCommands } from './aiSlashCommands';
|
||||
|
||||
interface AIChatInputProps {
|
||||
@@ -26,14 +30,14 @@ interface AIChatInputProps {
|
||||
onStop: () => void;
|
||||
handleKeyDown: (e: React.KeyboardEvent) => void;
|
||||
activeConnName: string;
|
||||
activeContext: any;
|
||||
activeProvider: any;
|
||||
activeContext: { connectionId?: string | null; dbName?: string | null } | null;
|
||||
activeProvider: AIProviderConfig | null;
|
||||
dynamicModels: string[];
|
||||
loadingModels: boolean;
|
||||
sendShortcutBinding: ShortcutPlatformBinding;
|
||||
shortcutPlatform?: ShortcutPlatform;
|
||||
composerNotice?: AIComposerNotice | null;
|
||||
onComposerNoticeAction?: () => void;
|
||||
onComposerAction?: (actionKey: AIComposerNoticeAction) => void;
|
||||
onModelChange: (val: string) => void;
|
||||
onFetchModels: () => void;
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
@@ -49,7 +53,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, onComposerNoticeAction,
|
||||
sendShortcutBinding, shortcutPlatform = 'windows', composerNotice, onComposerAction,
|
||||
onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme,
|
||||
contextUsageChars, maxContextChars, isV2Ui = false
|
||||
}) => {
|
||||
@@ -100,6 +104,13 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
|
||||
const connectionKey = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default';
|
||||
const activeContextItems = aiContexts[connectionKey] || [];
|
||||
const composerReadiness = React.useMemo(() => buildAIChatReadinessSnapshot({
|
||||
activeProvider,
|
||||
dynamicModels,
|
||||
loadingModels,
|
||||
activeContext,
|
||||
activeContextItems,
|
||||
}), [activeProvider, dynamicModels, loadingModels, activeContext, activeContextItems]);
|
||||
|
||||
const fetchTablesForDb = async (dbName: string, connConfig: any) => {
|
||||
setContextLoading(true);
|
||||
@@ -159,6 +170,9 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
};
|
||||
|
||||
const handleAppendContext = async () => {
|
||||
if (!activeContext?.connectionId) {
|
||||
return;
|
||||
}
|
||||
const conn = useStore.getState().connections.find(c => c.id === activeContext.connectionId);
|
||||
if (!conn) return;
|
||||
|
||||
@@ -260,6 +274,16 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
textareaRef.current?.focus();
|
||||
}, [setInput, textareaRef]);
|
||||
|
||||
const handleComposerNoticeAction = React.useCallback(() => {
|
||||
if (composerNotice?.action?.key && typeof onComposerAction === 'function') {
|
||||
onComposerAction(composerNotice.action.key);
|
||||
}
|
||||
}, [composerNotice?.action?.key, onComposerAction]);
|
||||
const composerActionHandler = typeof onComposerAction === 'function' ? onComposerAction : undefined;
|
||||
const composerNoticeActionHandler = composerNotice?.action?.key && composerActionHandler
|
||||
? handleComposerNoticeAction
|
||||
: undefined;
|
||||
|
||||
const handleRemoveDraftImage = React.useCallback((index: number) => {
|
||||
setDraftImages(prev => prev.filter((_, currentIndex) => currentIndex !== index));
|
||||
}, [setDraftImages]);
|
||||
@@ -303,8 +327,16 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
darkMode={darkMode}
|
||||
textColor={textColor}
|
||||
mutedColor={mutedColor}
|
||||
onComposerNoticeAction={onComposerNoticeAction}
|
||||
onComposerNoticeAction={composerNoticeActionHandler}
|
||||
/>
|
||||
{!composerNotice && (
|
||||
<AIChatComposerStatus
|
||||
snapshot={composerReadiness}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
onAction={composerActionHandler}
|
||||
/>
|
||||
)}
|
||||
<div data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
|
||||
<AISlashCommandMenu
|
||||
visible={showSlashMenu}
|
||||
@@ -351,25 +383,14 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{activeProvider && (
|
||||
<Select
|
||||
size="small"
|
||||
variant="filled"
|
||||
value={activeProvider.model || undefined}
|
||||
onChange={onModelChange}
|
||||
onOpenChange={(open) => {
|
||||
if (open && dynamicModels.length === 0 && (activeProvider.models || []).length === 0) {
|
||||
onFetchModels();
|
||||
}
|
||||
}}
|
||||
loading={loadingModels}
|
||||
options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))}
|
||||
style={{ width: 130, fontSize: 11, background: 'transparent' }}
|
||||
styles={{ popup: { root: { minWidth: 200 } } }}
|
||||
showSearch
|
||||
placeholder="选择模型"
|
||||
/>
|
||||
)}
|
||||
<AIChatProviderModelSelect
|
||||
activeProvider={activeProvider}
|
||||
dynamicModels={dynamicModels}
|
||||
loadingModels={loadingModels}
|
||||
variant="legacy"
|
||||
onModelChange={onModelChange}
|
||||
onFetchModels={onFetchModels}
|
||||
/>
|
||||
|
||||
{contextUsageChars !== undefined && maxContextChars !== undefined && (
|
||||
<Tooltip title={`当前会话记忆已用字符。达到限制(${(maxContextChars/1000).toFixed(0)}k)时将触发自动压缩。`}>
|
||||
@@ -507,8 +528,16 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
darkMode={darkMode}
|
||||
textColor={textColor}
|
||||
mutedColor={mutedColor}
|
||||
onComposerNoticeAction={onComposerNoticeAction}
|
||||
onComposerNoticeAction={composerNoticeActionHandler}
|
||||
/>
|
||||
{!composerNotice && (
|
||||
<AIChatComposerStatus
|
||||
snapshot={composerReadiness}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
onAction={composerActionHandler}
|
||||
/>
|
||||
)}
|
||||
<div className="gn-v2-ai-input-box" data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
|
||||
<AISlashCommandMenu
|
||||
visible={showSlashMenu}
|
||||
@@ -602,24 +631,14 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{activeProvider && (
|
||||
<Select
|
||||
size="small"
|
||||
value={activeProvider.model || undefined}
|
||||
onChange={onModelChange}
|
||||
onOpenChange={(open) => {
|
||||
if (open && dynamicModels.length === 0 && (activeProvider.models || []).length === 0) {
|
||||
onFetchModels();
|
||||
}
|
||||
}}
|
||||
loading={loadingModels}
|
||||
options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))}
|
||||
styles={{ popup: { root: { minWidth: 200 } } }}
|
||||
placeholder="选择模型"
|
||||
className="gn-v2-ai-model-select"
|
||||
suffixIcon={<DownOutlined />}
|
||||
/>
|
||||
)}
|
||||
<AIChatProviderModelSelect
|
||||
activeProvider={activeProvider}
|
||||
dynamicModels={dynamicModels}
|
||||
loadingModels={loadingModels}
|
||||
variant="v2"
|
||||
onModelChange={onModelChange}
|
||||
onFetchModels={onFetchModels}
|
||||
/>
|
||||
|
||||
<div className="gn-v2-ai-model-spacer" />
|
||||
|
||||
|
||||
73
frontend/src/components/ai/AIChatProviderModelSelect.tsx
Normal file
73
frontend/src/components/ai/AIChatProviderModelSelect.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { DownOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { AIProviderConfig } from '../../types';
|
||||
|
||||
interface AIChatProviderModelSelectProps {
|
||||
activeProvider?: AIProviderConfig | null;
|
||||
dynamicModels: string[];
|
||||
loadingModels: boolean;
|
||||
variant: 'legacy' | 'v2';
|
||||
onModelChange: (value: string) => void;
|
||||
onFetchModels: () => void;
|
||||
}
|
||||
|
||||
const AIChatProviderModelSelect: React.FC<AIChatProviderModelSelectProps> = ({
|
||||
activeProvider,
|
||||
dynamicModels,
|
||||
loadingModels,
|
||||
variant,
|
||||
onModelChange,
|
||||
onFetchModels,
|
||||
}) => {
|
||||
if (!activeProvider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = (dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || []))
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
.map((model) => ({ label: model, value: model }));
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open && options.length === 0) {
|
||||
onFetchModels();
|
||||
}
|
||||
};
|
||||
|
||||
if (variant === 'legacy') {
|
||||
return (
|
||||
<Select
|
||||
size="small"
|
||||
variant="filled"
|
||||
value={activeProvider.model || undefined}
|
||||
onChange={onModelChange}
|
||||
onOpenChange={handleOpenChange}
|
||||
loading={loadingModels}
|
||||
options={options}
|
||||
style={{ width: 130, fontSize: 11, background: 'transparent' }}
|
||||
styles={{ popup: { root: { minWidth: 200 } } }}
|
||||
showSearch
|
||||
placeholder="选择模型"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
size="small"
|
||||
value={activeProvider.model || undefined}
|
||||
onChange={onModelChange}
|
||||
onOpenChange={handleOpenChange}
|
||||
loading={loadingModels}
|
||||
options={options}
|
||||
styles={{ popup: { root: { minWidth: 200 } } }}
|
||||
placeholder="选择模型"
|
||||
className="gn-v2-ai-model-select"
|
||||
suffixIcon={<DownOutlined />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIChatProviderModelSelect;
|
||||
95
frontend/src/components/ai/aiChatReadiness.test.ts
Normal file
95
frontend/src/components/ai/aiChatReadiness.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildAIChatReadinessSnapshot } from './aiChatReadiness';
|
||||
|
||||
describe('buildAIChatReadinessSnapshot', () => {
|
||||
it('reports missing provider when no active provider is configured', () => {
|
||||
const snapshot = buildAIChatReadinessSnapshot({
|
||||
providers: [],
|
||||
activeProviderId: '',
|
||||
});
|
||||
|
||||
expect(snapshot.status).toBe('missing_provider');
|
||||
expect(snapshot.ready).toBe(false);
|
||||
expect(snapshot.action?.key).toBe('open-settings');
|
||||
expect(snapshot.title).toContain('还没有配置 AI 供应商');
|
||||
});
|
||||
|
||||
it('reports incomplete provider when secret or base url is missing', () => {
|
||||
const snapshot = buildAIChatReadinessSnapshot({
|
||||
providers: [{
|
||||
id: 'provider-1',
|
||||
type: 'custom',
|
||||
name: '自建代理',
|
||||
apiKey: '',
|
||||
hasSecret: false,
|
||||
baseUrl: '',
|
||||
model: 'gpt-5.5',
|
||||
models: ['gpt-5.5'],
|
||||
maxTokens: 16000,
|
||||
temperature: 0.7,
|
||||
}],
|
||||
activeProviderId: 'provider-1',
|
||||
});
|
||||
|
||||
expect(snapshot.status).toBe('provider_incomplete');
|
||||
expect(snapshot.issues).toEqual(['missing_secret', 'missing_base_url']);
|
||||
expect(snapshot.action?.label).toContain('修复');
|
||||
expect(snapshot.message).toContain('还缺少 密钥、接口地址');
|
||||
});
|
||||
|
||||
it('reports missing model and available model count when provider has no selected model', () => {
|
||||
const snapshot = buildAIChatReadinessSnapshot({
|
||||
providers: [{
|
||||
id: 'provider-1',
|
||||
type: 'openai',
|
||||
name: 'OpenAI 主账号',
|
||||
apiKey: '',
|
||||
hasSecret: true,
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: '',
|
||||
models: ['gpt-5.5', 'gpt-4.1'],
|
||||
maxTokens: 32000,
|
||||
temperature: 0.2,
|
||||
}],
|
||||
activeProviderId: 'provider-1',
|
||||
});
|
||||
|
||||
expect(snapshot.status).toBe('missing_model');
|
||||
expect(snapshot.selectableModelCount).toBe(2);
|
||||
expect(snapshot.action?.key).toBe('reload-models');
|
||||
expect(snapshot.description).toContain('当前已发现 2 个可选模型');
|
||||
});
|
||||
|
||||
it('reports ready with context summary when provider and context are already attached', () => {
|
||||
const snapshot = buildAIChatReadinessSnapshot({
|
||||
providers: [{
|
||||
id: 'provider-1',
|
||||
type: 'openai',
|
||||
name: 'OpenAI 主账号',
|
||||
apiKey: '',
|
||||
hasSecret: true,
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-5.5',
|
||||
models: ['gpt-5.5'],
|
||||
maxTokens: 32000,
|
||||
temperature: 0.2,
|
||||
}],
|
||||
activeProviderId: 'provider-1',
|
||||
activeContext: {
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'demo',
|
||||
},
|
||||
activeContextItems: [{
|
||||
dbName: 'demo',
|
||||
tableName: 'orders',
|
||||
ddl: 'CREATE TABLE orders (...)',
|
||||
}],
|
||||
});
|
||||
|
||||
expect(snapshot.status).toBe('ready');
|
||||
expect(snapshot.ready).toBe(true);
|
||||
expect(snapshot.contextAttachedCount).toBe(1);
|
||||
expect(snapshot.title).toContain('OpenAI 主账号 / gpt-5.5');
|
||||
});
|
||||
});
|
||||
244
frontend/src/components/ai/aiChatReadiness.ts
Normal file
244
frontend/src/components/ai/aiChatReadiness.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { AIContextItem, AIProviderConfig } from '../../types';
|
||||
|
||||
export type AIChatReadinessActionKey = 'open-settings' | 'reload-models';
|
||||
|
||||
export type AIChatReadinessStatus =
|
||||
| 'missing_provider'
|
||||
| 'provider_incomplete'
|
||||
| 'missing_model'
|
||||
| 'loading_models'
|
||||
| 'ready';
|
||||
|
||||
export type AIChatReadinessIssue =
|
||||
| 'missing_secret'
|
||||
| 'missing_base_url'
|
||||
| 'missing_selected_model';
|
||||
|
||||
export interface AIChatReadinessSnapshot {
|
||||
status: AIChatReadinessStatus;
|
||||
ready: boolean;
|
||||
severity: 'success' | 'warning' | 'error' | 'info';
|
||||
label: string;
|
||||
title: string;
|
||||
description: string;
|
||||
providerCount: number;
|
||||
hasActiveProvider: boolean;
|
||||
hasConnectionContext: boolean;
|
||||
contextAttachedCount: number;
|
||||
selectableModelCount: number;
|
||||
issues: AIChatReadinessIssue[];
|
||||
action?: {
|
||||
key: AIChatReadinessActionKey;
|
||||
label: string;
|
||||
};
|
||||
activeProvider: null | {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
baseUrlHost: string;
|
||||
hasSecret: boolean;
|
||||
declaredModelCount: number;
|
||||
dynamicModelCount: number;
|
||||
};
|
||||
message: string;
|
||||
}
|
||||
|
||||
const trimText = (value: unknown): string => String(value || '').trim();
|
||||
|
||||
const getProviderHost = (baseUrl: string): string => {
|
||||
const normalized = trimText(baseUrl);
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return new URL(normalized).host;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const hasProviderSecret = (provider: AIProviderConfig): boolean =>
|
||||
provider.hasSecret ?? Boolean(provider.secretRef || provider.apiKey);
|
||||
|
||||
const getSelectedProvider = (params: {
|
||||
providers?: AIProviderConfig[];
|
||||
activeProvider?: AIProviderConfig | null;
|
||||
activeProviderId?: string | null;
|
||||
}): AIProviderConfig | null => {
|
||||
if (params.activeProvider) {
|
||||
return params.activeProvider;
|
||||
}
|
||||
const providers = Array.isArray(params.providers) ? params.providers : [];
|
||||
const activeProviderId = trimText(params.activeProviderId);
|
||||
if (!activeProviderId) {
|
||||
return null;
|
||||
}
|
||||
return providers.find((provider) => provider.id === activeProviderId) || null;
|
||||
};
|
||||
|
||||
export const formatAIChatProviderIssueLabels = (issues: AIChatReadinessIssue[]): string[] => {
|
||||
const issueLabels: Record<AIChatReadinessIssue, string> = {
|
||||
missing_secret: '密钥',
|
||||
missing_base_url: '接口地址',
|
||||
missing_selected_model: '模型',
|
||||
};
|
||||
return issues
|
||||
.map((issue) => issueLabels[issue])
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export const buildAIChatReadinessSnapshot = (params: {
|
||||
providers?: AIProviderConfig[];
|
||||
activeProvider?: AIProviderConfig | null;
|
||||
activeProviderId?: string | null;
|
||||
dynamicModels?: string[];
|
||||
loadingModels?: boolean;
|
||||
activeContext?: { connectionId?: string | null; dbName?: string | null } | null;
|
||||
activeContextItems?: AIContextItem[];
|
||||
}): AIChatReadinessSnapshot => {
|
||||
const providers = Array.isArray(params.providers) ? params.providers : [];
|
||||
const activeProvider = getSelectedProvider(params);
|
||||
const providerCount = providers.length > 0 ? providers.length : (activeProvider ? 1 : 0);
|
||||
const dynamicModels = (Array.isArray(params.dynamicModels) ? params.dynamicModels : [])
|
||||
.map((item) => trimText(item))
|
||||
.filter(Boolean);
|
||||
const activeContextItems = Array.isArray(params.activeContextItems) ? params.activeContextItems : [];
|
||||
const declaredModels = activeProvider?.models?.map((item) => trimText(item)).filter(Boolean) || [];
|
||||
const selectableModelCount = dynamicModels.length > 0 ? dynamicModels.length : declaredModels.length;
|
||||
const hasConnectionContext = Boolean(trimText(params.activeContext?.connectionId));
|
||||
const contextAttachedCount = activeContextItems.length;
|
||||
|
||||
if (!activeProvider) {
|
||||
const title = providers.length > 0
|
||||
? '已配置供应商,但当前没有选中生效项'
|
||||
: '还没有配置 AI 供应商';
|
||||
const description = providers.length > 0
|
||||
? '先在 AI 设置里选中一个活动供应商,然后再发送。'
|
||||
: '先在 AI 设置里添加并启用一个模型供应商。';
|
||||
return {
|
||||
status: 'missing_provider',
|
||||
ready: false,
|
||||
severity: 'warning',
|
||||
label: '未就绪',
|
||||
title,
|
||||
description,
|
||||
providerCount,
|
||||
hasActiveProvider: false,
|
||||
hasConnectionContext,
|
||||
contextAttachedCount,
|
||||
selectableModelCount: 0,
|
||||
issues: [],
|
||||
action: {
|
||||
key: 'open-settings',
|
||||
label: '打开 AI 设置',
|
||||
},
|
||||
activeProvider: null,
|
||||
message: `${title}。${description}`,
|
||||
};
|
||||
}
|
||||
|
||||
const issues: AIChatReadinessIssue[] = [];
|
||||
if (!hasProviderSecret(activeProvider)) {
|
||||
issues.push('missing_secret');
|
||||
}
|
||||
if (!trimText(activeProvider.baseUrl)) {
|
||||
issues.push('missing_base_url');
|
||||
}
|
||||
if (!trimText(activeProvider.model)) {
|
||||
issues.push('missing_selected_model');
|
||||
}
|
||||
|
||||
const providerSummary = {
|
||||
id: activeProvider.id,
|
||||
name: trimText(activeProvider.name),
|
||||
type: activeProvider.type,
|
||||
model: trimText(activeProvider.model),
|
||||
baseUrl: trimText(activeProvider.baseUrl),
|
||||
baseUrlHost: getProviderHost(activeProvider.baseUrl),
|
||||
hasSecret: hasProviderSecret(activeProvider),
|
||||
declaredModelCount: declaredModels.length,
|
||||
dynamicModelCount: dynamicModels.length,
|
||||
};
|
||||
|
||||
const blockingProviderIssues = issues.filter((issue) => issue !== 'missing_selected_model');
|
||||
if (blockingProviderIssues.length > 0) {
|
||||
const missingLabels = formatAIChatProviderIssueLabels(blockingProviderIssues);
|
||||
const title = `${providerSummary.name || providerSummary.id || '当前供应商'} 还缺少 ${missingLabels.join('、')}`;
|
||||
const description = '先补全供应商配置再发送,避免请求直接失败。';
|
||||
return {
|
||||
status: 'provider_incomplete',
|
||||
ready: false,
|
||||
severity: 'error',
|
||||
label: '需修复',
|
||||
title,
|
||||
description,
|
||||
providerCount,
|
||||
hasActiveProvider: true,
|
||||
hasConnectionContext,
|
||||
contextAttachedCount,
|
||||
selectableModelCount,
|
||||
issues,
|
||||
action: {
|
||||
key: 'open-settings',
|
||||
label: '修复供应商配置',
|
||||
},
|
||||
activeProvider: providerSummary,
|
||||
message: `${title}。${description}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!providerSummary.model) {
|
||||
const title = params.loadingModels
|
||||
? `正在加载 ${providerSummary.name || providerSummary.id || '当前供应商'} 的模型列表`
|
||||
: `先为 ${providerSummary.name || providerSummary.id || '当前供应商'} 选择一个模型`;
|
||||
const description = selectableModelCount > 0
|
||||
? `当前已发现 ${selectableModelCount} 个可选模型,选中后即可发送。`
|
||||
: '如果列表为空,请检查供应商入口、密钥和模型权限。';
|
||||
return {
|
||||
status: params.loadingModels ? 'loading_models' : 'missing_model',
|
||||
ready: false,
|
||||
severity: params.loadingModels ? 'info' : 'warning',
|
||||
label: params.loadingModels ? '加载中' : '未选模型',
|
||||
title,
|
||||
description,
|
||||
providerCount,
|
||||
hasActiveProvider: true,
|
||||
hasConnectionContext,
|
||||
contextAttachedCount,
|
||||
selectableModelCount,
|
||||
issues,
|
||||
action: {
|
||||
key: 'reload-models',
|
||||
label: '重新加载模型',
|
||||
},
|
||||
activeProvider: providerSummary,
|
||||
message: `${title}。${description}`,
|
||||
};
|
||||
}
|
||||
|
||||
const title = `AI 已就绪:${providerSummary.name || providerSummary.id} / ${providerSummary.model}`;
|
||||
const description = contextAttachedCount > 0
|
||||
? `当前已关联 ${contextAttachedCount} 张表结构上下文,可直接发送。`
|
||||
: hasConnectionContext
|
||||
? '已选中当前连接;如需更准的数据库语义,建议再关联表结构上下文。'
|
||||
: '可直接发送;如需更准的数据库语义,建议先选中连接或关联表结构。';
|
||||
|
||||
return {
|
||||
status: 'ready',
|
||||
ready: true,
|
||||
severity: 'success',
|
||||
label: '已就绪',
|
||||
title,
|
||||
description,
|
||||
providerCount,
|
||||
hasActiveProvider: true,
|
||||
hasConnectionContext,
|
||||
contextAttachedCount,
|
||||
selectableModelCount,
|
||||
issues: [],
|
||||
activeProvider: providerSummary,
|
||||
message: `${title}。${description}`,
|
||||
};
|
||||
};
|
||||
@@ -278,6 +278,52 @@ describe('aiLocalToolExecutor', () => {
|
||||
expect(result.content).not.toContain('secret-token');
|
||||
});
|
||||
|
||||
it('returns the current chat readiness snapshot so the model can inspect why ai input cannot send yet', async () => {
|
||||
const result = await executeLocalAIToolCall({
|
||||
toolCall: buildToolCall('inspect_ai_chat_readiness', {}),
|
||||
connections: [buildConnection()],
|
||||
mcpTools: [],
|
||||
dynamicModels: ['gpt-5.5', 'gpt-4.1-mini'],
|
||||
activeContext: {
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'demo',
|
||||
},
|
||||
aiContexts: {
|
||||
'conn-1:demo': [{
|
||||
dbName: 'demo',
|
||||
tableName: 'orders',
|
||||
ddl: 'CREATE TABLE orders (...)',
|
||||
}],
|
||||
},
|
||||
toolContextMap: new Map(),
|
||||
runtime: {
|
||||
getDatabases: vi.fn(),
|
||||
getTables: vi.fn(),
|
||||
getAIRuntimeState: vi.fn().mockResolvedValue({
|
||||
activeProviderId: 'provider-1',
|
||||
providers: [{
|
||||
id: 'provider-1',
|
||||
type: 'openai',
|
||||
name: 'OpenAI 主账号',
|
||||
apiKey: '',
|
||||
hasSecret: true,
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: '',
|
||||
models: ['gpt-5.5', 'gpt-4.1-mini'],
|
||||
maxTokens: 32000,
|
||||
temperature: 0.2,
|
||||
}],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain('"status":"missing_model"');
|
||||
expect(result.content).toContain('"contextAttachedCount":1');
|
||||
expect(result.content).toContain('"selectableModelCount":2');
|
||||
expect(result.content).toContain('OpenAI 主账号');
|
||||
});
|
||||
|
||||
it('returns the current mcp setup snapshot so the model can inspect configured servers and client install state', async () => {
|
||||
const result = await executeLocalAIToolCall({
|
||||
toolCall: buildToolCall('inspect_mcp_setup', {}),
|
||||
|
||||
@@ -18,6 +18,7 @@ import { buildAIContextSnapshot } from './aiContextInsights';
|
||||
import { buildCurrentConnectionSnapshot } from './aiConnectionInsights';
|
||||
import { buildMCPSetupSnapshot } from './aiMCPInsights';
|
||||
import { buildAIGuidanceSnapshot } from './aiPromptInsights';
|
||||
import { buildAIChatReadinessSnapshot } from './aiChatReadiness';
|
||||
import { buildAIProviderSnapshot } from './aiProviderInsights';
|
||||
import { buildAIRuntimeSnapshot } from './aiRuntimeInsights';
|
||||
import {
|
||||
@@ -122,6 +123,24 @@ export async function executeSnapshotInspectionToolCall(
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
case 'inspect_ai_chat_readiness': {
|
||||
const runtimeState = typeof runtime?.getAIRuntimeState === 'function'
|
||||
? await runtime.getAIRuntimeState()
|
||||
: undefined;
|
||||
const activeContextKey = activeContext?.connectionId
|
||||
? `${activeContext.connectionId}:${activeContext.dbName || ''}`
|
||||
: 'default';
|
||||
return {
|
||||
content: JSON.stringify(buildAIChatReadinessSnapshot({
|
||||
providers: Array.isArray(runtimeState?.providers) ? runtimeState.providers : [],
|
||||
activeProviderId: runtimeState?.activeProviderId || '',
|
||||
dynamicModels,
|
||||
activeContext,
|
||||
activeContextItems: aiContexts[activeContextKey] || [],
|
||||
})),
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
case 'inspect_mcp_setup': {
|
||||
const [mcpServers, mcpClientInstallStatuses] = await Promise.all([
|
||||
typeof runtime?.getMCPServers === 'function' ? runtime.getMCPServers() : Promise.resolve(undefined),
|
||||
@@ -225,6 +244,7 @@ export async function executeSnapshotInspectionToolCall(
|
||||
const label = {
|
||||
inspect_ai_runtime: '读取当前 AI 运行状态失败',
|
||||
inspect_ai_providers: '读取当前 AI 供应商配置失败',
|
||||
inspect_ai_chat_readiness: '读取 AI 聊天发送前置状态失败',
|
||||
inspect_mcp_setup: '读取 MCP 配置状态失败',
|
||||
inspect_ai_guidance: '读取当前 AI 提示与技能配置失败',
|
||||
inspect_current_connection: '读取当前连接失败',
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('buildAISystemContextMessages', () => {
|
||||
connections: [connections[0]],
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_runtime', 'inspect_ai_providers', 'inspect_mcp_setup', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_saved_queries', 'inspect_sql_snippets', 'get_columns'],
|
||||
availableToolNames: ['inspect_workspace_tabs', 'inspect_ai_runtime', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_mcp_setup', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_saved_queries', 'inspect_sql_snippets', 'get_columns'],
|
||||
skills,
|
||||
userPromptSettings,
|
||||
});
|
||||
@@ -77,6 +77,7 @@ describe('buildAISystemContextMessages', () => {
|
||||
expect(joined).toContain('inspect_workspace_tabs 盘点当前工作区');
|
||||
expect(joined).toContain('inspect_ai_runtime 读取当前 AI 运行状态');
|
||||
expect(joined).toContain('inspect_ai_providers 读取真实供应商配置');
|
||||
expect(joined).toContain('inspect_ai_chat_readiness 读取真实发送前置状态');
|
||||
expect(joined).toContain('inspect_mcp_setup 读取真实 MCP 配置');
|
||||
expect(joined).toContain('inspect_ai_guidance 读取真实提示与技能配置');
|
||||
expect(joined).toContain('inspect_ai_context 读取当前挂载的表结构上下文');
|
||||
|
||||
@@ -121,6 +121,19 @@ const appendAIProviderInspectionGuidance = (
|
||||
});
|
||||
};
|
||||
|
||||
const appendAIChatReadinessInspectionGuidance = (
|
||||
messages: AISystemContextMessage[],
|
||||
availableToolNames: string[],
|
||||
) => {
|
||||
if (!availableToolNames.includes('inspect_ai_chat_readiness')) {
|
||||
return;
|
||||
}
|
||||
messages.push({
|
||||
role: 'system',
|
||||
content: '如果用户提到“为什么现在不能发送”“当前 AI 聊天到底缺什么配置”“输入区准备好了没有”,优先调用 inspect_ai_chat_readiness 读取真实发送前置状态,不要只凭界面现象或记忆判断。',
|
||||
});
|
||||
};
|
||||
|
||||
const appendMCPSetupInspectionGuidance = (
|
||||
messages: AISystemContextMessage[],
|
||||
availableToolNames: string[],
|
||||
@@ -364,6 +377,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
});
|
||||
}
|
||||
appendAIRuntimeInspectionGuidance(systemMessages, availableToolNames);
|
||||
appendAIChatReadinessInspectionGuidance(systemMessages, availableToolNames);
|
||||
appendAIProviderInspectionGuidance(systemMessages, availableToolNames);
|
||||
appendMCPSetupInspectionGuidance(systemMessages, availableToolNames);
|
||||
appendAIGuidanceInspectionGuidance(systemMessages, availableToolNames);
|
||||
|
||||
@@ -25,6 +25,7 @@ interface AIToolCallingBlockProps {
|
||||
const TOOL_ACTION_LABELS: Record<string, string> = {
|
||||
inspect_ai_runtime: '读取当前 AI 运行状态',
|
||||
inspect_ai_providers: '读取当前 AI 供应商与模型配置',
|
||||
inspect_ai_chat_readiness: '读取当前 AI 聊天发送前置状态',
|
||||
inspect_mcp_setup: '读取当前 MCP 配置状态',
|
||||
inspect_ai_guidance: '读取当前 AI 提示与技能配置',
|
||||
get_connections: '获取可用连接信息',
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { AIChatReadinessIssue } from '../components/ai/aiChatReadiness';
|
||||
import { formatAIChatProviderIssueLabels } from '../components/ai/aiChatReadiness';
|
||||
|
||||
export type AIComposerNoticeTone = 'warning' | 'error';
|
||||
export type AIComposerNoticeAction = 'open-settings' | 'reload-models';
|
||||
|
||||
@@ -33,6 +36,22 @@ export const buildMissingModelNotice = (): AIComposerNotice => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const buildIncompleteProviderNotice = (issues: AIChatReadinessIssue[] = []): AIComposerNotice => {
|
||||
const missingLabels = formatAIChatProviderIssueLabels(issues.filter((issue) => issue !== 'missing_selected_model'));
|
||||
const title = missingLabels.length > 0
|
||||
? `当前供应商还缺少 ${missingLabels.join('、')}`
|
||||
: '当前供应商配置还不完整';
|
||||
return {
|
||||
tone: 'error',
|
||||
title,
|
||||
description: '先补全供应商配置再发送,避免请求刚发起就失败。',
|
||||
action: {
|
||||
key: 'open-settings',
|
||||
label: '修复供应商配置',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const buildModelFetchFailedNotice = (error?: string): AIComposerNotice => ({
|
||||
tone: 'error',
|
||||
title: '模型列表加载失败',
|
||||
|
||||
@@ -24,6 +24,13 @@ describe('aiToolRegistry', () => {
|
||||
expect(info?.tool.function.description).toContain('模型列表为空');
|
||||
});
|
||||
|
||||
it('registers the chat-readiness inspector as a builtin tool', () => {
|
||||
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_chat_readiness');
|
||||
expect(info).toBeTruthy();
|
||||
expect(info?.desc).toContain('发送条件');
|
||||
expect(info?.tool.function.description).toContain('当前 AI 聊天输入区');
|
||||
});
|
||||
|
||||
it('registers the ai-guidance inspector as a builtin tool', () => {
|
||||
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_guidance');
|
||||
expect(info).toBeTruthy();
|
||||
@@ -66,6 +73,7 @@ describe('aiToolRegistry', () => {
|
||||
|
||||
expect(tools.some((item) => item.function.name === 'inspect_ai_runtime')).toBe(true);
|
||||
expect(tools.some((item) => item.function.name === 'inspect_ai_providers')).toBe(true);
|
||||
expect(tools.some((item) => item.function.name === 'inspect_ai_chat_readiness')).toBe(true);
|
||||
expect(tools.some((item) => item.function.name === 'inspect_mcp_setup')).toBe(true);
|
||||
expect(tools.some((item) => item.function.name === 'inspect_ai_guidance')).toBe(true);
|
||||
expect(tools.some((item) => item.function.name === 'inspect_current_connection')).toBe(true);
|
||||
|
||||
@@ -343,6 +343,23 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inspect_ai_chat_readiness",
|
||||
icon: "🚦",
|
||||
desc: "查看当前 AI 聊天是否具备发送条件",
|
||||
detail:
|
||||
"返回当前聊天输入区是否已经具备发送条件,包括有没有活动供应商、当前供应商是否缺密钥或接口地址、是否已选模型、当前连接/表结构上下文是否已挂载,以及下一步建议动作。适合用户问“为什么现在不能发送”“输入框到底缺什么配置”“当前 AI 聊天准备好了没有”时先读真实状态。",
|
||||
params: "无参数",
|
||||
tool: {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "inspect_ai_chat_readiness",
|
||||
description:
|
||||
"读取当前 AI 聊天输入区的发送前置状态,包括活动供应商、密钥和接口地址是否完整、是否已选模型、当前连接上下文和已挂载表结构数量,以及建议的下一步动作。适用于用户提到为什么现在不能发送、为什么输入区还没准备好、当前到底缺什么配置时,先读取真实状态再回答。",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inspect_mcp_setup",
|
||||
icon: "🪛",
|
||||
|
||||
Reference in New Issue
Block a user