mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-09 16:09:41 +08:00
🐛 fix(ai/provider/chat-ui): 修复AI供应商兼容性并优化聊天提示交互
- 修复通义千问百炼 Anthropic 兼容鉴权头与健康检查请求 - 拆分通义千问百炼通用与 Coding Plan 双入口,调整预设回填与模型策略 - 修复火山 Coding Plan 模型列表过滤逻辑,避免混入无关模型 - 统一 OpenAI 兼容供应商路径与模型列表处理,补充相关服务层测试 - 优化 AI 设置供应商卡片布局,统一高度并收紧文本展示 - 将聊天区模型校验提示改为输入框上方的内联提示卡,补充前端回归测试
This commit is contained in:
33
frontend/src/utils/aiComposerNotice.test.ts
Normal file
33
frontend/src/utils/aiComposerNotice.test.ts
Normal file
@@ -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: '当前接口未返回可用模型',
|
||||
});
|
||||
});
|
||||
});
|
||||
27
frontend/src/utils/aiComposerNotice.ts
Normal file
27
frontend/src/utils/aiComposerNotice.ts
Normal file
@@ -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,
|
||||
});
|
||||
68
frontend/src/utils/aiProviderPresets.test.ts
Normal file
68
frontend/src/utils/aiProviderPresets.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
105
frontend/src/utils/aiProviderPresets.ts
Normal file
105
frontend/src/utils/aiProviderPresets.ts
Normal file
@@ -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<AIProviderConfig, 'type' | 'baseUrl'>): 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;
|
||||
};
|
||||
56
frontend/src/utils/aiSettingsPresetLayout.test.ts
Normal file
56
frontend/src/utils/aiSettingsPresetLayout.test.ts
Normal file
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
47
frontend/src/utils/aiSettingsPresetLayout.ts
Normal file
47
frontend/src/utils/aiSettingsPresetLayout.ts
Normal file
@@ -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',
|
||||
};
|
||||
Reference in New Issue
Block a user