feat(ai-chat): 增强发送前状态提示并新增就绪探针

This commit is contained in:
Syngnat
2026-06-08 21:47:10 +08:00
parent 5ce5d03d69
commit 7fa23e72c0
17 changed files with 859 additions and 65 deletions

View File

@@ -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}

View File

@@ -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');

View File

@@ -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',

View 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;

View File

@@ -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('修复供应商配置');
});
});

View File

@@ -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" />

View 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;

View 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');
});
});

View 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}`,
};
};

View File

@@ -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', {}),

View File

@@ -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: '读取当前连接失败',

View File

@@ -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 读取当前挂载的表结构上下文');

View File

@@ -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);

View File

@@ -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: '获取可用连接信息',

View File

@@ -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: '模型列表加载失败',

View File

@@ -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);

View File

@@ -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: "🪛",