From 4f74c4414740e044167f19d0c51b31162a6eb178 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Mar 2026 14:29:03 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(ai/provider/chat-ui):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8DAI=E4=BE=9B=E5=BA=94=E5=95=86=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E6=80=A7=E5=B9=B6=E4=BC=98=E5=8C=96=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复通义千问百炼 Anthropic 兼容鉴权头与健康检查请求 - 拆分通义千问百炼通用与 Coding Plan 双入口,调整预设回填与模型策略 - 修复火山 Coding Plan 模型列表过滤逻辑,避免混入无关模型 - 统一 OpenAI 兼容供应商路径与模型列表处理,补充相关服务层测试 - 优化 AI 设置供应商卡片布局,统一高度并收紧文本展示 - 将聊天区模型校验提示改为输入框上方的内联提示卡,补充前端回归测试 --- frontend/src/components/AIChatPanel.tsx | 36 +++- frontend/src/components/AISettingsModal.tsx | 83 +++++---- .../components/ai/AIChatInput.notice.test.tsx | 61 +++++++ frontend/src/components/ai/AIChatInput.tsx | 64 ++++++- frontend/src/utils/aiComposerNotice.test.ts | 33 ++++ frontend/src/utils/aiComposerNotice.ts | 27 +++ frontend/src/utils/aiProviderPresets.test.ts | 68 ++++++++ frontend/src/utils/aiProviderPresets.ts | 105 ++++++++++++ .../src/utils/aiSettingsPresetLayout.test.ts | 56 ++++++ frontend/src/utils/aiSettingsPresetLayout.ts | 47 +++++ frontend/wailsjs/runtime/package.json | 0 frontend/wailsjs/runtime/runtime.d.ts | 0 frontend/wailsjs/runtime/runtime.js | 0 internal/ai/provider/anthropic.go | 23 ++- internal/ai/provider/anthropic_test.go | 35 +++- internal/ai/service/service.go | 162 ++++++++++++++---- internal/ai/service/service_qwen_test.go | 85 +++++++++ internal/ai/service/service_test.go | 37 ++++ .../ai/service/service_volcengine_test.go | 89 ++++++++++ 19 files changed, 931 insertions(+), 80 deletions(-) create mode 100644 frontend/src/components/ai/AIChatInput.notice.test.tsx create mode 100644 frontend/src/utils/aiComposerNotice.test.ts create mode 100644 frontend/src/utils/aiComposerNotice.ts create mode 100644 frontend/src/utils/aiProviderPresets.test.ts create mode 100644 frontend/src/utils/aiProviderPresets.ts create mode 100644 frontend/src/utils/aiSettingsPresetLayout.test.ts create mode 100644 frontend/src/utils/aiSettingsPresetLayout.ts mode change 100755 => 100644 frontend/wailsjs/runtime/package.json mode change 100755 => 100644 frontend/wailsjs/runtime/runtime.d.ts mode change 100755 => 100644 frontend/wailsjs/runtime/runtime.js create mode 100644 internal/ai/service/service_qwen_test.go create mode 100644 internal/ai/service/service_volcengine_test.go diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 6f2702e..22b60b6 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -6,7 +6,6 @@ import { DBGetDatabases, DBGetTables } from '../../wailsjs/go/app/App'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { AIChatMessage, AIToolCall } from '../types'; import { DownOutlined } from '@ant-design/icons'; -import { message as antdMessage } from 'antd'; import './AIChatPanel.css'; import { AIChatHeader } from './ai/AIChatHeader'; @@ -14,6 +13,12 @@ import { AIChatWelcome } from './ai/AIChatWelcome'; import { AIMessageBubble } from './ai/AIMessageBubble'; import { AIChatInput } from './ai/AIChatInput'; import { AIHistoryDrawer } from './ai/AIHistoryDrawer'; +import type { AIComposerNotice } from '../utils/aiComposerNotice'; +import { + buildMissingModelNotice, + buildMissingProviderNotice, + buildModelFetchFailedNotice, +} from '../utils/aiComposerNotice'; interface AIChatPanelProps { width?: number; @@ -211,6 +216,7 @@ export const AIChatPanel: React.FC = ({ const [dynamicModels, setDynamicModels] = useState([]); const [showScrollBottom, setShowScrollBottom] = useState(false); const [loadingModels, setLoadingModels] = useState(false); + const [composerNotice, setComposerNotice] = useState(null); const [panelWidth, setPanelWidth] = useState(width); const [isResizing, setIsResizing] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); @@ -224,9 +230,6 @@ export const AIChatPanel: React.FC = ({ const panelRef = useRef(null); // 面板 DOM ref,用于拖拽时直接操作宽度 const dragWidthRef = useRef(0); // 拖拽过程中的实时宽度(不触发 React 重渲染) - // 面板内部 toast 通知(不在屏幕顶部,而在面板容器内显示) - const [messageApi, messageContextHolder] = antdMessage.useMessage({ getContainer: () => panelRef.current || document.body }); - const aiChatHistory = useStore(state => state.aiChatHistory); const aiActiveSessionId = useStore(state => state.aiActiveSessionId); const createNewAISession = useStore(state => state.createNewAISession); @@ -336,6 +339,7 @@ export const AIChatPanel: React.FC = ({ useEffect(() => { const handler = () => { setDynamicModels([]); + setComposerNotice(null); activeProviderIdRef.current = null; loadActiveProvider(); }; @@ -350,6 +354,7 @@ export const AIChatPanel: React.FC = ({ const payload = { ...activeProvider, model: val }; await Service?.AISaveProvider?.(payload); setActiveProvider(payload); + setComposerNotice(null); } catch (e) { console.warn('Failed to update provider model', e); } }; @@ -358,33 +363,45 @@ export const AIChatPanel: React.FC = ({ useEffect(() => { if (activeProvider?.id && activeProvider.id !== activeProviderIdRef.current) { setDynamicModels([]); + setComposerNotice(null); activeProviderIdRef.current = activeProvider.id; } // 供应商被删除后 activeProvider 变为 null,此时也必须清空残留模型 if (!activeProvider) { setDynamicModels([]); + setComposerNotice(null); activeProviderIdRef.current = null; } }, [activeProvider?.id, activeProvider]); + useEffect(() => { + if (activeProvider?.model && String(activeProvider.model).trim()) { + setComposerNotice(null); + } + }, [activeProvider?.model]); + // dynamicModels 仅在内存中使用,不再写回供应商配置,避免污染静态 models 列表 const fetchDynamicModels = useCallback(async () => { try { setLoadingModels(true); + setComposerNotice(null); const Service = (window as any).go?.aiservice?.Service; if (!Service) return; const result = await Service.AIListModels?.(); if (result?.success && Array.isArray(result.models) && result.models.length > 0) { const sortedModels = [...result.models].sort((a, b) => a.localeCompare(b)); setDynamicModels(sortedModels); + setComposerNotice(null); } else if (result && !result.success) { - messageApi.warning(result.error || '获取模型列表失败,可手动输入模型名称'); + setDynamicModels([]); + setComposerNotice(buildModelFetchFailedNotice(result.error)); } } catch (e: any) { console.warn('Failed to fetch models', e); - messageApi.warning('获取模型列表失败: ' + (e?.message || '未知错误')); + setDynamicModels([]); + setComposerNotice(buildModelFetchFailedNotice('获取模型列表失败:' + (e?.message || '未知错误'))); } finally { setLoadingModels(false); } @@ -1030,13 +1047,14 @@ SELECT * FROM users WHERE status = 1; // 前置校验:必须配置供应商且选择模型后才能发送 if (!activeProvider) { - messageApi.warning('请先在 AI 设置中配置供应商'); + setComposerNotice(buildMissingProviderNotice()); return; } if (!activeProvider.model || !activeProvider.model.trim()) { - messageApi.warning('请先选择模型 ID(点击工具栏的模型下拉框选择)'); + setComposerNotice(buildMissingModelNotice()); return; } + setComposerNotice(null); toolCallRoundRef.current = 0; // 重置工具调用轮次计数 nudgeCountRef.current = 0; // 重置催促计数 @@ -1258,7 +1276,6 @@ SELECT * FROM users WHERE status = 1; return (
- {messageContextHolder}
{isResizing && panelRect.current && createPortal( @@ -1366,6 +1383,7 @@ SELECT * FROM users WHERE status = 1; activeProvider={activeProvider} dynamicModels={dynamicModels} loadingModels={loadingModels} + composerNotice={composerNotice} onModelChange={handleModelChange} onFetchModels={fetchDynamicModels} textareaRef={textareaRef} diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 8f6436c..829db1b 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -2,6 +2,23 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd'; import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons'; import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types'; +import { + getProviderFingerprint, + getProviderHostname, + matchQwenPresetKey, + QWEN_BAILIAN_ANTHROPIC_BASE_URL, + QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, + QWEN_CODING_PLAN_MODELS, + resolvePresetBaseURL, + resolvePresetModelSelection, +} from '../utils/aiProviderPresets'; +import { + PROVIDER_PRESET_CARD_BASE_STYLE, + PROVIDER_PRESET_CARD_CONTENT_STYLE, + PROVIDER_PRESET_CARD_DESCRIPTION_STYLE, + PROVIDER_PRESET_GRID_STYLE, + PROVIDER_PRESET_CARD_TITLE_STYLE, +} from '../utils/aiSettingsPresetLayout'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; @@ -28,7 +45,8 @@ interface ProviderPreset { const PROVIDER_PRESETS: ProviderPreset[] = [ { key: 'openai', label: 'OpenAI', icon: , desc: 'GPT-5.4 / 5.3 系列', color: '#10b981', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o', models: [] }, { key: 'deepseek', label: 'DeepSeek', icon: , desc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] }, - { key: 'qwen', label: '通义千问', icon: , desc: 'Qwen3.5 / Qwen3 系列', color: '#6366f1', backendType: 'openai', defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', defaultModel: 'qwen-max', models: [] }, + { key: 'qwen-bailian', label: '通义千问(百炼通用)', icon: , desc: '百炼 Anthropic 兼容 / 模型从远端拉取', color: '#6366f1', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, defaultModel: '', models: [] }, + { key: 'qwen-coding-plan', label: '通义千问(Coding Plan)', icon: , desc: 'Coding Plan 专属入口 / 使用官方支持模型清单', color: '#4f46e5', backendType: 'anthropic', defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, defaultModel: '', models: QWEN_CODING_PLAN_MODELS }, { key: 'zhipu', label: '智谱 GLM', icon: , desc: 'GLM-5 / GLM-5-Turbo', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-4', models: [] }, { key: 'moonshot', label: 'Kimi', icon: , desc: 'Kimi K2.5 (Anthropic 兼容)', color: '#0d9488', backendType: 'anthropic', defaultBaseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'moonshot-v1-8k', models: [] }, { key: 'anthropic', label: 'Claude', icon: , desc: 'Claude Opus/Sonnet', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-20241022', models: [] }, @@ -42,27 +60,11 @@ const PROVIDER_PRESETS: ProviderPreset[] = [ const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find(p => p.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1]; -const getProviderHostname = (raw?: string): string => { - if (!raw) return ''; - try { - return new URL(raw).hostname.toLowerCase(); - } catch { - return ''; - } -}; - -const getProviderFingerprint = (raw?: string): string => { - if (!raw) return ''; - try { - const url = new URL(raw); - const normalizedPath = url.pathname.replace(/\/+$/, '').toLowerCase(); - return `${url.hostname.toLowerCase()}${normalizedPath}`; - } catch { - return ''; - } -}; - const matchProviderPreset = (provider: Pick): ProviderPreset => { + const qwenPresetKey = matchQwenPresetKey(provider); + if (qwenPresetKey) { + return findPreset(qwenPresetKey); + } const fingerprint = getProviderFingerprint(provider.baseUrl); const exactPreset = PROVIDER_PRESETS.find(pr => pr.backendType === provider.type @@ -201,15 +203,23 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const Service = (window as any).go?.aiservice?.Service; // 构建 payload,处理 model/models 逻辑 - const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama'; const preset = findPreset(values.presetKey); - const resolvedModels = isCustomLike ? (values.models || []) : preset.models; - const fallbackModel = resolvedModels.length > 0 ? resolvedModels[0] : ''; - const finalModel = isCustomLike ? fallbackModel : (values.model || fallbackModel); + const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama'; + const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({ + presetKey: values.presetKey, + presetDefaultModel: preset.defaultModel, + presetModels: preset.models, + valuesModel: values.model, + customModels: values.models, + }); // 内置供应商自动使用 preset label 作为名称 const finalName = isCustomLike ? (values.name || preset.label) : preset.label; - const finalBaseUrl = values.baseUrl || preset.defaultBaseUrl; + const finalBaseUrl = resolvePresetBaseURL({ + presetKey: values.presetKey, + presetDefaultBaseUrl: preset.defaultBaseUrl, + valuesBaseUrl: values.baseUrl, + }); const payload = { ...editingProvider, @@ -262,7 +272,11 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo setTestStatus('idle'); const Service = (window as any).go?.aiservice?.Service; const preset = findPreset(values.presetKey || 'openai'); - const finalBaseUrl = values.baseUrl || preset.defaultBaseUrl; + const finalBaseUrl = resolvePresetBaseURL({ + presetKey: values.presetKey || 'openai', + presetDefaultBaseUrl: preset.defaultBaseUrl, + valuesBaseUrl: values.baseUrl, + }); const res = await Service?.AITestProvider?.({ ...values, baseUrl: finalBaseUrl, maxTokens: Number(values.maxTokens) || 4096, temperature: Number(values.temperature) ?? 0.7 }); if (res?.success) { setTestStatus('success'); void messageApi.success('连接成功'); } else { setTestStatus('error'); void messageApi.error(`测试失败: ${res?.message || '未知错误'}`); } @@ -329,7 +343,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo
{matchedPreset.label} · - {p.model} + {p.model || '未选择模型'}
@@ -375,25 +389,24 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo 服务类型
-
+
{PROVIDER_PRESETS.map(pt => (
{ form.setFieldValue('presetKey', pt.key); handlePresetChange(pt.key); }} style={{ - padding: '12px 14px', borderRadius: 12, cursor: 'pointer', transition: 'all 0.2s ease', + ...PROVIDER_PRESET_CARD_BASE_STYLE, border: `1.5px solid ${presetKeyFromForm === pt.key ? overlayTheme.selectedText : 'transparent'}`, background: presetKeyFromForm === pt.key ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'), boxShadow: presetKeyFromForm === pt.key ? 'none' : (darkMode ? 'inset 0 0 0 1px rgba(255,255,255,0.028)' : 'inset 0 0 0 1px rgba(16,24,40,0.03)'), - display: 'flex', alignItems: 'flex-start', gap: 10, }}>
{pt.icon}
-
-
{pt.label}
-
{pt.desc}
+
+
{pt.label}
+
{pt.desc}
))} diff --git a/frontend/src/components/ai/AIChatInput.notice.test.tsx b/frontend/src/components/ai/AIChatInput.notice.test.tsx new file mode 100644 index 0000000..f8b75e6 --- /dev/null +++ b/frontend/src/components/ai/AIChatInput.notice.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { AIChatInput } from './AIChatInput'; +import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; + +vi.mock('../../store', () => ({ + useStore: (selector: (state: any) => any) => selector({ + aiContexts: {}, + addAIContext: vi.fn(), + removeAIContext: vi.fn(), + }), +})); + +vi.mock('../../../wailsjs/go/app/App', () => ({ + DBGetTables: vi.fn(), + DBShowCreateTable: vi.fn(), + DBGetDatabases: vi.fn(), +})); + +describe('AIChatInput notice layout', () => { + it('renders the composer notice above the input editor', () => { + const markup = renderToStaticMarkup( + {}} + draftImages={[]} + setDraftImages={() => {}} + sending={false} + onSend={() => {}} + onStop={() => {}} + handleKeyDown={() => {}} + activeConnName="" + activeContext={null} + activeProvider={{ model: '', models: [] }} + dynamicModels={[]} + loadingModels={false} + composerNotice={{ + tone: 'error', + title: '模型列表加载失败', + description: '请检查供应商入口和 API Key。', + }} + onModelChange={() => {}} + onFetchModels={() => {}} + textareaRef={React.createRef()} + darkMode={false} + textColor="#162033" + mutedColor="rgba(16,24,40,0.55)" + overlayTheme={buildOverlayWorkbenchTheme(false)} + /> + ); + + const noticeIndex = markup.indexOf('data-ai-chat-composer-notice="true"'); + const inputIndex = markup.indexOf('data-ai-chat-composer-input="true"'); + + expect(noticeIndex).toBeGreaterThanOrEqual(0); + expect(inputIndex).toBeGreaterThanOrEqual(0); + expect(noticeIndex).toBeLessThan(inputIndex); + }); +}); diff --git a/frontend/src/components/ai/AIChatInput.tsx b/frontend/src/components/ai/AIChatInput.tsx index 3dbd216..37ab15e 100644 --- a/frontend/src/components/ai/AIChatInput.tsx +++ b/frontend/src/components/ai/AIChatInput.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { Input, Select, AutoComplete, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd'; -import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined } from '@ant-design/icons'; +import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons'; import { useStore } from '../../store'; import { DBGetTables, DBShowCreateTable, DBGetDatabases } from '../../../wailsjs/go/app/App'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; +import type { AIComposerNotice } from '../../utils/aiComposerNotice'; interface AIChatInputProps { input: string; @@ -19,6 +20,7 @@ interface AIChatInputProps { activeProvider: any; dynamicModels: string[]; loadingModels: boolean; + composerNotice?: AIComposerNotice | null; onModelChange: (val: string) => void; onFetchModels: () => void; textareaRef: React.RefObject; @@ -33,6 +35,7 @@ interface AIChatInputProps { export const AIChatInput: React.FC = ({ input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown, activeConnName, activeContext, activeProvider, dynamicModels, loadingModels, + composerNotice, onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme, contextUsageChars, maxContextChars }) => { @@ -67,6 +70,33 @@ export const AIChatInput: React.FC = ({ const filteredTables = contextTables.filter(t => t.name.toLowerCase().includes(searchText.toLowerCase())); const [contextExpanded, setContextExpanded] = React.useState(false); + const composerNoticePalette = React.useMemo(() => { + if (composerNotice?.tone === 'error') { + return darkMode + ? { + background: 'rgba(255,120,117,0.12)', + borderColor: 'rgba(255,120,117,0.24)', + iconColor: '#ff7875', + } + : { + background: 'rgba(255,77,79,0.08)', + borderColor: 'rgba(255,77,79,0.16)', + iconColor: '#ff4d4f', + }; + } + + return darkMode + ? { + background: 'rgba(250,173,20,0.12)', + borderColor: 'rgba(250,173,20,0.22)', + iconColor: '#ffd666', + } + : { + background: 'rgba(250,173,20,0.08)', + borderColor: 'rgba(250,173,20,0.18)', + iconColor: '#d48806', + }; + }, [composerNotice, darkMode]); // Slash commands const [showSlashMenu, setShowSlashMenu] = React.useState(false); @@ -258,7 +288,31 @@ export const AIChatInput: React.FC = ({
))}
-
+ {composerNotice && ( +
+ +
+
+ {composerNotice.title} +
+
+ {composerNotice.description} +
+
+
+ )} +
{showSlashMenu && filteredSlashCmds.length > 0 && (
= ({ variant="filled" value={activeProvider.model || undefined} onChange={onModelChange} - onDropdownVisibleChange={(open) => { if (open && dynamicModels.length === 0) onFetchModels(); }} + onDropdownVisibleChange={(open) => { + if (open && dynamicModels.length === 0 && (activeProvider.models || []).length === 0) { + onFetchModels(); + } + }} loading={loadingModels} options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))} style={{ width: 130, fontSize: 11, background: 'transparent' }} diff --git a/frontend/src/utils/aiComposerNotice.test.ts b/frontend/src/utils/aiComposerNotice.test.ts new file mode 100644 index 0000000..33404f8 --- /dev/null +++ b/frontend/src/utils/aiComposerNotice.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildModelFetchFailedNotice, + buildMissingModelNotice, + buildMissingProviderNotice, +} from './aiComposerNotice'; + +describe('ai composer notice helpers', () => { + it('builds a compact notice for missing provider', () => { + expect(buildMissingProviderNotice()).toEqual({ + tone: 'warning', + title: '还没有可用供应商', + description: '先在 AI 设置里添加并启用一个模型供应商。', + }); + }); + + it('builds a compact notice for missing model selection', () => { + expect(buildMissingModelNotice()).toEqual({ + tone: 'warning', + title: '先选择一个模型', + description: '打开下方模型下拉并选择模型;如果列表为空,请检查供应商入口和 API Key。', + }); + }); + + it('builds a readable inline notice for model fetch failures', () => { + expect(buildModelFetchFailedNotice('当前接口未返回可用模型')).toEqual({ + tone: 'error', + title: '模型列表加载失败', + description: '当前接口未返回可用模型', + }); + }); +}); diff --git a/frontend/src/utils/aiComposerNotice.ts b/frontend/src/utils/aiComposerNotice.ts new file mode 100644 index 0000000..0034d9d --- /dev/null +++ b/frontend/src/utils/aiComposerNotice.ts @@ -0,0 +1,27 @@ +export type AIComposerNoticeTone = 'warning' | 'error'; + +export interface AIComposerNotice { + tone: AIComposerNoticeTone; + title: string; + description: string; +} + +const defaultModelFetchFailedDescription = '请检查供应商入口、API Key 或账号权限,然后重新打开模型下拉。'; + +export const buildMissingProviderNotice = (): AIComposerNotice => ({ + tone: 'warning', + title: '还没有可用供应商', + description: '先在 AI 设置里添加并启用一个模型供应商。', +}); + +export const buildMissingModelNotice = (): AIComposerNotice => ({ + tone: 'warning', + title: '先选择一个模型', + description: '打开下方模型下拉并选择模型;如果列表为空,请检查供应商入口和 API Key。', +}); + +export const buildModelFetchFailedNotice = (error?: string): AIComposerNotice => ({ + tone: 'error', + title: '模型列表加载失败', + description: String(error || '').trim() || defaultModelFetchFailedDescription, +}); diff --git a/frontend/src/utils/aiProviderPresets.test.ts b/frontend/src/utils/aiProviderPresets.test.ts new file mode 100644 index 0000000..addc6d6 --- /dev/null +++ b/frontend/src/utils/aiProviderPresets.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { + matchQwenPresetKey, + QWEN_BAILIAN_MODELS_BASE_URL, + QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, + QWEN_CODING_PLAN_MODELS, + resolvePresetBaseURL, + resolvePresetModelSelection, +} from './aiProviderPresets'; + +describe('ai provider preset helpers', () => { + it('maps legacy Bailian compatible-mode URL back to the Bailian preset', () => { + expect(matchQwenPresetKey({ + type: 'openai', + baseUrl: QWEN_BAILIAN_MODELS_BASE_URL, + })).toBe('qwen-bailian'); + }); + + it('maps Coding Plan anthropic URL to the dedicated Coding Plan preset', () => { + expect(matchQwenPresetKey({ + type: 'anthropic', + baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, + })).toBe('qwen-coding-plan'); + }); + + it('keeps built-in preset model empty when the preset intentionally requires an explicit selection', () => { + expect(resolvePresetModelSelection({ + presetKey: 'qwen-coding-plan', + presetDefaultModel: '', + presetModels: QWEN_CODING_PLAN_MODELS, + valuesModel: '', + customModels: [], + })).toEqual({ + model: '', + models: QWEN_CODING_PLAN_MODELS, + }); + }); + + it('still falls back to the first configured model for custom-like presets', () => { + expect(resolvePresetModelSelection({ + presetKey: 'custom', + presetDefaultModel: '', + presetModels: [], + valuesModel: '', + customModels: ['foo-model', 'bar-model'], + })).toEqual({ + model: 'foo-model', + models: ['foo-model', 'bar-model'], + }); + }); + + it('forces built-in presets back to their standard base URL when saving or testing', () => { + expect(resolvePresetBaseURL({ + presetKey: 'qwen-bailian', + presetDefaultBaseUrl: 'https://dashscope.aliyuncs.com/apps/anthropic', + valuesBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + })).toBe('https://dashscope.aliyuncs.com/apps/anthropic'); + }); + + it('keeps the user-entered base URL for custom-like presets', () => { + expect(resolvePresetBaseURL({ + presetKey: 'custom', + presetDefaultBaseUrl: '', + valuesBaseUrl: 'https://example-proxy.internal/v1', + })).toBe('https://example-proxy.internal/v1'); + }); +}); diff --git a/frontend/src/utils/aiProviderPresets.ts b/frontend/src/utils/aiProviderPresets.ts new file mode 100644 index 0000000..c4b61a0 --- /dev/null +++ b/frontend/src/utils/aiProviderPresets.ts @@ -0,0 +1,105 @@ +import type { AIProviderConfig } from '../types'; + +export const LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; +export const LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL = 'https://coding.dashscope.aliyuncs.com/v1'; +export const QWEN_BAILIAN_ANTHROPIC_BASE_URL = 'https://dashscope.aliyuncs.com/apps/anthropic'; +export const QWEN_CODING_PLAN_ANTHROPIC_BASE_URL = 'https://coding.dashscope.aliyuncs.com/apps/anthropic'; +export const QWEN_BAILIAN_MODELS_BASE_URL = LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL; + +export const QWEN_CODING_PLAN_MODELS = [ + 'qwen3-coder-plus', + 'qwen3-coder-480b-a35b-instruct', + 'qwen3-coder-30b-a3b-instruct', + 'qwen3-coder-flash', + 'qwen-plus', + 'qwen-turbo', +]; + +const CUSTOM_LIKE_PRESET_KEYS = new Set(['custom', 'ollama']); + +export interface ResolvePresetModelSelectionInput { + presetKey: string; + presetDefaultModel: string; + presetModels: string[]; + valuesModel?: string; + customModels?: string[]; +} + +export interface ResolvePresetModelSelectionResult { + model: string; + models: string[]; +} + +export interface ResolvePresetBaseURLInput { + presetKey: string; + presetDefaultBaseUrl: string; + valuesBaseUrl?: string; +} + +export const getProviderHostname = (raw?: string): string => { + if (!raw) return ''; + try { + return new URL(raw).hostname.toLowerCase(); + } catch { + return ''; + } +}; + +export const getProviderFingerprint = (raw?: string): string => { + if (!raw) return ''; + try { + const url = new URL(raw); + const normalizedPath = url.pathname.replace(/\/+$/, '').toLowerCase(); + return `${url.hostname.toLowerCase()}${normalizedPath}`; + } catch { + return ''; + } +}; + +export const matchQwenPresetKey = (provider: Pick): string | null => { + const fingerprint = getProviderFingerprint(provider.baseUrl); + const bailianFingerprints = new Set([ + getProviderFingerprint(LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL), + getProviderFingerprint(QWEN_BAILIAN_ANTHROPIC_BASE_URL), + ]); + if (fingerprint !== '' && bailianFingerprints.has(fingerprint)) { + return 'qwen-bailian'; + } + + const codingPlanFingerprints = new Set([ + getProviderFingerprint(LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL), + getProviderFingerprint(QWEN_CODING_PLAN_ANTHROPIC_BASE_URL), + ]); + if (fingerprint !== '' && codingPlanFingerprints.has(fingerprint)) { + return 'qwen-coding-plan'; + } + + return null; +}; + +export const resolvePresetModelSelection = ({ + presetKey, + presetDefaultModel, + presetModels, + valuesModel, + customModels, +}: ResolvePresetModelSelectionInput): ResolvePresetModelSelectionResult => { + const isCustomLike = CUSTOM_LIKE_PRESET_KEYS.has(presetKey); + const resolvedModels = isCustomLike ? (customModels || []) : presetModels; + const fallbackModel = resolvedModels.length > 0 ? resolvedModels[0] : ''; + return { + models: resolvedModels, + model: isCustomLike ? (valuesModel || fallbackModel) : (valuesModel || presetDefaultModel), + }; +}; + +export const resolvePresetBaseURL = ({ + presetKey, + presetDefaultBaseUrl, + valuesBaseUrl, +}: ResolvePresetBaseURLInput): string => { + if (CUSTOM_LIKE_PRESET_KEYS.has(presetKey)) { + return valuesBaseUrl || presetDefaultBaseUrl; + } + return presetDefaultBaseUrl; +}; diff --git a/frontend/src/utils/aiSettingsPresetLayout.test.ts b/frontend/src/utils/aiSettingsPresetLayout.test.ts new file mode 100644 index 0000000..a4cd942 --- /dev/null +++ b/frontend/src/utils/aiSettingsPresetLayout.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { + PROVIDER_PRESET_CARD_BASE_STYLE, + PROVIDER_PRESET_CARD_CONTENT_STYLE, + PROVIDER_PRESET_CARD_DESCRIPTION_STYLE, + PROVIDER_PRESET_GRID_STYLE, + PROVIDER_PRESET_CARD_TITLE_STYLE, +} from './aiSettingsPresetLayout'; + +describe('ai settings preset layout', () => { + it('uses a fixed grid auto row height so provider bubbles stay visually consistent across rows', () => { + expect(PROVIDER_PRESET_GRID_STYLE).toMatchObject({ + display: 'grid', + gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', + gap: 6, + gridAutoRows: '96px', + alignItems: 'stretch', + }); + }); + + it('stretches each provider card to fill the row height', () => { + expect(PROVIDER_PRESET_CARD_BASE_STYLE).toMatchObject({ + display: 'flex', + alignItems: 'flex-start', + gap: 10, + height: '100%', + minHeight: '96px', + overflow: 'hidden', + }); + }); + + it('keeps the text column compact instead of pinning the description to the bottom', () => { + expect(PROVIDER_PRESET_CARD_CONTENT_STYLE).toMatchObject({ + minWidth: 0, + flex: 1, + display: 'flex', + flexDirection: 'column', + }); + + expect(PROVIDER_PRESET_CARD_DESCRIPTION_STYLE).toMatchObject({ + marginTop: 4, + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + }); + + expect(PROVIDER_PRESET_CARD_TITLE_STYLE).toMatchObject({ + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + }); + }); +}); diff --git a/frontend/src/utils/aiSettingsPresetLayout.ts b/frontend/src/utils/aiSettingsPresetLayout.ts new file mode 100644 index 0000000..52a1b37 --- /dev/null +++ b/frontend/src/utils/aiSettingsPresetLayout.ts @@ -0,0 +1,47 @@ +import type { CSSProperties } from 'react'; + +export const PROVIDER_PRESET_CARD_HEIGHT = 96; + +export const PROVIDER_PRESET_GRID_STYLE: CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', + gap: 6, + gridAutoRows: `${PROVIDER_PRESET_CARD_HEIGHT}px`, + alignItems: 'stretch', +}; + +export const PROVIDER_PRESET_CARD_BASE_STYLE: CSSProperties = { + padding: '12px 14px', + borderRadius: 12, + cursor: 'pointer', + transition: 'all 0.2s ease', + display: 'flex', + alignItems: 'flex-start', + gap: 10, + height: '100%', + minHeight: `${PROVIDER_PRESET_CARD_HEIGHT}px`, + boxSizing: 'border-box', + overflow: 'hidden', +}; + +export const PROVIDER_PRESET_CARD_CONTENT_STYLE: CSSProperties = { + minWidth: 0, + flex: 1, + display: 'flex', + flexDirection: 'column', +}; + +export const PROVIDER_PRESET_CARD_DESCRIPTION_STYLE: CSSProperties = { + marginTop: 4, + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', +}; + +export const PROVIDER_PRESET_CARD_TITLE_STYLE: CSSProperties = { + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', +}; diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json old mode 100755 new mode 100644 diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts old mode 100755 new mode 100644 diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js old mode 100755 new mode 100644 diff --git a/internal/ai/provider/anthropic.go b/internal/ai/provider/anthropic.go index 1733222..58b0c28 100644 --- a/internal/ai/provider/anthropic.go +++ b/internal/ai/provider/anthropic.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "GoNavi-Wails/internal/ai" @@ -32,6 +33,25 @@ func normalizeAnthropicMessagesURL(baseURL string) string { return url + "/v1/messages" } +func IsDashScopeAnthropicCompatibleBaseURL(baseURL string) bool { + parsed, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil { + return false + } + host := strings.ToLower(parsed.Hostname()) + return host == "dashscope.aliyuncs.com" || host == "coding.dashscope.aliyuncs.com" +} + +func ApplyAnthropicAuthHeaders(headers http.Header, baseURL string, apiKey string) { + headers.Set("x-api-key", apiKey) + if IsDashScopeAnthropicCompatibleBaseURL(baseURL) { + headers.Set("Authorization", "Bearer "+apiKey) + headers.Del("anthropic-version") + return + } + headers.Set("anthropic-version", anthropicAPIVersion) +} + // AnthropicProvider 实现 Anthropic Claude API 的 Provider type AnthropicProvider struct { config ai.ProviderConfig @@ -446,8 +466,7 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, body interface{}) (io } httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("x-api-key", p.config.APIKey) - httpReq.Header.Set("anthropic-version", anthropicAPIVersion) + ApplyAnthropicAuthHeaders(httpReq.Header, p.baseURL, p.config.APIKey) if strings.Contains(string(jsonBody), `"stream":true`) || strings.Contains(string(jsonBody), `"stream": true`) { httpReq.Header.Set("Accept", "text/event-stream") diff --git a/internal/ai/provider/anthropic_test.go b/internal/ai/provider/anthropic_test.go index 3c119f2..298329b 100644 --- a/internal/ai/provider/anthropic_test.go +++ b/internal/ai/provider/anthropic_test.go @@ -1,6 +1,9 @@ package provider -import "testing" +import ( + "net/http" + "testing" +) func TestNormalizeAnthropicMessagesURL_AppendsMessagesSuffix(t *testing.T) { url := normalizeAnthropicMessagesURL("https://api.anthropic.com") @@ -22,3 +25,33 @@ func TestNormalizeAnthropicMessagesURL_PreservesExplicitMessagesPath(t *testing. t.Fatalf("expected explicit messages path to be preserved, got %q", url) } } + +func TestApplyAnthropicAuthHeaders_UsesOfficialAnthropicHeadersForAnthropicAPI(t *testing.T) { + headers := http.Header{} + ApplyAnthropicAuthHeaders(headers, "https://api.anthropic.com", "sk-test") + + if got := headers.Get("x-api-key"); got != "sk-test" { + t.Fatalf("expected x-api-key header, got %q", got) + } + if got := headers.Get("anthropic-version"); got != anthropicAPIVersion { + t.Fatalf("expected anthropic-version header, got %q", got) + } + if got := headers.Get("Authorization"); got != "" { + t.Fatalf("expected no authorization header for official anthropic, got %q", got) + } +} + +func TestApplyAnthropicAuthHeaders_UsesBearerForDashScopeCompatibleAnthropic(t *testing.T) { + headers := http.Header{} + ApplyAnthropicAuthHeaders(headers, "https://coding.dashscope.aliyuncs.com/apps/anthropic", "sk-sp-test") + + if got := headers.Get("Authorization"); got != "Bearer sk-sp-test" { + t.Fatalf("expected bearer authorization header, got %q", got) + } + if got := headers.Get("x-api-key"); got != "sk-sp-test" { + t.Fatalf("expected x-api-key header, got %q", got) + } + if got := headers.Get("anthropic-version"); got != "" { + t.Fatalf("expected no anthropic-version header for DashScope, got %q", got) + } +} diff --git a/internal/ai/service/service.go b/internal/ai/service/service.go index 1982cd2..285d61e 100644 --- a/internal/ai/service/service.go +++ b/internal/ai/service/service.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -45,6 +46,24 @@ var miniMaxAnthropicModels = []string{ "MiniMax-M2", } +var dashScopeCodingPlanModels = []string{ + "qwen3-coder-plus", + "qwen3-coder-480b-a35b-instruct", + "qwen3-coder-30b-a3b-instruct", + "qwen3-coder-flash", + "qwen-plus", + "qwen-turbo", +} + +var volcengineCodingPlanAllowedModelFamilies = []string{ + "doubao-seed-code", + "glm-4.7", + "deepseek-v3.2", + "kimi-k2", +} + +const volcengineCodingPlanEmptyModelsError = `当前接口未返回可用的火山 Coding Plan 模型,请检查账号权限或切换到"火山方舟"供应商` + // NewService 创建 AI Service 实例 func NewService() *Service { return &Service{ @@ -224,18 +243,86 @@ func isMoonshotAnthropicProvider(config ai.ProviderConfig) bool { return strings.Contains(baseURL, "api.moonshot.cn") } +func parseProviderBaseURL(raw string) (string, string) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return "", "" + } + return strings.ToLower(parsed.Hostname()), strings.TrimRight(strings.ToLower(parsed.Path), "/") +} + +func isDashScopeBailianAnthropicProvider(config ai.ProviderConfig) bool { + if normalizedProviderType(config) != "anthropic" { + return false + } + host, path := parseProviderBaseURL(config.BaseURL) + return host == "dashscope.aliyuncs.com" && strings.HasPrefix(path, "/apps/anthropic") +} + +func isDashScopeCodingPlanAnthropicProvider(config ai.ProviderConfig) bool { + if normalizedProviderType(config) != "anthropic" { + return false + } + host, path := parseProviderBaseURL(config.BaseURL) + return host == "coding.dashscope.aliyuncs.com" && strings.HasPrefix(path, "/apps/anthropic") +} + +func isVolcengineCodingPlanProvider(config ai.ProviderConfig) bool { + if normalizedProviderType(config) != "openai" { + return false + } + host, path := parseProviderBaseURL(provider.NormalizeOpenAICompatibleBaseURL(config.BaseURL)) + return host == "ark.cn-beijing.volces.com" && path == "/api/coding/v3" +} + +func filterVolcengineCodingPlanModels(models []string) []string { + filtered := make([]string, 0, len(models)) + for _, model := range models { + lowerModel := strings.ToLower(strings.TrimSpace(model)) + for _, family := range volcengineCodingPlanAllowedModelFamilies { + if strings.Contains(lowerModel, family) { + filtered = append(filtered, model) + break + } + } + } + return filtered +} + +func filterFetchedModelsForProvider(config ai.ProviderConfig, models []string) ([]string, error) { + if !isVolcengineCodingPlanProvider(config) { + return models, nil + } + filtered := filterVolcengineCodingPlanModels(models) + if len(filtered) == 0 { + return nil, fmt.Errorf(volcengineCodingPlanEmptyModelsError) + } + return filtered, nil +} + func defaultStaticModelsForProvider(config ai.ProviderConfig) []string { if isMiniMaxAnthropicProvider(config) { return append([]string(nil), miniMaxAnthropicModels...) } + if isDashScopeCodingPlanAnthropicProvider(config) { + return append([]string(nil), dashScopeCodingPlanModels...) + } return nil } func normalizeProviderConfig(config ai.ProviderConfig) ai.ProviderConfig { - staticModels := defaultStaticModelsForProvider(config) - if len(staticModels) > 0 && len(config.Models) == 0 { - config.Models = staticModels + switch { + case isDashScopeBailianAnthropicProvider(config): + config.Models = nil + case isDashScopeCodingPlanAnthropicProvider(config): + config.Models = append([]string(nil), dashScopeCodingPlanModels...) + default: + staticModels := defaultStaticModelsForProvider(config) + if len(staticModels) > 0 && len(config.Models) == 0 { + config.Models = staticModels + } } + model := strings.TrimSpace(config.Model) if isMiniMaxAnthropicProvider(config) && (model == "" || strings.HasPrefix(strings.ToLower(model), "minimax-text-")) { config.Model = miniMaxAnthropicModels[0] @@ -253,6 +340,9 @@ func resolveModelsURL(config ai.ProviderConfig) string { if isMoonshotAnthropicProvider(config) { return "https://api.moonshot.cn/v1/models" } + if isDashScopeBailianAnthropicProvider(config) { + return "https://dashscope.aliyuncs.com/compatible-mode/v1/models" + } if baseURL == "" { baseURL = "https://api.anthropic.com" } @@ -282,9 +372,11 @@ func newModelsRequest(config ai.ProviderConfig) (*http.Request, error) { switch normalizedProviderType(config) { case "anthropic": - req.Header.Set("x-api-key", config.APIKey) - req.Header.Set("anthropic-version", "2023-06-01") - req.Header.Set("Authorization", "Bearer "+config.APIKey) + if isDashScopeBailianAnthropicProvider(config) { + req.Header.Set("Authorization", "Bearer "+config.APIKey) + } else { + provider.ApplyAnthropicAuthHeaders(req.Header, config.BaseURL, config.APIKey) + } case "gemini": // Gemini 使用 query string 传递 key,无需额外鉴权头 default: @@ -314,33 +406,36 @@ func resolveAnthropicMessagesURL(baseURL string) string { func newProviderHealthCheckRequest(config ai.ProviderConfig) (*http.Request, error) { config = normalizeProviderConfig(config) - if isMiniMaxAnthropicProvider(config) { - body := map[string]interface{}{ - "model": config.Model, - "max_tokens": 1, - "messages": []map[string]string{ - {"role": "user", "content": "ping"}, - }, - } - bodyBytes, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("序列化请求失败: %w", err) - } - req, err := http.NewRequest("POST", resolveAnthropicMessagesURL(config.BaseURL), strings.NewReader(string(bodyBytes))) - if err != nil { - return nil, fmt.Errorf("创建请求失败: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("x-api-key", config.APIKey) - req.Header.Set("anthropic-version", "2023-06-01") - for k, v := range config.Headers { - req.Header.Set(k, v) - } - return req, nil + if isMiniMaxAnthropicProvider(config) || isDashScopeBailianAnthropicProvider(config) || isDashScopeCodingPlanAnthropicProvider(config) { + return newAnthropicMessagesHealthCheckRequest(config) } return newModelsRequest(config) } +func newAnthropicMessagesHealthCheckRequest(config ai.ProviderConfig) (*http.Request, error) { + body := map[string]interface{}{ + "model": config.Model, + "max_tokens": 1, + "messages": []map[string]string{ + {"role": "user", "content": "ping"}, + }, + } + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("序列化请求失败: %w", err) + } + req, err := http.NewRequest("POST", resolveAnthropicMessagesURL(config.BaseURL), strings.NewReader(string(bodyBytes))) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + req.Header.Set("Content-Type", "application/json") + provider.ApplyAnthropicAuthHeaders(req.Header, config.BaseURL, config.APIKey) + for k, v := range config.Headers { + req.Header.Set(k, v) + } + return req, nil +} + // AISetActiveProvider 设置活动 Provider func (s *Service) AISetActiveProvider(id string) { s.mu.Lock() @@ -379,7 +474,7 @@ func (s *Service) AIListModels() map[string]interface{} { return map[string]interface{}{"success": false, "models": []string{}, "error": "未找到活跃 Provider"} } - models, err := fetchModels(config) + models, err := fetchModelsFunc(config) if err != nil { // 回退到配置中的静态模型列表 if len(config.Models) > 0 { @@ -388,10 +483,17 @@ func (s *Service) AIListModels() map[string]interface{} { return map[string]interface{}{"success": false, "models": []string{}, "error": err.Error()} } + models, err = filterFetchedModelsForProvider(config, models) + if err != nil { + return map[string]interface{}{"success": false, "models": []string{}, "error": err.Error()} + } + return map[string]interface{}{"success": true, "models": models, "source": "api"} } // fetchModels 从供应商 API 获取可用模型列表 +var fetchModelsFunc = fetchModels + func fetchModels(config ai.ProviderConfig) ([]string, error) { providerType := normalizedProviderType(config) if staticModels := defaultStaticModelsForProvider(config); len(staticModels) > 0 { diff --git a/internal/ai/service/service_qwen_test.go b/internal/ai/service/service_qwen_test.go new file mode 100644 index 0000000..8a364d5 --- /dev/null +++ b/internal/ai/service/service_qwen_test.go @@ -0,0 +1,85 @@ +package aiservice + +import ( + "reflect" + "testing" + + "GoNavi-Wails/internal/ai" +) + +func TestDefaultStaticModelsForProvider_DoesNotReturnBailianStaticModels(t *testing.T) { + models := defaultStaticModelsForProvider(ai.ProviderConfig{ + Type: "anthropic", + BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic", + }) + if len(models) != 0 { + t.Fatalf("expected Bailian provider to rely on remote model list, got %v", models) + } +} + +func TestDefaultStaticModelsForProvider_ReturnsDashScopeCodingPlanModels(t *testing.T) { + models := defaultStaticModelsForProvider(ai.ProviderConfig{ + Type: "anthropic", + BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + }) + expected := []string{ + "qwen3-coder-plus", + "qwen3-coder-480b-a35b-instruct", + "qwen3-coder-30b-a3b-instruct", + "qwen3-coder-flash", + "qwen-plus", + "qwen-turbo", + } + if !reflect.DeepEqual(models, expected) { + t.Fatalf("expected Coding Plan static models %v, got %v", expected, models) + } +} + +func TestNormalizeProviderConfig_DoesNotForceModelForDashScopeProviders(t *testing.T) { + bailian := normalizeProviderConfig(ai.ProviderConfig{ + Type: "anthropic", + BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic", + }) + if bailian.Model != "" { + t.Fatalf("expected Bailian model to remain empty until explicit selection, got %q", bailian.Model) + } + + codingPlan := normalizeProviderConfig(ai.ProviderConfig{ + Type: "anthropic", + BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + }) + if codingPlan.Model != "" { + t.Fatalf("expected Coding Plan model to remain empty until explicit selection, got %q", codingPlan.Model) + } + if len(codingPlan.Models) == 0 { + t.Fatal("expected Coding Plan provider to expose official supported models") + } +} + +func TestResolveModelsURL_UsesDashScopeCompatibleModelsEndpointForBailianAnthropic(t *testing.T) { + url := resolveModelsURL(ai.ProviderConfig{ + Type: "anthropic", + BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic", + }) + if url != "https://dashscope.aliyuncs.com/compatible-mode/v1/models" { + t.Fatalf("expected Bailian models endpoint, got %q", url) + } +} + +func TestNewProviderHealthCheckRequest_UsesMessagesEndpointForDashScopeCodingPlanAnthropic(t *testing.T) { + req, err := newProviderHealthCheckRequest(ai.ProviderConfig{ + Type: "anthropic", + BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + Model: "qwen3-coder-plus", + APIKey: "sk-test", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Method != "POST" { + t.Fatalf("expected POST request, got %s", req.Method) + } + if req.URL.String() != "https://coding.dashscope.aliyuncs.com/apps/anthropic/v1/messages" { + t.Fatalf("expected Coding Plan messages endpoint, got %q", req.URL.String()) + } +} diff --git a/internal/ai/service/service_test.go b/internal/ai/service/service_test.go index 52e90d7..0bb6159 100644 --- a/internal/ai/service/service_test.go +++ b/internal/ai/service/service_test.go @@ -93,6 +93,16 @@ func TestDefaultStaticModelsForProvider_ReturnsMiniMaxAnthropicModels(t *testing } } +func TestDefaultStaticModelsForProvider_DoesNotReturnDashScopeBailianStaticModels(t *testing.T) { + models := defaultStaticModelsForProvider(ai.ProviderConfig{ + Type: "anthropic", + BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic", + }) + if len(models) != 0 { + t.Fatalf("expected Bailian provider to fetch models remotely, got %v", models) + } +} + func TestNewProviderHealthCheckRequest_UsesMessagesEndpointForMiniMaxAnthropic(t *testing.T) { req, err := newProviderHealthCheckRequest(ai.ProviderConfig{ Type: "anthropic", @@ -113,3 +123,30 @@ func TestNewProviderHealthCheckRequest_UsesMessagesEndpointForMiniMaxAnthropic(t t.Fatalf("expected x-api-key header to be set, got %q", got) } } + +func TestNewProviderHealthCheckRequest_UsesMessagesEndpointForDashScopeAnthropic(t *testing.T) { + req, err := newProviderHealthCheckRequest(ai.ProviderConfig{ + Type: "anthropic", + BaseURL: "https://dashscope.aliyuncs.com/apps/anthropic", + Model: "qwen3.5-plus", + APIKey: "sk-test", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Method != "POST" { + t.Fatalf("expected POST request, got %s", req.Method) + } + if req.URL.String() != "https://dashscope.aliyuncs.com/apps/anthropic/v1/messages" { + t.Fatalf("expected DashScope messages endpoint, got %q", req.URL.String()) + } + if got := req.Header.Get("x-api-key"); got != "sk-test" { + t.Fatalf("expected x-api-key header to be set, got %q", got) + } + if got := req.Header.Get("Authorization"); got != "Bearer sk-test" { + t.Fatalf("expected bearer authorization header, got %q", got) + } + if got := req.Header.Get("anthropic-version"); got != "" { + t.Fatalf("expected no anthropic-version header for DashScope, got %q", got) + } +} diff --git a/internal/ai/service/service_volcengine_test.go b/internal/ai/service/service_volcengine_test.go new file mode 100644 index 0000000..6210861 --- /dev/null +++ b/internal/ai/service/service_volcengine_test.go @@ -0,0 +1,89 @@ +package aiservice + +import ( + "reflect" + "strings" + "testing" + + "GoNavi-Wails/internal/ai" +) + +func TestIsVolcengineCodingPlanProvider_MatchesCodingPlanBaseURL(t *testing.T) { + if !isVolcengineCodingPlanProvider(ai.ProviderConfig{ + Type: "openai", + BaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3", + }) { + t.Fatal("expected volcengine coding plan provider to be detected") + } +} + +func TestFilterVolcengineCodingPlanModels_KeepsOnlySupportedFamilies(t *testing.T) { + filtered := filterVolcengineCodingPlanModels([]string{ + "qwen3-14b-20250429", + "wan2-1-14b-t2v-250225", + "doubao-seed-code-32k-250615", + "GLM-4.7", + "DeepSeek-V3.2", + "kimi-k2-turbo-preview", + }) + + expected := []string{ + "doubao-seed-code-32k-250615", + "GLM-4.7", + "DeepSeek-V3.2", + "kimi-k2-turbo-preview", + } + if !reflect.DeepEqual(filtered, expected) { + t.Fatalf("expected filtered models %v, got %v", expected, filtered) + } +} + +func TestFilterFetchedModelsForProvider_DoesNotFilterVolcengineArk(t *testing.T) { + rawModels := []string{ + "qwen3-14b-20250429", + "wan2-1-14b-t2v-250225", + } + + filtered, err := filterFetchedModelsForProvider(ai.ProviderConfig{ + Type: "openai", + BaseURL: "https://ark.cn-beijing.volces.com/api/v3", + }, rawModels) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(filtered, rawModels) { + t.Fatalf("expected ark models to stay untouched, got %v", filtered) + } +} + +func TestAIListModels_ReturnsFailureWhenVolcengineCodingPlanModelsAreFilteredEmpty(t *testing.T) { + originalFetchModelsFunc := fetchModelsFunc + fetchModelsFunc = func(config ai.ProviderConfig) ([]string, error) { + return []string{ + "qwen3-14b-20250429", + "wan2-1-14b-t2v-250225", + }, nil + } + defer func() { + fetchModelsFunc = originalFetchModelsFunc + }() + + service := NewService() + service.providers = []ai.ProviderConfig{ + { + ID: "provider-coding", + Type: "openai", + BaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3", + }, + } + service.activeProvider = "provider-coding" + + result := service.AIListModels() + if result["success"] != false { + t.Fatalf("expected AIListModels to fail, got %#v", result) + } + errorMessage, _ := result["error"].(string) + if !strings.Contains(errorMessage, "当前接口未返回可用的火山 Coding Plan 模型") { + t.Fatalf("expected specific coding plan error, got %q", errorMessage) + } +}