feat(ai): 优化 AI 对话体验与 MCP 接入配置

- AI 请求:增强 OpenAI 兼容接口降级逻辑,文本模型自动省略图片并在 400 场景重试
- MCP 接入:支持自定义 HTTP 服务监听地址、端口和 Authorization Bearer Token
- MCP 生命周期:停止服务后保留授权信息,并将主动关闭子进程视为正常停止
- 交互优化:移除 AI 对话导出入口,支持关闭常驻状态提示并收敛设置弹窗 toast 宽度
- UI 调整:优化 AI 输入框边框、聚焦态和 Authorization 运行中只读可查看体验
- 测试覆盖:补充 OpenAI 降级、MCP HTTP、AI Header 和设置面板相关用例
This commit is contained in:
Syngnat
2026-06-12 14:51:37 +08:00
parent c189125aa4
commit c3a3387ee3
25 changed files with 811 additions and 184 deletions

View File

@@ -1 +1 @@
d0464f9da25e9356e61652e638c99ffe
416aaa5c6e66a62430103d6905ad9465

View File

@@ -305,16 +305,17 @@
gap: 8px;
border-radius: 6px;
border: 1px solid transparent;
border-bottom-color: rgba(128, 128, 128, 0.4);
border-color: rgba(128, 128, 128, 0.22) !important;
padding: 6px 10px;
transition: all 0.2s ease;
background: transparent !important;
background: rgba(128, 128, 128, 0.03) !important;
box-shadow: none !important;
}
.ai-chat-input-wrapper:focus-within {
border-color: var(--ant-primary-color, #1677ff) !important;
background: rgba(128, 128, 128, 0.05) !important;
border-color: rgba(128, 128, 128, 0.28) !important;
background: rgba(128, 128, 128, 0.035) !important;
box-shadow: none !important;
}
.ai-chat-input-wrapper textarea {
@@ -336,6 +337,32 @@
opacity: 0.4;
}
body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-input-wrapper,
body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-input-wrapper:focus-within {
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
body[data-ui-version="v2"] .gn-v2-ai-panel .gn-v2-ai-input-surface,
body[data-ui-version="v2"] .gn-v2-ai-panel .gn-v2-ai-input-surface:focus-within {
border: 0.5px solid var(--gn-br-2) !important;
border-radius: 10px !important;
background: var(--gn-bg-input) !important;
box-shadow: none !important;
}
body[data-ui-version="v2"] .gn-v2-ai-panel .gn-v2-ai-input-box textarea,
body[data-ui-version="v2"] .gn-v2-ai-panel .gn-v2-ai-input-box textarea.ant-input:focus,
body[data-ui-version="v2"] .gn-v2-ai-panel .gn-v2-ai-input-box textarea.ant-input:focus-visible {
border: 0 !important;
border-radius: 0 !important;
outline: none !important;
background: transparent !important;
box-shadow: none !important;
padding: 0 !important;
}
.ai-chat-send-btn {
width: 26px;
height: 26px;
@@ -493,17 +520,26 @@
left: 50% !important;
transform: translateX(-50%) !important;
right: auto !important;
width: min(100%, 720px);
max-width: calc(100% - 32px);
z-index: 100;
}
.ai-chat-panel .ant-message {
width: min(100%, 720px);
max-width: calc(100% - 32px);
}
.ai-settings-body .ant-message {
width: fit-content;
max-width: min(520px, calc(100% - 32px));
}
.ai-settings-body .ant-message .ant-message-notice {
width: 100%;
width: auto;
}
.ai-settings-body .ant-message .ant-message-notice-content {
width: 100%;
width: fit-content;
max-width: 100%;
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;

View File

@@ -595,7 +595,6 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
}}
onSettingsClick={handleOpenSettingsFromPanel}
onClose={onClose}
messages={messages}
sessionTitle={currentSessionTitle}
activeMode={effectivePanelMode}
onModeChange={(mode) => {

View File

@@ -83,9 +83,10 @@ describe('AISettingsModal edit password behavior', () => {
it('keeps long ai settings toast errors wrapped within the modal body', () => {
expect(aiChatPanelCss).toContain('.ai-settings-body .ant-message {');
expect(aiChatPanelCss).toContain('width: min(100%, 720px);');
expect(aiChatPanelCss).toContain('max-width: calc(100% - 32px);');
expect(aiChatPanelCss).toContain('width: fit-content;');
expect(aiChatPanelCss).toContain('max-width: min(520px, calc(100% - 32px));');
expect(aiChatPanelCss).toContain('.ai-settings-body .ant-message .ant-message-notice-content {');
expect(aiChatPanelCss).toContain('max-width: 100%;');
expect(aiChatPanelCss).toContain('white-space: normal;');
expect(aiChatPanelCss).toContain('overflow-wrap: anywhere;');
});

View File

@@ -14,6 +14,7 @@ import { BUILTIN_AI_TOOL_INFO } from '../utils/aiToolRegistry';
import { EMPTY_MCP_CLIENT_STATUSES } from '../utils/mcpClientInstallStatus';
import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';
import AISettingsMCPSection from './ai/AISettingsMCPSection';
import type { AIMCPHTTPServerDraft } from './ai/AIMCPHTTPServerPanel';
import AISettingsSidebar, { type AISettingsSectionKey } from './ai/AISettingsSidebar';
import AISettingsSafetySection from './ai/AISettingsSafetySection';
import AISettingsContextSection from './ai/AISettingsContextSection';
@@ -48,6 +49,33 @@ const DEFAULT_MCP_HTTP_SERVER_STATUS: AIMCPHTTPServerStatus = {
message: 'GoNavi MCP HTTP 服务未启动',
};
const DEFAULT_MCP_HTTP_SERVER_DRAFT: AIMCPHTTPServerDraft = {
addr: DEFAULT_MCP_HTTP_SERVER_STATUS.addr,
path: DEFAULT_MCP_HTTP_SERVER_STATUS.path,
authorizationHeader: '',
};
const buildMCPHTTPServerDraftFromStatus = (
status: AIMCPHTTPServerStatus,
fallback: AIMCPHTTPServerDraft = DEFAULT_MCP_HTTP_SERVER_DRAFT,
): AIMCPHTTPServerDraft => ({
addr: String(status.addr || fallback.addr || DEFAULT_MCP_HTTP_SERVER_STATUS.addr).trim(),
path: String(status.path || fallback.path || DEFAULT_MCP_HTTP_SERVER_STATUS.path).trim(),
authorizationHeader: String(
status.authorizationHeader ||
(status.token ? `Bearer ${status.token}` : '') ||
fallback.authorizationHeader ||
'',
).trim(),
});
const normalizeMCPHTTPAuthorizationToken = (value: string): string => {
const trimmed = String(value || '').trim();
if (!trimmed) return '';
const withoutHeaderName = trimmed.replace(/^Authorization\s*:\s*/i, '').trim();
return withoutHeaderName.replace(/^Bearer\s+/i, '').trim();
};
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
const [activeProviderId, setActiveProviderId] = useState<string>('');
@@ -56,6 +84,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const [mcpServers, setMCPServers] = useState<AIMCPServerConfig[]>([]);
const [mcpTools, setMCPTools] = useState<AIMCPToolDescriptor[]>([]);
const [mcpHTTPServerStatus, setMCPHTTPServerStatus] = useState<AIMCPHTTPServerStatus>(DEFAULT_MCP_HTTP_SERVER_STATUS);
const [mcpHTTPServerDraft, setMCPHTTPServerDraft] = useState<AIMCPHTTPServerDraft>(DEFAULT_MCP_HTTP_SERVER_DRAFT);
const [mcpHTTPServerLoading, setMCPHTTPServerLoading] = useState(false);
const [skills, setSkills] = useState<AISkillConfig[]>([]);
const [editingProvider, setEditingProvider] = useState<AIProviderConfig | null>(null);
@@ -182,10 +211,12 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
if (Array.isArray(mcpServersRes)) setMCPServers(mcpServersRes);
if (Array.isArray(mcpToolsRes)) setMCPTools(mcpToolsRes);
if (mcpHTTPServerStatusRes) {
setMCPHTTPServerStatus({
const nextStatus = {
...DEFAULT_MCP_HTTP_SERVER_STATUS,
...mcpHTTPServerStatusRes,
});
};
setMCPHTTPServerStatus(nextStatus);
setMCPHTTPServerDraft((prev) => buildMCPHTTPServerDraftFromStatus(nextStatus, prev));
}
if (Array.isArray(skillsRes)) setSkills(skillsRes);
if (Array.isArray(mcpClientStatusesRes)) {
@@ -480,16 +511,19 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
}
const nextStatus = checked
? await Service.AIStartMCPHTTPServer({
addr: mcpHTTPServerStatus.addr || DEFAULT_MCP_HTTP_SERVER_STATUS.addr,
path: mcpHTTPServerStatus.path || DEFAULT_MCP_HTTP_SERVER_STATUS.path,
addr: mcpHTTPServerDraft.addr || DEFAULT_MCP_HTTP_SERVER_STATUS.addr,
path: mcpHTTPServerDraft.path || DEFAULT_MCP_HTTP_SERVER_STATUS.path,
token: normalizeMCPHTTPAuthorizationToken(mcpHTTPServerDraft.authorizationHeader),
schemaOnly: true,
})
: await Service.AIStopMCPHTTPServer();
if (nextStatus) {
setMCPHTTPServerStatus({
const normalizedStatus = {
...DEFAULT_MCP_HTTP_SERVER_STATUS,
...nextStatus,
});
};
setMCPHTTPServerStatus(normalizedStatus);
setMCPHTTPServerDraft((prev) => buildMCPHTTPServerDraftFromStatus(normalizedStatus, prev));
}
void messageApi.success(checked ? 'GoNavi MCP HTTP 服务已启动' : 'GoNavi MCP HTTP 服务已停止');
} catch (e: any) {
@@ -499,6 +533,13 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
}
};
const handleUpdateMCPHTTPServerDraft = (patch: Partial<AIMCPHTTPServerDraft>) => {
setMCPHTTPServerDraft((prev) => ({
...prev,
...patch,
}));
};
const handleCopyMCPHTTPServerURL = async () => {
const url = String(mcpHTTPServerStatus.url || '').trim();
if (!url) {
@@ -724,6 +765,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
selectedMCPClientStatus={selectedMCPClientStatus}
selectedMCPClientCommandText={selectedMCPClientCommandText}
mcpHTTPServerStatus={mcpHTTPServerStatus}
mcpHTTPServerDraft={mcpHTTPServerDraft}
mcpServers={mcpServers}
mcpTools={mcpTools}
darkMode={darkMode}
@@ -734,6 +776,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
loading={loading}
mcpClientStatusLoading={mcpClientStatusLoading}
mcpHTTPServerLoading={mcpHTTPServerLoading}
onUpdateHTTPServerDraft={handleUpdateMCPHTTPServerDraft}
onToggleHTTPServer={handleToggleMCPHTTPServer}
onCopyHTTPServerURL={() => void handleCopyMCPHTTPServerURL()}
onCopyHTTPServerAuthorization={() => void handleCopyMCPHTTPServerAuthorization()}
@@ -796,6 +839,3 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
};
export default AISettingsModal;

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Button } from 'antd';
import {
CloseOutlined,
CheckCircleFilled,
ExclamationCircleFilled,
LoadingOutlined,
@@ -15,6 +16,7 @@ interface AIChatComposerStatusProps {
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
onAction?: (actionKey: AIComposerNoticeAction) => void;
onDismiss?: () => void;
}
const resolvePalette = (
@@ -96,6 +98,7 @@ const AIChatComposerStatus: React.FC<AIChatComposerStatusProps> = ({
darkMode,
overlayTheme,
onAction,
onDismiss,
}) => {
const palette = resolvePalette(snapshot.severity, darkMode);
const handleAction = () => {
@@ -103,6 +106,7 @@ const AIChatComposerStatus: React.FC<AIChatComposerStatusProps> = ({
onAction(snapshot.action.key);
}
};
const canDismiss = typeof onDismiss === 'function';
return (
<div
@@ -153,16 +157,31 @@ const AIChatComposerStatus: React.FC<AIChatComposerStatusProps> = ({
</div>
</div>
</div>
{snapshot.action && typeof onAction === 'function' && (
<Button
size="small"
type="default"
onClick={handleAction}
style={{ borderRadius: 8, flexShrink: 0 }}
>
{snapshot.action.label}
</Button>
)}
{(snapshot.action && typeof onAction === 'function') || canDismiss ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
{snapshot.action && typeof onAction === 'function' && (
<Button
size="small"
type="default"
onClick={handleAction}
style={{ borderRadius: 8 }}
>
{snapshot.action.label}
</Button>
)}
{canDismiss && (
<Button
aria-label="关闭 AI 状态提示"
title="关闭"
size="small"
type="text"
icon={<CloseOutlined />}
onClick={onDismiss}
style={{ borderRadius: 8 }}
/>
)}
</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,14 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const headerSource = readFileSync(new URL('./AIChatHeader.tsx', import.meta.url), 'utf8');
const v2ThemeCss = readFileSync(new URL('../../v2-theme.css', import.meta.url), 'utf8');
describe('AIChatHeader export affordance', () => {
it('does not expose chat export UI or markdown export implementation', () => {
expect(headerSource).not.toContain('exportToMarkdown');
expect(headerSource).not.toContain('导出为 Markdown');
expect(headerSource).not.toContain('gn-v2-ai-export-button');
expect(v2ThemeCss).not.toContain('gn-v2-ai-export-button');
});
});

View File

@@ -1,8 +1,7 @@
import React from 'react';
import { Button, Tooltip } from 'antd';
import { HistoryOutlined, RobotOutlined, ClearOutlined, SettingOutlined, CloseOutlined, ExportOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { HistoryOutlined, RobotOutlined, ClearOutlined, SettingOutlined, CloseOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { AIChatMessage } from '../../types';
interface AIChatHeaderProps {
darkMode: boolean;
@@ -14,39 +13,16 @@ interface AIChatHeaderProps {
onClear: () => void;
onSettingsClick: () => void;
onClose: () => void;
messages?: AIChatMessage[];
sessionTitle?: string;
activeMode?: 'chat' | 'insights' | 'history';
onModeChange?: (mode: 'chat' | 'insights' | 'history') => void;
}
const exportToMarkdown = (messages: AIChatMessage[], title: string) => {
const lines: string[] = [`# ${title}`, '', `> 导出时间:${new Date().toLocaleString()}`, ''];
messages.forEach(msg => {
const role = msg.role === 'user' ? '👤 You' : '🤖 GoNavi AI';
lines.push(`## ${role}`);
lines.push('');
lines.push(msg.content);
lines.push('');
lines.push('---');
lines.push('');
});
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title.replace(/[/\\?%*:|"<>]/g, '-')}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
export const AIChatHeader: React.FC<AIChatHeaderProps> = ({
darkMode, mutedColor, textColor, overlayTheme,
isV2Ui = false,
onHistoryClick, onClear, onSettingsClick, onClose,
messages = [], sessionTitle = '新对话',
sessionTitle = '新对话',
activeMode = 'chat',
onModeChange,
}) => {
@@ -63,11 +39,6 @@ export const AIChatHeader: React.FC<AIChatHeaderProps> = ({
<span className="ai-title" style={{ color: textColor, fontSize: 13, fontWeight: 600 }}>GoNavi AI</span>
</div>
<div className="ai-chat-header-right">
{messages.length > 0 && (
<Tooltip title="导出为 Markdown">
<Button type="text" size="small" icon={<ExportOutlined />} onClick={() => exportToMarkdown(messages, sessionTitle)} style={{ color: mutedColor }} />
</Tooltip>
)}
<Tooltip title="新对话 (清空当前)">
<Button type="text" size="small" icon={<ClearOutlined />} onClick={onClear} style={{ color: mutedColor }} />
</Tooltip>
@@ -137,15 +108,6 @@ export const AIChatHeader: React.FC<AIChatHeaderProps> = ({
<span></span>
</button>
</div>
{messages.length > 0 && (
<div className="gn-v2-ai-session-row">
<button type="button" className="gn-v2-ai-export-button" onClick={() => exportToMarkdown(messages, sessionTitle)}>
<ExportOutlined />
<span></span>
</button>
</div>
)}
</div>
);
};

View File

@@ -3,6 +3,8 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { AIChatInput } from './AIChatInput';
import AIChatComposerStatus from './AIChatComposerStatus';
import { buildAIChatReadinessSnapshot } from './aiChatReadiness';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
vi.mock('../../store', () => ({
@@ -33,7 +35,7 @@ const baseProvider = {
temperature: 0.2,
};
const renderAIChatInput = (overrides: Partial<React.ComponentProps<typeof AIChatInput>> = {}) => renderToStaticMarkup(
const buildAIChatInput = (overrides: Partial<React.ComponentProps<typeof AIChatInput>> = {}) => (
<AIChatInput
input=""
setInput={() => {}}
@@ -63,6 +65,9 @@ const renderAIChatInput = (overrides: Partial<React.ComponentProps<typeof AIChat
/>
);
const renderAIChatInput = (overrides: Partial<React.ComponentProps<typeof AIChatInput>> = {}) =>
renderToStaticMarkup(buildAIChatInput(overrides));
describe('AIChatInput notice layout', () => {
it('renders the composer notice above the input editor', () => {
const markup = renderToStaticMarkup(
@@ -251,4 +256,26 @@ describe('AIChatInput notice layout', () => {
expect(markup).toContain('自建代理 还缺少 密钥、接口地址');
expect(markup).toContain('修复供应商配置');
});
it('renders a dismiss affordance for the non-blocking ready composer status', () => {
const snapshot = buildAIChatReadinessSnapshot({
activeProvider: {
...baseProvider,
model: 'MiniMax-M2.7-highspeed',
models: ['MiniMax-M2.7-highspeed'],
},
});
const markup = renderToStaticMarkup(
<AIChatComposerStatus
snapshot={snapshot}
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
onDismiss={() => {}}
/>,
);
expect(markup).toContain('data-ai-chat-composer-status="true"');
expect(markup).toContain('aria-label="关闭 AI 状态提示"');
expect(markup).toContain('title="关闭"');
});
});

View File

@@ -71,6 +71,33 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
activeContext,
activeContextItems,
}), [activeProvider, dynamicModels, loadingModels, activeContext, activeContextItems]);
const composerStatusKey = React.useMemo(() => [
composerReadiness.status,
composerReadiness.activeProvider?.id || '',
composerReadiness.activeProvider?.model || '',
activeContext?.connectionId || '',
activeContext?.dbName || '',
composerReadiness.contextAttachedCount,
composerReadiness.selectableModelCount,
].join('|'), [
composerReadiness.status,
composerReadiness.activeProvider?.id,
composerReadiness.activeProvider?.model,
activeContext?.connectionId,
activeContext?.dbName,
composerReadiness.contextAttachedCount,
composerReadiness.selectableModelCount,
]);
const [dismissedComposerStatusKey, setDismissedComposerStatusKey] = React.useState<string | null>(null);
React.useEffect(() => {
setDismissedComposerStatusKey(null);
}, [composerStatusKey]);
const isComposerStatusDismissed = composerReadiness.ready && dismissedComposerStatusKey === composerStatusKey;
const handleDismissComposerStatus = React.useCallback(() => {
if (composerReadiness.ready) {
setDismissedComposerStatusKey(composerStatusKey);
}
}, [composerReadiness.ready, composerStatusKey]);
const {
appendingContext,
contextExpanded,
@@ -164,12 +191,13 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
mutedColor={mutedColor}
onComposerNoticeAction={composerNoticeActionHandler}
/>
{!composerNotice && (
{!composerNotice && !isComposerStatusDismissed && (
<AIChatComposerStatus
snapshot={composerReadiness}
darkMode={darkMode}
overlayTheme={overlayTheme}
onAction={composerActionHandler}
onDismiss={composerReadiness.ready ? handleDismissComposerStatus : undefined}
/>
)}
<div data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
@@ -317,12 +345,13 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
mutedColor={mutedColor}
onComposerNoticeAction={composerNoticeActionHandler}
/>
{!composerNotice && (
{!composerNotice && !isComposerStatusDismissed && (
<AIChatComposerStatus
snapshot={composerReadiness}
darkMode={darkMode}
overlayTheme={overlayTheme}
onAction={composerActionHandler}
onDismiss={composerReadiness.ready ? handleDismissComposerStatus : undefined}
/>
)}
<div className="gn-v2-ai-input-box" data-ai-chat-composer-input="true" style={{ position: 'relative' }}>

View File

@@ -5,34 +5,76 @@ import { describe, expect, it } from 'vitest';
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import AIMCPHTTPServerPanel from './AIMCPHTTPServerPanel';
const findElement = (node: any, predicate: (element: any) => boolean): any => {
if (node == null || typeof node === 'boolean' || typeof node === 'string' || typeof node === 'number') {
return null;
}
if (Array.isArray(node)) {
for (const item of node) {
const match = findElement(item, predicate);
if (match) {
return match;
}
}
return null;
}
if (predicate(node)) {
return node;
}
return findElement(node.props?.children, predicate);
};
const buildPanelProps = () => ({
status: {
running: true,
addr: '127.0.0.1:8765',
path: '/mcp',
url: 'http://127.0.0.1:8765/mcp',
schemaOnly: true,
authorizationHeader: 'Bearer gnv_test',
message: 'GoNavi MCP HTTP 服务已启动',
},
draft: {
addr: '127.0.0.1:8765',
path: '/mcp',
authorizationHeader: 'Bearer gnv_test',
},
loading: false,
cardBg: '#fff',
cardBorder: 'rgba(0,0,0,0.08)',
darkMode: false,
overlayTheme: buildOverlayWorkbenchTheme(false),
onDraftChange: () => {},
onToggle: () => {},
onCopyURL: () => {},
onCopyAuthorization: () => {},
});
describe('AIMCPHTTPServerPanel', () => {
it('renders the in-app MCP HTTP switch and remote connection details', () => {
const markup = renderToStaticMarkup(
<AIMCPHTTPServerPanel
status={{
running: true,
addr: '127.0.0.1:8765',
path: '/mcp',
url: 'http://127.0.0.1:8765/mcp',
schemaOnly: true,
authorizationHeader: 'Bearer gnv_test',
message: 'GoNavi MCP HTTP 服务已启动',
}}
loading={false}
cardBg="#fff"
cardBorder="rgba(0,0,0,0.08)"
darkMode={false}
overlayTheme={buildOverlayWorkbenchTheme(false)}
onToggle={() => {}}
onCopyURL={() => {}}
onCopyAuthorization={() => {}}
/>,
<AIMCPHTTPServerPanel {...buildPanelProps()} />,
);
expect(markup).toContain('GoNavi MCP HTTP 服务');
expect(markup).toContain('已启动');
expect(markup).toContain('schema-only');
expect(markup).toContain('监听地址 / 端口');
expect(markup).toContain('Authorization');
expect(markup).toContain('127.0.0.1:8765');
expect(markup).toContain('http://127.0.0.1:8765/mcp');
expect(markup).toContain('复制 Authorization');
});
it('keeps Authorization read-only but revealable while running', () => {
const tree = AIMCPHTTPServerPanel(buildPanelProps());
const passwordInput = findElement(
tree,
(node) => node.props?.placeholder === 'Bearer gnv_xxx留空自动生成',
);
expect(passwordInput).toBeTruthy();
expect(passwordInput.props.disabled).toBe(false);
expect(passwordInput.props.readOnly).toBe(true);
});
});

View File

@@ -1,17 +1,25 @@
import React from 'react';
import { Button, Switch, Tag } from 'antd';
import { Button, Input, Switch, Tag } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import type { AIMCPHTTPServerStatus } from '../../types';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
export interface AIMCPHTTPServerDraft {
addr: string;
path: string;
authorizationHeader: string;
}
export interface AIMCPHTTPServerPanelProps {
status: AIMCPHTTPServerStatus;
draft: AIMCPHTTPServerDraft;
loading: boolean;
cardBg: string;
cardBorder: string;
darkMode: boolean;
overlayTheme: OverlayWorkbenchTheme;
onDraftChange: (patch: Partial<AIMCPHTTPServerDraft>) => void;
onToggle: (checked: boolean) => void;
onCopyURL: () => void;
onCopyAuthorization: () => void;
@@ -19,11 +27,13 @@ export interface AIMCPHTTPServerPanelProps {
const AIMCPHTTPServerPanel: React.FC<AIMCPHTTPServerPanelProps> = ({
status,
draft,
loading,
cardBg,
cardBorder,
darkMode,
overlayTheme,
onDraftChange,
onToggle,
onCopyURL,
onCopyAuthorization,
@@ -31,6 +41,10 @@ const AIMCPHTTPServerPanel: React.FC<AIMCPHTTPServerPanelProps> = ({
const running = status?.running === true;
const url = String(status?.url || '').trim();
const authorizationHeader = String(status?.authorizationHeader || '').trim();
const inputStyle: React.CSSProperties = {
borderRadius: 10,
background: darkMode ? 'rgba(15,23,42,0.82)' : '#fff',
};
return (
<div
@@ -56,7 +70,7 @@ const AIMCPHTTPServerPanel: React.FC<AIMCPHTTPServerPanelProps> = ({
</Tag>
</div>
<div style={{ marginTop: 6, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
OpenClawHermans Agent 使 Bearer Token DDL
OpenClawHermans Agent 使 DDL
</div>
</div>
<Switch
@@ -78,10 +92,36 @@ const AIMCPHTTPServerPanel: React.FC<AIMCPHTTPServerPanelProps> = ({
gap: 8,
}}
>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: overlayTheme.mutedText }}> / </span>
<Input
size="small"
value={draft.addr}
disabled={running || loading}
placeholder="127.0.0.1:8765"
onChange={(event) => onDraftChange({ addr: event.target.value })}
style={inputStyle}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: overlayTheme.mutedText }}>Authorization</span>
<Input.Password
size="small"
value={draft.authorizationHeader}
disabled={loading}
readOnly={running || loading}
placeholder="Bearer gnv_xxx留空自动生成"
autoComplete="off"
onChange={(event) => onDraftChange({ authorizationHeader: event.target.value })}
style={inputStyle}
/>
</div>
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
{running
? status.message || '服务运行中,可把 URL 和 Authorization Header 配置到远程 MCP 客户端。'
: '不用再手动执行 GoNavi.exe mcp-server http 命令;在这里打开开关即可启动本机 HTTP MCP。'}
: '可自定义本机监听端口和 Bearer Token留空 Authorization 时启动会自动生成随机 Token。'}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<code

View File

@@ -66,6 +66,11 @@ const buildMCPSectionProps = (patch: Partial<AISettingsMCPSectionProps> = {}): A
schemaOnly: true,
message: 'GoNavi MCP HTTP 服务未启动',
},
mcpHTTPServerDraft: {
addr: '127.0.0.1:8765',
path: '/mcp',
authorizationHeader: 'Bearer gnv_test',
},
mcpServers: [],
mcpTools: [],
darkMode: false,
@@ -76,6 +81,7 @@ const buildMCPSectionProps = (patch: Partial<AISettingsMCPSectionProps> = {}): A
loading: false,
mcpClientStatusLoading: false,
mcpHTTPServerLoading: false,
onUpdateHTTPServerDraft: () => {},
onToggleHTTPServer: () => {},
onCopyHTTPServerURL: () => {},
onCopyHTTPServerAuthorization: () => {},
@@ -99,7 +105,7 @@ describe('AISettingsMCPSection', () => {
);
expect(markup).toContain('GoNavi MCP HTTP 服务');
expect(markup).toContain('不用再手动执行 GoNavi.exe mcp-server http 命令');
expect(markup).toContain('可自定义本机监听端口和 Bearer Token');
expect(markup).toContain('http://127.0.0.1:8765/mcp');
expect(markup).toContain('复制 Authorization');
expect(markup).toContain('接入外部客户端');
@@ -208,4 +214,20 @@ describe('AISettingsMCPSection', () => {
expect(onToggleHTTPServer).toHaveBeenCalledWith(true);
});
it('passes MCP HTTP draft updates through the switch panel', () => {
const onUpdateHTTPServerDraft = vi.fn();
const tree = AISettingsMCPSection(buildMCPSectionProps({
onUpdateHTTPServerDraft,
}));
const httpPanel = findElement(
tree,
(node) => node.props?.onDraftChange === onUpdateHTTPServerDraft,
);
expect(httpPanel).toBeTruthy();
httpPanel.props.onDraftChange({ addr: '127.0.0.1:9123' });
expect(onUpdateHTTPServerDraft).toHaveBeenCalledWith({ addr: '127.0.0.1:9123' });
});
});

View File

@@ -9,6 +9,7 @@ import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import AIMCPClientInstallPanel from './AIMCPClientInstallPanel';
import AIMCPFieldGuideCard from './AIMCPFieldGuideCard';
import AIMCPHTTPServerPanel from './AIMCPHTTPServerPanel';
import type { AIMCPHTTPServerDraft } from './AIMCPHTTPServerPanel';
import AIMCPQuickAddServerPanel from './AIMCPQuickAddServerPanel';
import AIMCPServerCard from './AIMCPServerCard';
@@ -20,6 +21,7 @@ export interface AISettingsMCPSectionProps {
selectedMCPClientStatus?: AIMCPClientInstallStatus;
selectedMCPClientCommandText: string;
mcpHTTPServerStatus: AIMCPHTTPServerStatus;
mcpHTTPServerDraft: AIMCPHTTPServerDraft;
mcpServers: AIMCPServerConfig[];
mcpTools: AIMCPToolDescriptor[];
darkMode: boolean;
@@ -30,6 +32,7 @@ export interface AISettingsMCPSectionProps {
loading: boolean;
mcpClientStatusLoading: boolean;
mcpHTTPServerLoading: boolean;
onUpdateHTTPServerDraft: (patch: Partial<AIMCPHTTPServerDraft>) => void;
onToggleHTTPServer: (checked: boolean) => void;
onCopyHTTPServerURL: () => void;
onCopyHTTPServerAuthorization: () => void;
@@ -51,6 +54,7 @@ const AISettingsMCPSection: React.FC<AISettingsMCPSectionProps> = ({
selectedMCPClientStatus,
selectedMCPClientCommandText,
mcpHTTPServerStatus,
mcpHTTPServerDraft,
mcpServers,
mcpTools,
darkMode,
@@ -61,6 +65,7 @@ const AISettingsMCPSection: React.FC<AISettingsMCPSectionProps> = ({
loading,
mcpClientStatusLoading,
mcpHTTPServerLoading,
onUpdateHTTPServerDraft,
onToggleHTTPServer,
onCopyHTTPServerURL,
onCopyHTTPServerAuthorization,
@@ -78,11 +83,13 @@ const AISettingsMCPSection: React.FC<AISettingsMCPSectionProps> = ({
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<AIMCPHTTPServerPanel
status={mcpHTTPServerStatus}
draft={mcpHTTPServerDraft}
loading={mcpHTTPServerLoading}
cardBg={cardBg}
cardBorder={cardBorder}
darkMode={darkMode}
overlayTheme={overlayTheme}
onDraftChange={onUpdateHTTPServerDraft}
onToggle={onToggleHTTPServer}
onCopyURL={onCopyHTTPServerURL}
onCopyAuthorization={onCopyHTTPServerAuthorization}

View File

@@ -382,14 +382,15 @@ if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window
AIStartMCPHTTPServer: async (input: any) => {
const addr = String(input?.addr || '127.0.0.1:8765');
const path = String(input?.path || '/mcp').startsWith('/') ? String(input?.path || '/mcp') : `/${String(input?.path || '/mcp')}`;
const token = String(input?.token || 'gnv_browser_mock_token').trim() || 'gnv_browser_mock_token';
mockMCPHTTPServerStatus = {
running: true,
addr,
path,
url: `http://${addr}${path}`,
schemaOnly: true,
token: 'gnv_browser_mock_token',
authorizationHeader: 'Bearer gnv_browser_mock_token',
token,
authorizationHeader: `Bearer ${token}`,
startedAt: Date.now(),
message: 'GoNavi MCP HTTP 服务已启动',
};
@@ -399,8 +400,6 @@ if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window
mockMCPHTTPServerStatus = {
...mockMCPHTTPServerStatus,
running: false,
token: '',
authorizationHeader: '',
message: 'GoNavi MCP HTTP 服务已停止',
};
return cloneBrowserMockValue(mockMCPHTTPServerStatus);

View File

@@ -5696,29 +5696,6 @@ body[data-ui-version="v2"] .gn-v2-ai-mode-tabs button.is-active {
color: var(--gn-fg-1);
}
body[data-ui-version="v2"] .gn-v2-ai-session-row {
justify-content: flex-end;
gap: 8px;
width: 100%;
flex: 0 0 auto;
min-height: 22px;
padding: 0 10px 7px;
}
body[data-ui-version="v2"] .gn-v2-ai-export-button {
display: inline-flex;
align-items: center;
gap: 4px;
height: 22px;
padding: 0 7px;
border: 0.5px solid var(--gn-br-1);
border-radius: 6px;
background: var(--gn-bg-panel);
color: var(--gn-fg-4);
font-size: 10.5px;
font-weight: 650;
}
body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-messages {
padding: 8px 14px 12px;
gap: 10px;
@@ -5995,6 +5972,7 @@ body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-input-wrapper {
border: none !important;
background: transparent !important;
box-shadow: none !important;
gap: 4px !important;
}
body[data-ui-version="v2"] .gn-v2-ai-context-row {
@@ -6157,25 +6135,34 @@ body[data-ui-version="v2"] .gn-v2-ai-attachment-file button {
}
body[data-ui-version="v2"] .gn-v2-ai-input-box {
min-height: 72px;
min-height: 68px;
padding: 0;
}
body[data-ui-version="v2"] .gn-v2-ai-input-surface {
min-height: 68px;
padding: 6px 6px 6px 10px;
border: 0.5px solid var(--gn-br-2);
border-radius: 10px;
background: var(--gn-bg-input);
box-shadow: var(--gn-shadow-sm);
border: 0.5px solid var(--gn-br-2) !important;
border-radius: 10px !important;
background: var(--gn-bg-input) !important;
box-shadow: none !important;
}
body[data-ui-version="v2"] .gn-v2-ai-input-box textarea {
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
font-size: 12.5px !important;
line-height: 1.55 !important;
padding: 0 !important;
}
body[data-ui-version="v2"] .gn-v2-ai-input-box textarea.ant-input:focus,
body[data-ui-version="v2"] .gn-v2-ai-input-box textarea.ant-input:focus-visible {
border-color: transparent !important;
outline: none !important;
box-shadow: none !important;
}
body[data-ui-version="v2"] .gn-v2-ai-slash-menu {
@@ -6209,7 +6196,7 @@ body[data-ui-version="v2"] .gn-v2-ai-model-bar {
align-items: center;
gap: 6px;
min-width: 0;
padding-top: 6px;
padding-top: 0;
white-space: nowrap;
}

View File

@@ -1,6 +1,7 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {ai} from '../models';
import {context} from '../models';
export function AICallMCPTool(arg1:string,arg2:string):Promise<ai.MCPToolCallResult>;
@@ -77,3 +78,5 @@ export function AIStopMCPHTTPServer():Promise<ai.MCPHTTPServerStatus>;
export function AITestMCPServer(arg1:ai.MCPServerConfig):Promise<Record<string, any>>;
export function AITestProvider(arg1:ai.ProviderConfig):Promise<Record<string, any>>;
export function Shutdown(arg1:context.Context):Promise<void>;

View File

@@ -153,3 +153,7 @@ export function AITestMCPServer(arg1) {
export function AITestProvider(arg1) {
return window['go']['aiservice']['Service']['AITestProvider'](arg1);
}
export function Shutdown(arg1) {
return window['go']['aiservice']['Service']['Shutdown'](arg1);
}

View File

@@ -5,6 +5,7 @@ import {sync} from '../models';
import {app} from '../models';
import {jvm} from '../models';
import {redis} from '../models';
import {context} from '../models';
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
@@ -34,6 +35,8 @@ export function CreateSQLFile(arg1:string,arg2:string):Promise<connection.QueryR
export function CreateSchema(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DBCommitTransaction(arg1:string):Promise<connection.QueryResult>;
export function DBConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function DBGetAllColumns(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
@@ -52,8 +55,6 @@ export function DBGetTriggers(arg1:connection.ConnectionConfig,arg2:string,arg3:
export function DBQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DBCommitTransaction(arg1:string):Promise<connection.QueryResult>;
export function DBQueryIsolated(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DBQueryMulti(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
@@ -62,10 +63,10 @@ export function DBQueryMultiTransactional(arg1:connection.ConnectionConfig,arg2:
export function DBQueryWithCancel(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function DBShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DBRollbackTransaction(arg1:string):Promise<connection.QueryResult>;
export function DBShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DataSync(arg1:sync.SyncConfig):Promise<sync.SyncResult>;
export function DataSyncAnalyze(arg1:sync.SyncConfig):Promise<connection.QueryResult>;
@@ -206,10 +207,10 @@ export function PreviewChanges(arg1:connection.ConnectionConfig,arg2:string,arg3
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
export function ReadSQLFile(arg1:string):Promise<connection.QueryResult>;
export function ReadAppLogTail(arg1:number,arg2:string):Promise<connection.QueryResult>;
export function ReadSQLFile(arg1:string):Promise<connection.QueryResult>;
export function RedisConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:any):Promise<connection.QueryResult>;
@@ -306,6 +307,8 @@ export function SetMacNativeWindowControls(arg1:boolean):Promise<void>;
export function SetWindowTranslucency(arg1:number,arg2:number):Promise<void>;
export function Shutdown(arg1:context.Context):Promise<void>;
export function StartSecurityUpdate(arg1:app.StartSecurityUpdateRequest):Promise<app.SecurityUpdateStatus>;
export function TestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;

View File

@@ -58,6 +58,10 @@ export function CreateSchema(arg1, arg2, arg3) {
return window['go']['app']['App']['CreateSchema'](arg1, arg2, arg3);
}
export function DBCommitTransaction(arg1) {
return window['go']['app']['App']['DBCommitTransaction'](arg1);
}
export function DBConnect(arg1) {
return window['go']['app']['App']['DBConnect'](arg1);
}
@@ -94,10 +98,6 @@ export function DBQuery(arg1, arg2, arg3) {
return window['go']['app']['App']['DBQuery'](arg1, arg2, arg3);
}
export function DBCommitTransaction(arg1) {
return window['go']['app']['App']['DBCommitTransaction'](arg1);
}
export function DBQueryIsolated(arg1, arg2, arg3) {
return window['go']['app']['App']['DBQueryIsolated'](arg1, arg2, arg3);
}
@@ -114,14 +114,14 @@ export function DBQueryWithCancel(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['DBQueryWithCancel'](arg1, arg2, arg3, arg4);
}
export function DBShowCreateTable(arg1, arg2, arg3) {
return window['go']['app']['App']['DBShowCreateTable'](arg1, arg2, arg3);
}
export function DBRollbackTransaction(arg1) {
return window['go']['app']['App']['DBRollbackTransaction'](arg1);
}
export function DBShowCreateTable(arg1, arg2, arg3) {
return window['go']['app']['App']['DBShowCreateTable'](arg1, arg2, arg3);
}
export function DataSync(arg1) {
return window['go']['app']['App']['DataSync'](arg1);
}
@@ -402,14 +402,14 @@ export function PreviewImportFile(arg1) {
return window['go']['app']['App']['PreviewImportFile'](arg1);
}
export function ReadSQLFile(arg1) {
return window['go']['app']['App']['ReadSQLFile'](arg1);
}
export function ReadAppLogTail(arg1, arg2) {
return window['go']['app']['App']['ReadAppLogTail'](arg1, arg2);
}
export function ReadSQLFile(arg1) {
return window['go']['app']['App']['ReadSQLFile'](arg1);
}
export function RedisConnect(arg1) {
return window['go']['app']['App']['RedisConnect'](arg1);
}
@@ -602,6 +602,10 @@ export function SetWindowTranslucency(arg1, arg2) {
return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2);
}
export function Shutdown(arg1) {
return window['go']['app']['App']['Shutdown'](arg1);
}
export function StartSecurityUpdate(arg1) {
return window['go']['app']['App']['StartSecurityUpdate'](arg1);
}

View File

@@ -1,5 +1,5 @@
export namespace ai {
export class MCPClientInstallResult {
success: boolean;
client?: string;
@@ -7,11 +7,11 @@ export namespace ai {
configPath?: string;
command?: string;
args?: string[];
static createFrom(source: any = {}) {
return new MCPClientInstallResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.success = source["success"];
@@ -35,7 +35,7 @@ export namespace ai {
configPath?: string;
command?: string;
args?: string[];
static createFrom(source: any = {}) {
return new MCPClientInstallStatus(source);
}
@@ -61,11 +61,11 @@ export namespace ai {
path?: string;
token?: string;
schemaOnly: boolean;
static createFrom(source: any = {}) {
return new MCPHTTPServerOptions(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.addr = source["addr"];
@@ -84,11 +84,11 @@ export namespace ai {
authorizationHeader?: string;
startedAt?: number;
message: string;
static createFrom(source: any = {}) {
return new MCPHTTPServerStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.running = source["running"];
@@ -1392,3 +1392,4 @@ export namespace sync {
}
}

View File

@@ -19,6 +19,7 @@ const (
defaultOpenAIMaxTokens = 4096
defaultOpenAITemperature = 0.7
openAIHTTPTimeout = 120 * time.Second
omittedImageNotice = "【图片已省略:当前模型或上游接口不支持图片输入,请切换支持视觉的模型后重新发送图片。】"
)
// OpenAIProvider 实现 OpenAI / OpenAI 兼容 API 的 Provider
@@ -205,7 +206,8 @@ func (p *OpenAIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.Chat
return nil, err
}
messages := buildOpenAIMessages(req.Messages, p.config.Model, p.baseURL)
requestMessages := prepareOpenAIRequestMessages(req.Messages, p.config.Model, p.baseURL)
messages := buildOpenAIMessages(requestMessages, p.config.Model, p.baseURL)
temperature := req.Temperature
if temperature <= 0 {
@@ -222,15 +224,8 @@ func (p *OpenAIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.Chat
respBody, err := p.doRequest(ctx, body)
if err != nil {
// 当带 tools 的请求返回 400 时,自动降级为不带 tools 的纯文本请求
if len(req.Tools) > 0 && isHTTP400Error(err) {
fmt.Println("[OpenAI] 模型不支持 Function Calling自动降级为纯文本模式")
body.Tools = nil
respBody, err = p.doRequest(ctx, body)
if err != nil {
return nil, err
}
} else {
respBody, err = p.retryClientRejectedChatRequest(ctx, req, body, err)
if err != nil {
return nil, err
}
}
@@ -264,7 +259,8 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, cal
return err
}
messages := buildOpenAIMessages(req.Messages, p.config.Model, p.baseURL)
requestMessages := prepareOpenAIRequestMessages(req.Messages, p.config.Model, p.baseURL)
messages := buildOpenAIMessages(requestMessages, p.config.Model, p.baseURL)
temperature := req.Temperature
if temperature <= 0 {
@@ -281,15 +277,8 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, cal
respBody, err := p.doRequest(ctx, body)
if err != nil {
// 当带 tools 的请求返回 400 时,自动降级为不带 tools 的纯文本请求
if len(req.Tools) > 0 && isHTTP400Error(err) {
fmt.Println("[OpenAI] 模型不支持 Function Calling自动降级为纯文本模式")
body.Tools = nil
respBody, err = p.doRequest(ctx, body)
if err != nil {
return err
}
} else {
respBody, err = p.retryClientRejectedChatRequest(ctx, req, body, err)
if err != nil {
return err
}
}
@@ -395,6 +384,113 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, cal
return nil
}
func (p *OpenAIProvider) retryClientRejectedChatRequest(ctx context.Context, req ai.ChatRequest, body openAIChatRequest, err error) (io.ReadCloser, error) {
if !isHTTP400Error(err) {
return nil, err
}
if len(body.Tools) > 0 {
fmt.Println("[OpenAI] 模型不支持 Function Calling自动降级为纯文本模式")
body.Tools = nil
respBody, retryErr := p.doRequest(ctx, body)
if retryErr == nil {
return respBody, nil
}
if !isHTTP400Error(retryErr) {
return nil, retryErr
}
err = retryErr
}
if requestMessagesContainImages(req.Messages) {
fmt.Println("[OpenAI] 模型不支持图片输入,自动移除图片后重试")
body.Messages = buildOpenAIMessages(stripImagesFromRequestMessages(req.Messages), p.config.Model, p.baseURL)
body.Tools = nil
respBody, retryErr := p.doRequest(ctx, body)
if retryErr == nil {
return respBody, nil
}
return nil, retryErr
}
return nil, err
}
func prepareOpenAIRequestMessages(messages []ai.Message, modelName string, baseURL string) []ai.Message {
if requestMessagesContainImages(messages) && shouldOmitImagesBeforeRequest(modelName, baseURL) {
fmt.Println("[OpenAI] 当前模型按文本模型处理,发送前移除图片输入")
return stripImagesFromRequestMessages(messages)
}
return messages
}
func shouldOmitImagesBeforeRequest(modelName string, baseURL string) bool {
model := strings.ToLower(strings.TrimSpace(modelName))
base := strings.ToLower(strings.TrimSpace(baseURL))
if model == "" {
return false
}
visionMarkers := []string{
"vision",
"vl",
"image",
"4v",
"omni",
"gpt-4o",
"gpt-4.1",
"gpt-5",
"glm-4v",
}
for _, marker := range visionMarkers {
if strings.Contains(model, marker) || strings.Contains(base, marker) {
return false
}
}
textOnlyMarkers := []string{
"minimax-m1",
"minimax-m2",
"kimi-k2",
"deepseek",
"moonshot-v1",
}
for _, marker := range textOnlyMarkers {
if strings.Contains(model, marker) {
return true
}
}
return false
}
func requestMessagesContainImages(messages []ai.Message) bool {
for _, message := range messages {
if len(message.Images) > 0 {
return true
}
}
return false
}
func stripImagesFromRequestMessages(messages []ai.Message) []ai.Message {
stripped := make([]ai.Message, len(messages))
for i, message := range messages {
stripped[i] = message
if len(message.Images) == 0 {
continue
}
stripped[i].Images = nil
content := strings.TrimSpace(stripped[i].Content)
if content == "" {
stripped[i].Content = omittedImageNotice
continue
}
stripped[i].Content = content + "\n\n" + omittedImageNotice
}
return stripped
}
func (p *OpenAIProvider) doRequest(ctx context.Context, body interface{}) (io.ReadCloser, error) {
jsonBody, err := json.Marshal(body)
if err != nil {

View File

@@ -1,10 +1,15 @@
package provider
import (
"GoNavi-Wails/internal/ai"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"GoNavi-Wails/internal/ai"
)
func TestNormalizeOpenAICompatibleBaseURL(t *testing.T) {
@@ -168,6 +173,207 @@ func TestOpenAIProvider_DefaultMaxTokens(t *testing.T) {
}
}
func TestOpenAIProviderChatRetriesWithoutImagesOnHTTP400(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body failed: %v", err)
}
defer r.Body.Close()
if strings.Contains(string(body), `"image_url"`) {
http.Error(w, `{"error":{"message":"Model do not support image input"}}`, http.StatusBadRequest)
return
}
if !strings.Contains(string(body), omittedImageNotice) {
t.Fatalf("expected retry body to explain omitted image, got %s", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`))
}))
defer server.Close()
providerInstance, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai",
Name: "test-openai",
APIKey: "sk-test",
BaseURL: server.URL,
Model: "custom-text-model",
MaxTokens: 64,
Temperature: 0.1,
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
resp, err := providerInstance.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{
Role: "user",
Content: "请描述这张图片",
Images: []string{"data:image/png;base64,abc"},
}},
})
if err != nil {
t.Fatalf("expected chat image fallback to succeed, got %v", err)
}
if resp.Content != "pong" {
t.Fatalf("expected fallback content %q, got %q", "pong", resp.Content)
}
if requestCount != 2 {
t.Fatalf("expected 2 requests (with image then fallback), got %d", requestCount)
}
}
func TestOpenAIProviderChatOmitsImagesUpfrontForMiniMaxTextModel(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body failed: %v", err)
}
defer r.Body.Close()
bodyText := string(body)
if strings.Contains(bodyText, `"image_url"`) {
t.Fatalf("expected MiniMax text request to omit image_url, got %s", body)
}
if !strings.Contains(bodyText, omittedImageNotice) {
t.Fatalf("expected request body to explain omitted image, got %s", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`))
}))
defer server.Close()
providerInstance, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai",
Name: "test-openai",
APIKey: "sk-test",
BaseURL: server.URL,
Model: "MiniMax-M2.7-highspeed",
MaxTokens: 64,
Temperature: 0.1,
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
resp, err := providerInstance.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{
Role: "user",
Content: "请描述这张图片",
Images: []string{"data:image/png;base64,abc"},
}},
})
if err != nil {
t.Fatalf("expected chat to succeed without sending image, got %v", err)
}
if resp.Content != "pong" {
t.Fatalf("expected content %q, got %q", "pong", resp.Content)
}
if requestCount != 1 {
t.Fatalf("expected 1 request without image retry, got %d", requestCount)
}
}
func TestPrepareOpenAIRequestMessagesKeepsImagesForVisionModel(t *testing.T) {
got := prepareOpenAIRequestMessages([]ai.Message{{
Role: "user",
Content: "请描述图片",
Images: []string{"data:image/png;base64,abc"},
}}, "gpt-5.4", "https://sub.syngnat.top/v1")
if len(got) != 1 || len(got[0].Images) != 1 {
t.Fatalf("expected vision-capable model to keep images, got %#v", got)
}
}
func TestOpenAIProviderChatStreamRetriesWithoutToolsThenImagesOnHTTP400(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body failed: %v", err)
}
defer r.Body.Close()
bodyText := string(body)
if strings.Contains(bodyText, `"tools"`) {
http.Error(w, `{"error":{"message":"A parameter specified in the request is not valid"}}`, http.StatusBadRequest)
return
}
if strings.Contains(bodyText, `"image_url"`) {
http.Error(w, `{"error":{"message":"A parameter specified in the request is not valid"}}`, http.StatusBadRequest)
return
}
if !strings.Contains(bodyText, omittedImageNotice) {
t.Fatalf("expected retry body to explain omitted image, got %s", body)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(strings.Join([]string{
`data: {"choices":[{"delta":{"content":"pong"},"finish_reason":null}]}`,
``,
`data: [DONE]`,
``,
}, "\n")))
}))
defer server.Close()
providerInstance, err := NewOpenAIProvider(ai.ProviderConfig{
Type: "openai",
Name: "test-openai",
APIKey: "sk-test",
BaseURL: server.URL,
Model: "custom-text-model",
MaxTokens: 64,
Temperature: 0.1,
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
var chunks []ai.StreamChunk
err = providerInstance.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{
Role: "user",
Content: "请描述这张图片",
Images: []string{"data:image/png;base64,abc"},
}},
Tools: []ai.Tool{{
Type: "function",
Function: ai.ToolFunction{
Name: "inspect_ai_last_render_error",
Description: "test tool",
Parameters: map[string]interface{}{"type": "object"},
},
}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("expected stream fallback to succeed, got %v", err)
}
if requestCount != 3 {
t.Fatalf("expected 3 requests (with tools, without tools, without images), got %d", requestCount)
}
if len(chunks) < 2 {
t.Fatalf("expected content and done chunks, got %#v", chunks)
}
if chunks[0].Content != "pong" {
t.Fatalf("expected first chunk content %q, got %#v", "pong", chunks[0])
}
if !chunks[len(chunks)-1].Done {
t.Fatalf("expected final done chunk, got %#v", chunks[len(chunks)-1])
}
}
func TestBuildOpenAIMessages_ReplaysDeepSeekReasoningContentForToolCalls(t *testing.T) {
toolCall := testOpenAIToolCall()
got := buildOpenAIMessages([]ai.Message{

View File

@@ -129,8 +129,9 @@ func (s *Service) stopMCPHTTPServer(ctx context.Context, message string) (ai.MCP
status = defaultMCPHTTPServerStatus()
}
status.Running = false
status.Token = ""
status.AuthorizationHeader = ""
if strings.TrimSpace(status.Token) != "" {
status.AuthorizationHeader = "Bearer " + strings.TrimSpace(status.Token)
}
status.Message = "GoNavi MCP HTTP 服务未启动"
s.mcpHTTPLast = status
s.mcpHTTPMu.Unlock()
@@ -260,6 +261,11 @@ func (p *mcpHTTPCommandProcess) Stop(ctx context.Context) error {
if p == nil {
return nil
}
select {
case <-p.done:
return p.waitErr()
default:
}
if p.cancel != nil {
p.cancel()
}
@@ -268,7 +274,7 @@ func (p *mcpHTTPCommandProcess) Stop(ctx context.Context) error {
}
select {
case <-p.done:
return p.waitErr()
return nil
case <-ctx.Done():
return ctx.Err()
}
@@ -383,8 +389,9 @@ func defaultMCPHTTPServerStatus() ai.MCPHTTPServerStatus {
func stoppedMCPHTTPStatus(status ai.MCPHTTPServerStatus, message string) ai.MCPHTTPServerStatus {
status.Running = false
status.Token = ""
status.AuthorizationHeader = ""
if strings.TrimSpace(status.Token) != "" {
status.AuthorizationHeader = "Bearer " + strings.TrimSpace(status.Token)
}
status.Message = message
return status
}

View File

@@ -2,9 +2,12 @@ package aiservice
import (
"context"
"os"
"os/exec"
"strings"
"sync"
"testing"
"time"
"GoNavi-Wails/internal/ai"
"GoNavi-Wails/internal/secretstore"
@@ -95,7 +98,83 @@ func TestMCPHTTPServerLifecycleFromAIService(t *testing.T) {
if stopped.Running {
t.Fatalf("expected stopped status, got %#v", stopped)
}
if stopped.Token != "" || stopped.AuthorizationHeader != "" {
t.Fatalf("expected stopped status to clear token fields, got token=%q header=%q", stopped.Token, stopped.AuthorizationHeader)
if stopped.Token != started.Token || stopped.AuthorizationHeader != "Bearer "+started.Token {
t.Fatalf("expected stopped status to keep token fields, got token=%q header=%q", stopped.Token, stopped.AuthorizationHeader)
}
}
func TestMCPHTTPServerStartUsesCustomAddrAndToken(t *testing.T) {
originalStarter := startMCPHTTPProcess
originalHealth := waitMCPHTTPHealth
t.Cleanup(func() {
startMCPHTTPProcess = originalStarter
waitMCPHTTPHealth = originalHealth
})
var capturedOptions mcpHTTPProcessStartOptions
startMCPHTTPProcess = func(_ context.Context, options mcpHTTPProcessStartOptions) (mcpHTTPProcess, error) {
capturedOptions = options
return newFakeMCPHTTPProcess(), nil
}
waitMCPHTTPHealth = func(_ context.Context, _ string) error {
return nil
}
service := NewServiceWithSecretStore(secretstore.NewUnavailableStore("test"))
InitializeLifecycle(service, context.Background())
t.Cleanup(func() {
service.Shutdown(context.Background())
})
started, err := service.AIStartMCPHTTPServer(ai.MCPHTTPServerOptions{
Addr: "127.0.0.1:9123",
Path: "mcp",
Token: "gnv_custom_token",
})
if err != nil {
t.Fatalf("AIStartMCPHTTPServer returned error: %v", err)
}
if capturedOptions.Addr != "127.0.0.1:9123" || capturedOptions.Token != "gnv_custom_token" {
t.Fatalf("expected custom addr/token, got %#v", capturedOptions)
}
if started.URL != "http://127.0.0.1:9123/mcp" {
t.Fatalf("expected custom MCP URL, got %q", started.URL)
}
if started.Token != "gnv_custom_token" || started.AuthorizationHeader != "Bearer gnv_custom_token" {
t.Fatalf("expected custom bearer token in status, got token=%q header=%q", started.Token, started.AuthorizationHeader)
}
if !started.SchemaOnly || !capturedOptions.SchemaOnly {
t.Fatal("expected custom in-app MCP HTTP server to remain schema-only")
}
}
func TestMCPHTTPCommandProcessStopTreatsRequestedCancelAsSuccess(t *testing.T) {
cmdCtx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(cmdCtx, os.Args[0], "-test.run=TestMCPHTTPCommandProcessStopHelper")
cmd.Env = append(os.Environ(), "GONAVI_MCP_HTTP_STOP_HELPER=1")
if err := cmd.Start(); err != nil {
cancel()
t.Fatalf("start helper process: %v", err)
}
process := &mcpHTTPCommandProcess{
cancel: cancel,
cmd: cmd,
done: make(chan struct{}),
}
go process.wait()
stopCtx, stopCancel := context.WithTimeout(context.Background(), 2*time.Second)
defer stopCancel()
if err := process.Stop(stopCtx); err != nil {
t.Fatalf("expected requested stop to ignore process kill error, got %v", err)
}
}
func TestMCPHTTPCommandProcessStopHelper(t *testing.T) {
if os.Getenv("GONAVI_MCP_HTTP_STOP_HELPER") != "1" {
return
}
select {}
}