feat(ai): 支持 MCP 少量查数与分服务商思考强度,修复 DeepSeek Anthropic

- MCP HTTP 默认可注册 execute_sql,行数默认 50/上限 200,设置页可切换仅结构模式
- 思考强度按 OpenAI/Anthropic/DeepSeek/Gemini 体系映射档位与请求参数
- 自定义端点 DeepSeek Anthropic 自动纠正 base 路径并回传 thinking 块
- 补充前后端回归测试与多语言文案
This commit is contained in:
Syngnat
2026-07-09 11:34:38 +08:00
parent f930ffe153
commit 4fcd7ff61f
29 changed files with 1129 additions and 56 deletions

View File

@@ -57,7 +57,8 @@ const DEFAULT_MCP_HTTP_SERVER_STATUS: AIMCPHTTPServerStatus = {
addr: '127.0.0.1:8765',
path: '/mcp',
url: 'http://127.0.0.1:8765/mcp',
schemaOnly: true,
// 默认允许 execute_sql 查少量数据
schemaOnly: false,
message: '',
};
@@ -65,6 +66,7 @@ const DEFAULT_MCP_HTTP_SERVER_DRAFT: AIMCPHTTPServerDraft = {
addr: DEFAULT_MCP_HTTP_SERVER_STATUS.addr,
path: DEFAULT_MCP_HTTP_SERVER_STATUS.path,
authorizationHeader: '',
schemaOnly: false,
};
const buildMCPHTTPServerDraftFromStatus = (
@@ -79,6 +81,10 @@ const buildMCPHTTPServerDraftFromStatus = (
fallback.authorizationHeader ||
'',
).trim(),
// 运行中用状态;未运行保留草稿选择
schemaOnly: typeof status.schemaOnly === 'boolean'
? status.schemaOnly
: (typeof fallback.schemaOnly === 'boolean' ? fallback.schemaOnly : false),
});
const normalizeMCPHTTPAuthorizationToken = (value: string): string => {
@@ -406,6 +412,7 @@ export const AISettingsContent: React.FC<AISettingsContentProps> = ({ active, da
models: resolvedModels,
baseUrl: finalBaseUrl,
apiFormat: resolvedTransport.apiFormat,
thinkingIntensity: String(values.thinkingIntensity || '').trim(),
};
// 后端 AISaveProvider 统一处理新增和更新,返回 void失败抛异常
await Service?.AISaveProvider?.(payload);
@@ -549,7 +556,7 @@ export const AISettingsContent: React.FC<AISettingsContentProps> = ({ active, da
addr: mcpHTTPServerDraft.addr || DEFAULT_MCP_HTTP_SERVER_STATUS.addr,
path: mcpHTTPServerDraft.path || DEFAULT_MCP_HTTP_SERVER_STATUS.path,
token: normalizeMCPHTTPAuthorizationToken(mcpHTTPServerDraft.authorizationHeader),
schemaOnly: true,
schemaOnly: mcpHTTPServerDraft.schemaOnly === true,
})
: await Service.AIStopMCPHTTPServer();
if (nextStatus) {
@@ -678,6 +685,7 @@ export const AISettingsContent: React.FC<AISettingsContentProps> = ({ active, da
models: resolvedModels,
maxTokens: Number(values.maxTokens) || 4096,
temperature: Number(values.temperature) ?? 0.7,
thinkingIntensity: String(values.thinkingIntensity || '').trim(),
apiFormat: resolvedTransport.apiFormat,
});
if (res?.success) { setTestStatus('success'); void messageApi.success(t('ai_settings.message.test_success')); }

View File

@@ -32,6 +32,12 @@ const REQUIRED_KEYS = [
'ai_settings.mcp_http.panel.stopped_hint',
'ai_settings.mcp_http.panel.copy_url',
'ai_settings.mcp_http.panel.copy_authorization',
'ai_settings.mcp_http.panel.mode.schema_only',
'ai_settings.mcp_http.panel.mode.limited_query',
'ai_settings.mcp_http.panel.limited_query.label',
'ai_settings.mcp_http.panel.limited_query.hint',
'ai_settings.mcp_http.panel.limited_query.on',
'ai_settings.mcp_http.panel.limited_query.off',
];
const buildPanelProps = () => ({
@@ -40,7 +46,7 @@ const buildPanelProps = () => ({
addr: '127.0.0.1:8765',
path: '/mcp',
url: 'http://127.0.0.1:8765/mcp',
schemaOnly: true,
schemaOnly: false,
authorizationHeader: 'Bearer gnv_test',
message: '',
},
@@ -48,6 +54,7 @@ const buildPanelProps = () => ({
addr: '127.0.0.1:8765',
path: '/mcp',
authorizationHeader: 'Bearer gnv_test',
schemaOnly: false,
},
loading: false,
cardBg: '#fff',
@@ -98,7 +105,7 @@ describe('AIMCPHTTPServerPanel', () => {
expect(markup).toContain('GoNavi MCP HTTP service');
expect(markup).toContain('Running');
expect(markup).toContain('schema-only');
expect(markup).toContain('Limited query');
expect(markup).toContain('Listen address / port');
expect(markup).toContain('Authorization');
expect(markup).toContain('127.0.0.1:8765');

View File

@@ -11,6 +11,8 @@ export interface AIMCPHTTPServerDraft {
addr: string;
path: string;
authorizationHeader: string;
/** false 时注册 execute_sql允许查少量样例数据 */
schemaOnly: boolean;
}
export interface AIMCPHTTPServerPanelProps {
@@ -71,8 +73,10 @@ const AIMCPHTTPServerPanel: React.FC<AIMCPHTTPServerPanelProps> = ({
<Tag color={running ? 'success' : 'default'} style={{ marginInlineEnd: 0 }}>
{copy(running ? 'ai_settings.mcp_http.panel.status.running' : 'ai_settings.mcp_http.panel.status.stopped')}
</Tag>
<Tag color="blue" style={{ marginInlineEnd: 0 }}>
schema-only
<Tag color={draft.schemaOnly || status.schemaOnly ? 'blue' : 'green'} style={{ marginInlineEnd: 0 }}>
{draft.schemaOnly || (running && status.schemaOnly)
? copy('ai_settings.mcp_http.panel.mode.schema_only')
: copy('ai_settings.mcp_http.panel.mode.limited_query')}
</Tag>
</div>
<div style={{ marginTop: 6, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
@@ -126,6 +130,31 @@ const AIMCPHTTPServerPanel: React.FC<AIMCPHTTPServerPanelProps> = ({
/>
</div>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
}}
>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: overlayTheme.titleText }}>
{copy('ai_settings.mcp_http.panel.limited_query.label')}
</div>
<div style={{ marginTop: 2, fontSize: 11, color: overlayTheme.mutedText, lineHeight: 1.6 }}>
{copy('ai_settings.mcp_http.panel.limited_query.hint')}
</div>
</div>
<Switch
checked={!draft.schemaOnly}
disabled={running || loading}
onChange={(checked) => onDraftChange({ schemaOnly: !checked })}
checkedChildren={copy('ai_settings.mcp_http.panel.limited_query.on')}
unCheckedChildren={copy('ai_settings.mcp_http.panel.limited_query.off')}
/>
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{running
? status.message || copy('ai_settings.mcp_http.panel.running_hint')

View File

@@ -45,13 +45,14 @@ const buildMCPSectionProps = (patch: Partial<AISettingsMCPSectionProps> = {}): A
addr: '127.0.0.1:8765',
path: '/mcp',
url: 'http://127.0.0.1:8765/mcp',
schemaOnly: true,
schemaOnly: false,
message: 'GoNavi MCP HTTP 服务未启动',
},
mcpHTTPServerDraft: {
addr: '127.0.0.1:8765',
path: '/mcp',
authorizationHeader: 'Bearer gnv_test',
schemaOnly: false,
},
mcpServers: [],
mcpTools: [],

View File

@@ -14,6 +14,12 @@ import {
PROVIDER_PRESET_GRID_STYLE,
PROVIDER_PRESET_CARD_TITLE_STYLE,
} from '../../utils/aiSettingsPresetLayout';
import {
coerceThinkingIntensityForProfile,
resolveThinkingIntensityHintKey,
resolveThinkingIntensityOptions,
resolveThinkingIntensityProfile,
} from '../../utils/aiThinkingIntensity';
export interface AISettingsProviderPresetOption {
key: string;
@@ -120,8 +126,44 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
const currentFieldLabelStyle = fieldLabelStyle(sectionLabelColor);
const watchedModel = Form.useWatch('model', form);
const watchedModels = Form.useWatch('models', form);
const watchedBaseUrl = Form.useWatch('baseUrl', form);
const watchedType = Form.useWatch('type', form);
const watchedThinkingIntensity = Form.useWatch('thinkingIntensity', form);
const watchedInlineCompletionModel = Form.useWatch('inlineCompletionModel', form);
const presetFromForm = providerPresets.find((preset) => preset.key === presetKeyFromForm);
const thinkingProfile = React.useMemo(() => resolveThinkingIntensityProfile({
type: watchedType || editingProvider?.type,
apiFormat: watchedApiFormat || editingProvider?.apiFormat,
baseUrl: watchedBaseUrl || editingProvider?.baseUrl || presetFromForm?.defaultBaseUrl,
model: watchedModel || editingProvider?.model || presetFromForm?.defaultModel,
}), [
editingProvider?.apiFormat,
editingProvider?.baseUrl,
editingProvider?.model,
editingProvider?.type,
presetFromForm?.defaultBaseUrl,
presetFromForm?.defaultModel,
watchedApiFormat,
watchedBaseUrl,
watchedModel,
watchedType,
]);
const thinkingIntensityOptions = React.useMemo(
() => resolveThinkingIntensityOptions(thinkingProfile).map((item) => ({
value: item.value,
label: copy(item.labelKey),
})),
// copy 依赖 i18n 语言label 随语言刷新即可
// eslint-disable-next-line react-hooks/exhaustive-deps
[copy, thinkingProfile, i18n?.language],
);
React.useEffect(() => {
if (!isEditing) return;
const next = coerceThinkingIntensityForProfile(watchedThinkingIntensity, thinkingProfile);
if (next && next !== String(watchedThinkingIntensity || '').trim()) {
form.setFieldsValue({ thinkingIntensity: next });
}
}, [form, isEditing, thinkingProfile, watchedThinkingIntensity]);
const inlineCompletionModelOptions = React.useMemo(() => {
const values = [
watchedModel,
@@ -370,6 +412,24 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
<Form.Item name="model" hidden><Input /></Form.Item>
<Form.Item name="name" hidden><Input /></Form.Item>
<div style={{ ...currentFieldGroupStyle, marginTop: 16 }}>
<div style={currentFieldLabelStyle}>
<RobotOutlined style={{ fontSize: 14 }} /> {copy('ai_settings.form.section.thinking')}
</div>
<Form.Item
label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>{copy('ai_settings.form.thinking_intensity')}</span>}
name="thinkingIntensity"
extra={copy(resolveThinkingIntensityHintKey(thinkingProfile))}
style={{ marginBottom: 0 }}
>
<Select
size="middle"
style={{ width: '100%' }}
options={thinkingIntensityOptions}
/>
</Form.Item>
</div>
<div style={{ ...currentFieldGroupStyle, marginTop: 16 }}>
<div style={currentFieldLabelStyle}>
<RobotOutlined style={{ fontSize: 14 }} /> {copy('ai_settings.form.section.inline_completion')}