From 7fa23e72c062ae3c2a2becd1bcb0a2f3768d5ab8 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 8 Jun 2026 21:47:10 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ai-chat):=20=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E5=8F=91=E9=80=81=E5=89=8D=E7=8A=B6=E6=80=81=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9E=E5=B0=B1=E7=BB=AA=E6=8E=A2=E9=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/AIChatPanel.tsx | 37 ++- .../ai/AIBuiltinToolsCatalog.test.tsx | 2 + .../components/ai/AIBuiltinToolsCatalog.tsx | 5 + .../components/ai/AIChatComposerStatus.tsx | 170 ++++++++++++ .../components/ai/AIChatInput.notice.test.tsx | 59 ++++- frontend/src/components/ai/AIChatInput.tsx | 111 ++++---- .../ai/AIChatProviderModelSelect.tsx | 73 ++++++ .../src/components/ai/aiChatReadiness.test.ts | 95 +++++++ frontend/src/components/ai/aiChatReadiness.ts | 244 ++++++++++++++++++ .../components/ai/aiLocalToolExecutor.test.ts | 46 ++++ .../ai/aiSnapshotInspectionToolExecutor.ts | 20 ++ .../ai/aiSystemContextMessages.test.ts | 3 +- .../components/ai/aiSystemContextMessages.ts | 14 + .../messageBubble/AIMessageStatusBlocks.tsx | 1 + frontend/src/utils/aiComposerNotice.ts | 19 ++ frontend/src/utils/aiToolRegistry.test.ts | 8 + frontend/src/utils/aiToolRegistry.ts | 17 ++ 17 files changed, 859 insertions(+), 65 deletions(-) create mode 100644 frontend/src/components/ai/AIChatComposerStatus.tsx create mode 100644 frontend/src/components/ai/AIChatProviderModelSelect.tsx create mode 100644 frontend/src/components/ai/aiChatReadiness.test.ts create mode 100644 frontend/src/components/ai/aiChatReadiness.ts diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index f0d6184..58b0fcf 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -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 = ({ const [input, setInput] = useState(''); const [draftImages, setDraftImages] = useState([]); const [sending, setSending] = useState(false); - const [activeProvider, setActiveProvider] = useState(null); + const [activeProvider, setActiveProvider] = useState(null); const [userPromptSettings, setUserPromptSettings] = useState(EMPTY_AI_USER_PROMPT_SETTINGS); const [mcpTools, setMcpTools] = useState([]); const [skills, setSkills] = useState([]); @@ -417,8 +420,7 @@ export const AIChatPanel: React.FC = ({ }, 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 = ({ 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 = ({ 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 = ({ 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 = ({ sendShortcutBinding={aiChatSendShortcutBinding} shortcutPlatform={activeShortcutPlatform} composerNotice={composerNotice} - onComposerNoticeAction={handleComposerNoticeAction} + onComposerAction={handleComposerAction} onModelChange={handleModelChange} onFetchModels={fetchDynamicModels} textareaRef={textareaRef} diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx index cbeeaf1..98b2bcd 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx @@ -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'); diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx index 907f55c..35847e2 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx @@ -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', diff --git a/frontend/src/components/ai/AIChatComposerStatus.tsx b/frontend/src/components/ai/AIChatComposerStatus.tsx new file mode 100644 index 0000000..93cbce0 --- /dev/null +++ b/frontend/src/components/ai/AIChatComposerStatus.tsx @@ -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 ; + } + if (snapshot.ready) { + return ; + } + return ; +}; + +const AIChatComposerStatus: React.FC = ({ + snapshot, + darkMode, + overlayTheme, + onAction, +}) => { + const palette = resolvePalette(snapshot.severity, darkMode); + const handleAction = () => { + if (snapshot.action && typeof onAction === 'function') { + onAction(snapshot.action.key); + } + }; + + return ( +
+
+
+ {resolveIcon(snapshot)} +
+
+
+ + {snapshot.label} + + + {snapshot.title} + +
+
+ {snapshot.description} +
+
+
+ {snapshot.action && typeof onAction === 'function' && ( + + )} +
+ ); +}; + +export default AIChatComposerStatus; diff --git a/frontend/src/components/ai/AIChatInput.notice.test.tsx b/frontend/src/components/ai/AIChatInput.notice.test.tsx index 60b53d6..f07b62f 100644 --- a/frontend/src/components/ai/AIChatInput.notice.test.tsx +++ b/frontend/src/components/ai/AIChatInput.notice.test.tsx @@ -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> = {}) => renderToStaticMarkup( {}} 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()} @@ -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()} @@ -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()} @@ -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()} @@ -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('修复供应商配置'); + }); }); diff --git a/frontend/src/components/ai/AIChatInput.tsx b/frontend/src/components/ai/AIChatInput.tsx index cfa1784..69afd47 100644 --- a/frontend/src/components/ai/AIChatInput.tsx +++ b/frontend/src/components/ai/AIChatInput.tsx @@ -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; @@ -49,7 +53,7 @@ interface AIChatInputProps { export const AIChatInput: React.FC = ({ input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown, activeConnName, activeContext, activeProvider, dynamicModels, loadingModels, - sendShortcutBinding, shortcutPlatform = 'windows', composerNotice, 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 = ({ 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 = ({ }; 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 = ({ 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 = ({ darkMode={darkMode} textColor={textColor} mutedColor={mutedColor} - onComposerNoticeAction={onComposerNoticeAction} + onComposerNoticeAction={composerNoticeActionHandler} /> + {!composerNotice && ( + + )}
= ({ )} - {activeProvider && ( - { - 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={} - /> - )} +
diff --git a/frontend/src/components/ai/AIChatProviderModelSelect.tsx b/frontend/src/components/ai/AIChatProviderModelSelect.tsx new file mode 100644 index 0000000..20c9585 --- /dev/null +++ b/frontend/src/components/ai/AIChatProviderModelSelect.tsx @@ -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 = ({ + 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 ( + } + /> + ); +}; + +export default AIChatProviderModelSelect; diff --git a/frontend/src/components/ai/aiChatReadiness.test.ts b/frontend/src/components/ai/aiChatReadiness.test.ts new file mode 100644 index 0000000..65ac298 --- /dev/null +++ b/frontend/src/components/ai/aiChatReadiness.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/components/ai/aiChatReadiness.ts b/frontend/src/components/ai/aiChatReadiness.ts new file mode 100644 index 0000000..a3de8c8 --- /dev/null +++ b/frontend/src/components/ai/aiChatReadiness.ts @@ -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 = { + 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}`, + }; +}; diff --git a/frontend/src/components/ai/aiLocalToolExecutor.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.test.ts index e7cdd17..ca4673e 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.test.ts @@ -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', {}), diff --git a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts index 56ecfaa..4f1e0f9 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts @@ -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: '读取当前连接失败', diff --git a/frontend/src/components/ai/aiSystemContextMessages.test.ts b/frontend/src/components/ai/aiSystemContextMessages.test.ts index 975c6d1..32cc846 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.test.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.test.ts @@ -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 读取当前挂载的表结构上下文'); diff --git a/frontend/src/components/ai/aiSystemContextMessages.ts b/frontend/src/components/ai/aiSystemContextMessages.ts index 01bde15..dfb5c18 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.ts @@ -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); diff --git a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx index 8e64e54..ba65263 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx @@ -25,6 +25,7 @@ interface AIToolCallingBlockProps { const TOOL_ACTION_LABELS: Record = { inspect_ai_runtime: '读取当前 AI 运行状态', inspect_ai_providers: '读取当前 AI 供应商与模型配置', + inspect_ai_chat_readiness: '读取当前 AI 聊天发送前置状态', inspect_mcp_setup: '读取当前 MCP 配置状态', inspect_ai_guidance: '读取当前 AI 提示与技能配置', get_connections: '获取可用连接信息', diff --git a/frontend/src/utils/aiComposerNotice.ts b/frontend/src/utils/aiComposerNotice.ts index 79111e0..ffb9149 100644 --- a/frontend/src/utils/aiComposerNotice.ts +++ b/frontend/src/utils/aiComposerNotice.ts @@ -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: '模型列表加载失败', diff --git a/frontend/src/utils/aiToolRegistry.test.ts b/frontend/src/utils/aiToolRegistry.test.ts index d715c4f..db1cab8 100644 --- a/frontend/src/utils/aiToolRegistry.test.ts +++ b/frontend/src/utils/aiToolRegistry.test.ts @@ -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); diff --git a/frontend/src/utils/aiToolRegistry.ts b/frontend/src/utils/aiToolRegistry.ts index 9b58935..aacbcdd 100644 --- a/frontend/src/utils/aiToolRegistry.ts +++ b/frontend/src/utils/aiToolRegistry.ts @@ -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: "🪛",