mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-15 02:49:49 +08:00
✨ feat(ai-tools): 新增供应商与模型配置探针
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', {}),
|
||||
|
||||
73
frontend/src/components/ai/aiProviderInsights.test.ts
Normal file
73
frontend/src/components/ai/aiProviderInsights.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
140
frontend/src/components/ai/aiProviderInsights.ts
Normal file
140
frontend/src/components/ai/aiProviderInsights.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { AIProviderConfig } from '../../types';
|
||||
|
||||
const DECLARED_MODEL_PREVIEW_LIMIT = 12;
|
||||
const DYNAMIC_MODEL_PREVIEW_LIMIT = 20;
|
||||
|
||||
const sliceList = <T,>(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} 个供应商,但尚未选择活动供应商`,
|
||||
};
|
||||
};
|
||||
@@ -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: '读取当前连接失败',
|
||||
|
||||
@@ -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 读取当前挂载的表结构上下文');
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -24,6 +24,7 @@ interface AIToolCallingBlockProps {
|
||||
|
||||
const TOOL_ACTION_LABELS: Record<string, string> = {
|
||||
inspect_ai_runtime: '读取当前 AI 运行状态',
|
||||
inspect_ai_providers: '读取当前 AI 供应商与模型配置',
|
||||
inspect_mcp_setup: '读取当前 MCP 配置状态',
|
||||
inspect_ai_guidance: '读取当前 AI 提示与技能配置',
|
||||
get_connections: '获取可用连接信息',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: "🪛",
|
||||
|
||||
Reference in New Issue
Block a user