mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 02:19:58 +08:00
✨ 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:
@@ -1 +1 @@
|
||||
d0464f9da25e9356e61652e638c99ffe
|
||||
416aaa5c6e66a62430103d6905ad9465
|
||||
@@ -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;
|
||||
|
||||
@@ -595,7 +595,6 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
}}
|
||||
onSettingsClick={handleOpenSettingsFromPanel}
|
||||
onClose={onClose}
|
||||
messages={messages}
|
||||
sessionTitle={currentSessionTitle}
|
||||
activeMode={effectivePanelMode}
|
||||
onModeChange={(mode) => {
|
||||
|
||||
@@ -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;');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
14
frontend/src/components/ai/AIChatHeader.test.tsx
Normal file
14
frontend/src/components/ai/AIChatHeader.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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="关闭"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }}>
|
||||
给 OpenClaw、Hermans 等远程 Agent 使用。打开后默认监听本机地址,自动生成 Bearer Token,只开放连接、库表、字段和 DDL 等结构读取工具。
|
||||
给 OpenClaw、Hermans 等远程 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
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
3
frontend/wailsjs/go/aiservice/Service.d.ts
vendored
3
frontend/wailsjs/go/aiservice/Service.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
15
frontend/wailsjs/go/app/App.d.ts
vendored
15
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user