From c3a3387ee326a6f49c059c943935d07effac98f2 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 12 Jun 2026 14:51:37 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ai):=20=E4=BC=98=E5=8C=96=20AI?= =?UTF-8?q?=20=E5=AF=B9=E8=AF=9D=E4=BD=93=E9=AA=8C=E4=B8=8E=20MCP=20?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI 请求:增强 OpenAI 兼容接口降级逻辑,文本模型自动省略图片并在 400 场景重试 - MCP 接入:支持自定义 HTTP 服务监听地址、端口和 Authorization Bearer Token - MCP 生命周期:停止服务后保留授权信息,并将主动关闭子进程视为正常停止 - 交互优化:移除 AI 对话导出入口,支持关闭常驻状态提示并收敛设置弹窗 toast 宽度 - UI 调整:优化 AI 输入框边框、聚焦态和 Authorization 运行中只读可查看体验 - 测试覆盖:补充 OpenAI 降级、MCP HTTP、AI Header 和设置面板相关用例 --- frontend/package.json.md5 | 2 +- frontend/src/components/AIChatPanel.css | 52 ++++- frontend/src/components/AIChatPanel.tsx | 1 - .../AISettingsModal.edit-password.test.tsx | 5 +- frontend/src/components/AISettingsModal.tsx | 58 ++++- .../components/ai/AIChatComposerStatus.tsx | 39 +++- .../src/components/ai/AIChatHeader.test.tsx | 14 ++ frontend/src/components/ai/AIChatHeader.tsx | 42 +--- .../components/ai/AIChatInput.notice.test.tsx | 29 ++- frontend/src/components/ai/AIChatInput.tsx | 33 ++- .../ai/AIMCPHTTPServerPanel.test.tsx | 80 +++++-- .../components/ai/AIMCPHTTPServerPanel.tsx | 46 +++- .../ai/AISettingsMCPSection.test.tsx | 24 +- .../components/ai/AISettingsMCPSection.tsx | 7 + frontend/src/main.tsx | 7 +- frontend/src/v2-theme.css | 45 ++-- frontend/wailsjs/go/aiservice/Service.d.ts | 3 + frontend/wailsjs/go/aiservice/Service.js | 4 + frontend/wailsjs/go/app/App.d.ts | 15 +- frontend/wailsjs/go/app/App.js | 28 ++- frontend/wailsjs/go/models.ts | 17 +- internal/ai/provider/openai.go | 136 ++++++++++-- internal/ai/provider/openai_test.go | 208 +++++++++++++++++- internal/ai/service/mcp_http_server.go | 17 +- internal/ai/service/mcp_http_server_test.go | 83 ++++++- 25 files changed, 811 insertions(+), 184 deletions(-) create mode 100644 frontend/src/components/ai/AIChatHeader.test.tsx diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 7396e24..34c2d7f 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -d0464f9da25e9356e61652e638c99ffe \ No newline at end of file +416aaa5c6e66a62430103d6905ad9465 \ No newline at end of file diff --git a/frontend/src/components/AIChatPanel.css b/frontend/src/components/AIChatPanel.css index f8487da..65d47ae 100644 --- a/frontend/src/components/AIChatPanel.css +++ b/frontend/src/components/AIChatPanel.css @@ -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; diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 0b73ca4..a978305 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -595,7 +595,6 @@ export const AIChatPanel: React.FC = ({ }} onSettingsClick={handleOpenSettingsFromPanel} onClose={onClose} - messages={messages} sessionTitle={currentSessionTitle} activeMode={effectivePanelMode} onModeChange={(mode) => { diff --git a/frontend/src/components/AISettingsModal.edit-password.test.tsx b/frontend/src/components/AISettingsModal.edit-password.test.tsx index 0e82fa2..f5694ee 100644 --- a/frontend/src/components/AISettingsModal.edit-password.test.tsx +++ b/frontend/src/components/AISettingsModal.edit-password.test.tsx @@ -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;'); }); diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 2d7e945..0790e21 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -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 = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => { const [providers, setProviders] = useState([]); const [activeProviderId, setActiveProviderId] = useState(''); @@ -56,6 +84,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const [mcpServers, setMCPServers] = useState([]); const [mcpTools, setMCPTools] = useState([]); const [mcpHTTPServerStatus, setMCPHTTPServerStatus] = useState(DEFAULT_MCP_HTTP_SERVER_STATUS); + const [mcpHTTPServerDraft, setMCPHTTPServerDraft] = useState(DEFAULT_MCP_HTTP_SERVER_DRAFT); const [mcpHTTPServerLoading, setMCPHTTPServerLoading] = useState(false); const [skills, setSkills] = useState([]); const [editingProvider, setEditingProvider] = useState(null); @@ -182,10 +211,12 @@ const AISettingsModal: React.FC = ({ 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 = ({ 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 = ({ open, onClose, darkMo } }; + const handleUpdateMCPHTTPServerDraft = (patch: Partial) => { + setMCPHTTPServerDraft((prev) => ({ + ...prev, + ...patch, + })); + }; + const handleCopyMCPHTTPServerURL = async () => { const url = String(mcpHTTPServerStatus.url || '').trim(); if (!url) { @@ -724,6 +765,7 @@ const AISettingsModal: React.FC = ({ 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 = ({ 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 = ({ open, onClose, darkMo }; export default AISettingsModal; - - - diff --git a/frontend/src/components/ai/AIChatComposerStatus.tsx b/frontend/src/components/ai/AIChatComposerStatus.tsx index 93cbce0..3ce49de 100644 --- a/frontend/src/components/ai/AIChatComposerStatus.tsx +++ b/frontend/src/components/ai/AIChatComposerStatus.tsx @@ -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 = ({ darkMode, overlayTheme, onAction, + onDismiss, }) => { const palette = resolvePalette(snapshot.severity, darkMode); const handleAction = () => { @@ -103,6 +106,7 @@ const AIChatComposerStatus: React.FC = ({ onAction(snapshot.action.key); } }; + const canDismiss = typeof onDismiss === 'function'; return (
= ({
- {snapshot.action && typeof onAction === 'function' && ( - - )} + {(snapshot.action && typeof onAction === 'function') || canDismiss ? ( +
+ {snapshot.action && typeof onAction === 'function' && ( + + )} + {canDismiss && ( +
+ ) : null} ); }; diff --git a/frontend/src/components/ai/AIChatHeader.test.tsx b/frontend/src/components/ai/AIChatHeader.test.tsx new file mode 100644 index 0000000..b3a4edc --- /dev/null +++ b/frontend/src/components/ai/AIChatHeader.test.tsx @@ -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'); + }); +}); diff --git a/frontend/src/components/ai/AIChatHeader.tsx b/frontend/src/components/ai/AIChatHeader.tsx index 36898e2..0827329 100644 --- a/frontend/src/components/ai/AIChatHeader.tsx +++ b/frontend/src/components/ai/AIChatHeader.tsx @@ -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 = ({ 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 = ({ GoNavi AI
- {messages.length > 0 && ( - -
- - {messages.length > 0 && ( -
- -
- )} ); }; diff --git a/frontend/src/components/ai/AIChatInput.notice.test.tsx b/frontend/src/components/ai/AIChatInput.notice.test.tsx index 4ad94a6..a7d5b08 100644 --- a/frontend/src/components/ai/AIChatInput.notice.test.tsx +++ b/frontend/src/components/ai/AIChatInput.notice.test.tsx @@ -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> = {}) => renderToStaticMarkup( +const buildAIChatInput = (overrides: Partial> = {}) => ( {}} @@ -63,6 +65,9 @@ const renderAIChatInput = (overrides: Partial ); +const renderAIChatInput = (overrides: Partial> = {}) => + 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( + {}} + />, + ); + + expect(markup).toContain('data-ai-chat-composer-status="true"'); + expect(markup).toContain('aria-label="关闭 AI 状态提示"'); + expect(markup).toContain('title="关闭"'); + }); }); diff --git a/frontend/src/components/ai/AIChatInput.tsx b/frontend/src/components/ai/AIChatInput.tsx index dcb9b60..ef8fcfb 100644 --- a/frontend/src/components/ai/AIChatInput.tsx +++ b/frontend/src/components/ai/AIChatInput.tsx @@ -71,6 +71,33 @@ export const AIChatInput: React.FC = ({ 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(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 = ({ mutedColor={mutedColor} onComposerNoticeAction={composerNoticeActionHandler} /> - {!composerNotice && ( + {!composerNotice && !isComposerStatusDismissed && ( )}
@@ -317,12 +345,13 @@ export const AIChatInput: React.FC = ({ mutedColor={mutedColor} onComposerNoticeAction={composerNoticeActionHandler} /> - {!composerNotice && ( + {!composerNotice && !isComposerStatusDismissed && ( )}
diff --git a/frontend/src/components/ai/AIMCPHTTPServerPanel.test.tsx b/frontend/src/components/ai/AIMCPHTTPServerPanel.test.tsx index a211a46..a4e6f70 100644 --- a/frontend/src/components/ai/AIMCPHTTPServerPanel.test.tsx +++ b/frontend/src/components/ai/AIMCPHTTPServerPanel.test.tsx @@ -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( - {}} - onCopyURL={() => {}} - onCopyAuthorization={() => {}} - />, + , ); 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); + }); }); diff --git a/frontend/src/components/ai/AIMCPHTTPServerPanel.tsx b/frontend/src/components/ai/AIMCPHTTPServerPanel.tsx index 45b2e26..ab85564 100644 --- a/frontend/src/components/ai/AIMCPHTTPServerPanel.tsx +++ b/frontend/src/components/ai/AIMCPHTTPServerPanel.tsx @@ -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) => void; onToggle: (checked: boolean) => void; onCopyURL: () => void; onCopyAuthorization: () => void; @@ -19,11 +27,13 @@ export interface AIMCPHTTPServerPanelProps { const AIMCPHTTPServerPanel: React.FC = ({ status, + draft, loading, cardBg, cardBorder, darkMode, overlayTheme, + onDraftChange, onToggle, onCopyURL, onCopyAuthorization, @@ -31,6 +41,10 @@ const AIMCPHTTPServerPanel: React.FC = ({ 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 (
= ({
- 给 OpenClaw、Hermans 等远程 Agent 使用。打开后默认监听本机地址,自动生成 Bearer Token,只开放连接、库表、字段和 DDL 等结构读取工具。 + 给 OpenClaw、Hermans 等远程 Agent 使用。打开后监听本机地址,只开放连接、库表、字段和 DDL 等结构读取工具。
= ({ gap: 8, }} > +
+
+ 监听地址 / 端口 + onDraftChange({ addr: event.target.value })} + style={inputStyle} + /> +
+
+ Authorization + onDraftChange({ authorizationHeader: event.target.value })} + style={inputStyle} + /> +
+
{running ? status.message || '服务运行中,可把 URL 和 Authorization Header 配置到远程 MCP 客户端。' - : '不用再手动执行 GoNavi.exe mcp-server http 命令;在这里打开开关即可启动本机 HTTP MCP。'} + : '可自定义本机监听端口和 Bearer Token;留空 Authorization 时启动会自动生成随机 Token。'}
= {}): 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 = {}): 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' }); + }); }); diff --git a/frontend/src/components/ai/AISettingsMCPSection.tsx b/frontend/src/components/ai/AISettingsMCPSection.tsx index f5164e4..14aba83 100644 --- a/frontend/src/components/ai/AISettingsMCPSection.tsx +++ b/frontend/src/components/ai/AISettingsMCPSection.tsx @@ -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) => void; onToggleHTTPServer: (checked: boolean) => void; onCopyHTTPServerURL: () => void; onCopyHTTPServerAuthorization: () => void; @@ -51,6 +54,7 @@ const AISettingsMCPSection: React.FC = ({ selectedMCPClientStatus, selectedMCPClientCommandText, mcpHTTPServerStatus, + mcpHTTPServerDraft, mcpServers, mcpTools, darkMode, @@ -61,6 +65,7 @@ const AISettingsMCPSection: React.FC = ({ loading, mcpClientStatusLoading, mcpHTTPServerLoading, + onUpdateHTTPServerDraft, onToggleHTTPServer, onCopyHTTPServerURL, onCopyHTTPServerAuthorization, @@ -78,11 +83,13 @@ const AISettingsMCPSection: React.FC = ({
{ 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); diff --git a/frontend/src/v2-theme.css b/frontend/src/v2-theme.css index 15591bc..541057a 100644 --- a/frontend/src/v2-theme.css +++ b/frontend/src/v2-theme.css @@ -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; } diff --git a/frontend/wailsjs/go/aiservice/Service.d.ts b/frontend/wailsjs/go/aiservice/Service.d.ts index 9837835..d4a7546 100755 --- a/frontend/wailsjs/go/aiservice/Service.d.ts +++ b/frontend/wailsjs/go/aiservice/Service.d.ts @@ -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; @@ -77,3 +78,5 @@ export function AIStopMCPHTTPServer():Promise; export function AITestMCPServer(arg1:ai.MCPServerConfig):Promise>; export function AITestProvider(arg1:ai.ProviderConfig):Promise>; + +export function Shutdown(arg1:context.Context):Promise; diff --git a/frontend/wailsjs/go/aiservice/Service.js b/frontend/wailsjs/go/aiservice/Service.js index 9858fd4..768dd8d 100755 --- a/frontend/wailsjs/go/aiservice/Service.js +++ b/frontend/wailsjs/go/aiservice/Service.js @@ -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); +} diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 8596db0..fca2f85 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -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; @@ -34,6 +35,8 @@ export function CreateSQLFile(arg1:string,arg2:string):Promise; +export function DBCommitTransaction(arg1:string):Promise; + export function DBConnect(arg1:connection.ConnectionConfig):Promise; export function DBGetAllColumns(arg1:connection.ConnectionConfig,arg2:string):Promise; @@ -52,8 +55,6 @@ export function DBGetTriggers(arg1:connection.ConnectionConfig,arg2:string,arg3: export function DBQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; -export function DBCommitTransaction(arg1:string):Promise; - export function DBQueryIsolated(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; export function DBQueryMulti(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; @@ -62,10 +63,10 @@ export function DBQueryMultiTransactional(arg1:connection.ConnectionConfig,arg2: export function DBQueryWithCancel(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; -export function DBShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; - export function DBRollbackTransaction(arg1:string):Promise; +export function DBShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; + export function DataSync(arg1:sync.SyncConfig):Promise; export function DataSyncAnalyze(arg1:sync.SyncConfig):Promise; @@ -206,10 +207,10 @@ export function PreviewChanges(arg1:connection.ConnectionConfig,arg2:string,arg3 export function PreviewImportFile(arg1:string):Promise; -export function ReadSQLFile(arg1:string):Promise; - export function ReadAppLogTail(arg1:number,arg2:string):Promise; +export function ReadSQLFile(arg1:string):Promise; + export function RedisConnect(arg1:connection.ConnectionConfig):Promise; export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:any):Promise; @@ -306,6 +307,8 @@ export function SetMacNativeWindowControls(arg1:boolean):Promise; export function SetWindowTranslucency(arg1:number,arg2:number):Promise; +export function Shutdown(arg1:context.Context):Promise; + export function StartSecurityUpdate(arg1:app.StartSecurityUpdateRequest):Promise; export function TestConnection(arg1:connection.ConnectionConfig):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 637e28a..a1afcf6 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -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); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 9c25a91..6b7f70a 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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 { } } + diff --git a/internal/ai/provider/openai.go b/internal/ai/provider/openai.go index 9fcf912..8594ea4 100644 --- a/internal/ai/provider/openai.go +++ b/internal/ai/provider/openai.go @@ -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 { diff --git a/internal/ai/provider/openai_test.go b/internal/ai/provider/openai_test.go index 43d3a22..8cbeb12 100644 --- a/internal/ai/provider/openai_test.go +++ b/internal/ai/provider/openai_test.go @@ -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{ diff --git a/internal/ai/service/mcp_http_server.go b/internal/ai/service/mcp_http_server.go index 1d18ebb..3f1ac3f 100644 --- a/internal/ai/service/mcp_http_server.go +++ b/internal/ai/service/mcp_http_server.go @@ -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 } diff --git a/internal/ai/service/mcp_http_server_test.go b/internal/ai/service/mcp_http_server_test.go index f058479..df9e306 100644 --- a/internal/ai/service/mcp_http_server_test.go +++ b/internal/ai/service/mcp_http_server_test.go @@ -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 {} +}