diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx index 2c93d98..cbeeaf1 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx @@ -28,6 +28,8 @@ describe('AIBuiltinToolsCatalog', () => { expect(markup).toContain('inspect_database_bundle'); expect(markup).toContain('查看 AI 当前能力'); expect(markup).toContain('inspect_ai_runtime'); + expect(markup).toContain('排查供应商与模型'); + expect(markup).toContain('inspect_ai_providers'); 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 62891ff..907f55c 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx @@ -42,6 +42,11 @@ const BUILTIN_TOOL_FLOWS = [ steps: 'inspect_ai_runtime → inspect_ai_context / inspect_current_connection', description: '适合先确认当前模型、安全级别、上下文级别、Skills 和 MCP 工具,再决定让 AI 走哪条探针链路。', }, + { + title: '排查供应商与模型', + steps: 'inspect_ai_providers → inspect_ai_runtime', + description: '适合先确认当前到底配置了哪些供应商、哪个在生效、有没有缺密钥或没选模型,再解释为什么 AI 不能发送、为什么模型列表为空。', + }, { title: '排查 MCP 接入状态', steps: 'inspect_mcp_setup → inspect_ai_runtime', diff --git a/frontend/src/components/ai/aiLocalToolExecutor.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.test.ts index 7c7665c..e7cdd17 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.test.ts @@ -224,6 +224,60 @@ describe('aiLocalToolExecutor', () => { expect(result.content).toContain('"builtinToolCount":'); }); + it('returns the current ai provider snapshot so the model can inspect provider readiness and model selection', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_ai_providers', {}), + connections: [buildConnection()], + mcpTools: [], + dynamicModels: ['gpt-5.4', 'gpt-4.1-mini'], + 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: 'gpt-5.4', + models: ['gpt-5.4', 'gpt-4.1'], + maxTokens: 32000, + temperature: 0.2, + }, + { + id: 'provider-2', + type: 'custom', + name: '自建代理', + apiKey: '', + hasSecret: false, + baseUrl: '', + model: '', + models: [], + headers: { + Authorization: 'Bearer secret-token', + }, + maxTokens: 16000, + temperature: 0.7, + }, + ], + }), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"providerCount":2'); + expect(result.content).toContain('"missingSecretCount":1'); + expect(result.content).toContain('"name":"OpenAI 主账号"'); + expect(result.content).toContain('"name":"自建代理"'); + expect(result.content).toContain('"issues":["missing_secret","missing_base_url","missing_selected_model","missing_declared_models"]'); + expect(result.content).not.toContain('secret-token'); + }); + 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/aiProviderInsights.test.ts b/frontend/src/components/ai/aiProviderInsights.test.ts new file mode 100644 index 0000000..cbf3456 --- /dev/null +++ b/frontend/src/components/ai/aiProviderInsights.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import type { AIProviderConfig } from '../../types'; +import { buildAIProviderSnapshot } from './aiProviderInsights'; + +describe('aiProviderInsights', () => { + it('returns a sanitized provider snapshot with missing-secret and missing-model diagnostics', () => { + const providers: AIProviderConfig[] = [ + { + id: 'provider-1', + type: 'openai', + name: 'OpenAI 主账号', + apiKey: '', + hasSecret: true, + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5.4', + models: ['gpt-5.4', 'gpt-4.1'], + maxTokens: 32000, + temperature: 0.2, + }, + { + id: 'provider-2', + type: 'custom', + name: '自建代理', + apiKey: 'sk-secret', + hasSecret: false, + baseUrl: '', + model: '', + models: [], + headers: { + Authorization: 'Bearer secret-token', + }, + apiFormat: 'openai', + maxTokens: 16000, + temperature: 0.7, + }, + ]; + + const snapshot = buildAIProviderSnapshot({ + providers, + activeProviderId: 'provider-1', + dynamicModels: ['gpt-5.4', 'gpt-4.1-mini'], + }); + + expect(snapshot).toMatchObject({ + hasActiveProvider: true, + providerCount: 2, + readyProviderCount: 1, + providersNeedingAttentionCount: 1, + missingSecretCount: 1, + missingSelectedModelCount: 1, + missingBaseUrlCount: 1, + dynamicModelCount: 2, + }); + expect(snapshot.activeProvider).toMatchObject({ + id: 'provider-1', + name: 'OpenAI 主账号', + baseUrlHost: 'api.openai.com', + status: 'ready', + }); + expect(snapshot.providers[1]).toMatchObject({ + id: 'provider-2', + hasSecret: false, + hasHeaders: true, + headerKeys: ['Authorization'], + issues: ['missing_secret', 'missing_base_url', 'missing_selected_model', 'missing_declared_models'], + status: 'needs_attention', + }); + expect(snapshot.message).toContain('正在使用 OpenAI 主账号'); + expect(JSON.stringify(snapshot)).not.toContain('apiKey'); + expect(JSON.stringify(snapshot)).not.toContain('secret-token'); + }); +}); diff --git a/frontend/src/components/ai/aiProviderInsights.ts b/frontend/src/components/ai/aiProviderInsights.ts new file mode 100644 index 0000000..999f49b --- /dev/null +++ b/frontend/src/components/ai/aiProviderInsights.ts @@ -0,0 +1,140 @@ +import type { AIProviderConfig } from '../../types'; + +const DECLARED_MODEL_PREVIEW_LIMIT = 12; +const DYNAMIC_MODEL_PREVIEW_LIMIT = 20; + +const sliceList = (items: T[], limit: number) => { + const list = Array.isArray(items) ? items : []; + return { + items: list.slice(0, limit), + truncated: list.length > limit, + total: list.length, + }; +}; + +const trimText = (value: unknown): string => String(value || '').trim(); + +const hasProviderSecret = (provider: AIProviderConfig): boolean => + provider.hasSecret ?? Boolean(provider.secretRef || provider.apiKey); + +const getProviderHost = (baseUrl: string): string => { + const normalized = trimText(baseUrl); + if (!normalized) { + return ''; + } + try { + return new URL(normalized).host; + } catch { + return ''; + } +}; + +const buildProviderIssues = (provider: AIProviderConfig): string[] => { + const issues: string[] = []; + const hasSecret = hasProviderSecret(provider); + const baseUrl = trimText(provider.baseUrl); + const model = trimText(provider.model); + const declaredModels = Array.isArray(provider.models) + ? provider.models.map((item) => trimText(item)).filter(Boolean) + : []; + + if (!hasSecret) { + issues.push('missing_secret'); + } + if (!baseUrl) { + issues.push('missing_base_url'); + } + if (!model) { + issues.push('missing_selected_model'); + } + if (declaredModels.length === 0) { + issues.push('missing_declared_models'); + } + + return issues; +}; + +export const buildAIProviderSnapshot = (params: { + providers?: AIProviderConfig[]; + activeProviderId?: string | null; + dynamicModels?: string[]; +}) => { + const providers = Array.isArray(params.providers) ? params.providers : []; + const activeProviderId = trimText(params.activeProviderId); + const dynamicModelPreview = sliceList( + (Array.isArray(params.dynamicModels) ? params.dynamicModels : []) + .map((item) => trimText(item)) + .filter(Boolean), + DYNAMIC_MODEL_PREVIEW_LIMIT, + ); + + const providerSummaries = providers.map((provider) => { + const declaredModelPreview = sliceList( + (Array.isArray(provider.models) ? provider.models : []) + .map((item) => trimText(item)) + .filter(Boolean), + DECLARED_MODEL_PREVIEW_LIMIT, + ); + const headerKeys = Object.keys(provider.headers || {}) + .map((item) => trimText(item)) + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)); + const issues = buildProviderIssues(provider); + + return { + id: provider.id, + name: trimText(provider.name), + type: provider.type, + apiFormat: trimText(provider.apiFormat) || 'openai', + active: provider.id === activeProviderId, + baseUrl: trimText(provider.baseUrl), + baseUrlHost: getProviderHost(provider.baseUrl), + model: trimText(provider.model), + declaredModelCount: declaredModelPreview.total, + declaredModels: declaredModelPreview.items, + declaredModelsTruncated: declaredModelPreview.truncated, + hasSecret: hasProviderSecret(provider), + hasHeaders: headerKeys.length > 0, + headerKeys, + maxTokens: Number(provider.maxTokens) || 0, + temperature: Number(provider.temperature) || 0, + issues, + issueCount: issues.length, + status: issues.length === 0 ? 'ready' : 'needs_attention', + }; + }); + + const activeProvider = providerSummaries.find((provider) => provider.active) || null; + const providersNeedingAttention = providerSummaries.filter((provider) => provider.issueCount > 0); + const providerHosts = Array.from( + new Set( + providerSummaries + .map((provider) => provider.baseUrlHost) + .filter(Boolean), + ), + ).sort((left, right) => left.localeCompare(right)); + + return { + hasActiveProvider: Boolean(activeProvider), + activeProviderId, + activeProvider, + providerCount: providerSummaries.length, + readyProviderCount: providerSummaries.length - providersNeedingAttention.length, + providersNeedingAttentionCount: providersNeedingAttention.length, + missingSecretCount: providerSummaries.filter((provider) => provider.issues.includes('missing_secret')).length, + missingSelectedModelCount: providerSummaries.filter((provider) => provider.issues.includes('missing_selected_model')).length, + missingBaseUrlCount: providerSummaries.filter((provider) => provider.issues.includes('missing_base_url')).length, + providers: providerSummaries, + providerHosts, + dynamicModelCount: dynamicModelPreview.total, + dynamicModels: dynamicModelPreview.items, + dynamicModelsTruncated: dynamicModelPreview.truncated, + message: providerSummaries.length === 0 + ? '当前没有配置 AI 供应商' + : activeProvider + ? activeProvider.issueCount > 0 + ? `当前正在使用 ${activeProvider.name || activeProvider.id},但还有 ${activeProvider.issueCount} 项待检查` + : `当前共配置 ${providerSummaries.length} 个供应商,正在使用 ${activeProvider.name || activeProvider.id}` + : `当前已配置 ${providerSummaries.length} 个供应商,但尚未选择活动供应商`, + }; +}; diff --git a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts index b87824e..56ecfaa 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 { buildAIProviderSnapshot } from './aiProviderInsights'; import { buildAIRuntimeSnapshot } from './aiRuntimeInsights'; import { buildSavedQueriesSnapshot, @@ -108,6 +109,19 @@ export async function executeSnapshotInspectionToolCall( success: true, }; } + case 'inspect_ai_providers': { + const runtimeState = typeof runtime?.getAIRuntimeState === 'function' + ? await runtime.getAIRuntimeState() + : undefined; + return { + content: JSON.stringify(buildAIProviderSnapshot({ + providers: Array.isArray(runtimeState?.providers) ? runtimeState.providers : [], + activeProviderId: runtimeState?.activeProviderId || '', + dynamicModels, + })), + success: true, + }; + } case 'inspect_mcp_setup': { const [mcpServers, mcpClientInstallStatuses] = await Promise.all([ typeof runtime?.getMCPServers === 'function' ? runtime.getMCPServers() : Promise.resolve(undefined), @@ -210,6 +224,7 @@ export async function executeSnapshotInspectionToolCall( } catch (error: any) { const label = { inspect_ai_runtime: '读取当前 AI 运行状态失败', + inspect_ai_providers: '读取当前 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 bec02c2..975c6d1 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_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_mcp_setup', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_saved_queries', 'inspect_sql_snippets', 'get_columns'], skills, userPromptSettings, }); @@ -76,6 +76,7 @@ describe('buildAISystemContextMessages', () => { const joined = messages.map((message) => message.content).join('\n'); expect(joined).toContain('inspect_workspace_tabs 盘点当前工作区'); expect(joined).toContain('inspect_ai_runtime 读取当前 AI 运行状态'); + expect(joined).toContain('inspect_ai_providers 读取真实供应商配置'); 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 b1784a0..01bde15 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.ts @@ -108,6 +108,19 @@ const appendAIRuntimeInspectionGuidance = ( }); }; +const appendAIProviderInspectionGuidance = ( + messages: AISystemContextMessage[], + availableToolNames: string[], +) => { + if (!availableToolNames.includes('inspect_ai_providers')) { + return; + } + messages.push({ + role: 'system', + content: '如果用户提到“当前配了哪些供应商”“为什么模型列表为空”“API Key 有没有配”“为什么现在不能发送/没选中模型”,优先调用 inspect_ai_providers 读取真实供应商配置,不要凭记忆猜测。', + }); +}; + const appendMCPSetupInspectionGuidance = ( messages: AISystemContextMessage[], availableToolNames: string[], @@ -351,6 +364,7 @@ SELECT * FROM users WHERE status = 1; }); } appendAIRuntimeInspectionGuidance(systemMessages, availableToolNames); + appendAIProviderInspectionGuidance(systemMessages, availableToolNames); appendMCPSetupInspectionGuidance(systemMessages, availableToolNames); appendAIGuidanceInspectionGuidance(systemMessages, availableToolNames); if (availableToolNames.includes('inspect_current_connection')) { diff --git a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx index 5138ab9..8e64e54 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx @@ -24,6 +24,7 @@ interface AIToolCallingBlockProps { const TOOL_ACTION_LABELS: Record = { inspect_ai_runtime: '读取当前 AI 运行状态', + inspect_ai_providers: '读取当前 AI 供应商与模型配置', inspect_mcp_setup: '读取当前 MCP 配置状态', inspect_ai_guidance: '读取当前 AI 提示与技能配置', get_connections: '获取可用连接信息', diff --git a/frontend/src/utils/aiToolRegistry.test.ts b/frontend/src/utils/aiToolRegistry.test.ts index bf4d426..d715c4f 100644 --- a/frontend/src/utils/aiToolRegistry.test.ts +++ b/frontend/src/utils/aiToolRegistry.test.ts @@ -17,6 +17,13 @@ describe('aiToolRegistry', () => { expect(info?.tool.function.description).toContain('外部客户端'); }); + it('registers the ai-provider inspector as a builtin tool', () => { + const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_providers'); + expect(info).toBeTruthy(); + expect(info?.desc).toContain('供应商与模型配置'); + expect(info?.tool.function.description).toContain('模型列表为空'); + }); + 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(); @@ -58,6 +65,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_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 ab237de..9b58935 100644 --- a/frontend/src/utils/aiToolRegistry.ts +++ b/frontend/src/utils/aiToolRegistry.ts @@ -326,6 +326,23 @@ export const BUILTIN_AI_TOOL_INFO: AIBuiltinToolInfo[] = [ }, }, }, + { + name: "inspect_ai_providers", + icon: "🪪", + desc: "查看当前 AI 供应商与模型配置", + detail: + "返回当前配置了哪些 AI 供应商、哪个正在生效、各自的 baseUrl、已选模型、声明模型列表、密钥是否存在、自定义请求头 key,以及缺少密钥/模型/地址等待检查项。适合用户问“为什么没有模型”“API Key 有没有配”“当前到底配了哪些供应商”时先读真实配置。", + params: "无参数", + tool: { + type: "function", + function: { + name: "inspect_ai_providers", + description: + "读取当前 AI 供应商配置快照,包括供应商列表、活动供应商、接口地址、已选模型、声明模型列表、是否存在密钥、自定义请求头 key,以及缺少密钥/模型/地址等待检查项。适用于用户提到当前供应商、模型列表为空、API Key 是否配置、为什么 AI 不能正常发起请求时,先读取真实配置再解释。", + parameters: { type: "object", properties: {} }, + }, + }, + }, { name: "inspect_mcp_setup", icon: "🪛",