🐛 fix(ai/provider/chat-ui): 修复AI供应商兼容性并优化聊天提示交互

- 修复通义千问百炼 Anthropic 兼容鉴权头与健康检查请求
- 拆分通义千问百炼通用与 Coding Plan 双入口,调整预设回填与模型策略
- 修复火山 Coding Plan 模型列表过滤逻辑,避免混入无关模型
- 统一 OpenAI 兼容供应商路径与模型列表处理,补充相关服务层测试
- 优化 AI 设置供应商卡片布局,统一高度并收紧文本展示
- 将聊天区模型校验提示改为输入框上方的内联提示卡,补充前端回归测试
This commit is contained in:
Syngnat
2026-03-27 14:29:03 +08:00
parent a5fdfefa2d
commit 4f74c44147
19 changed files with 931 additions and 80 deletions

View 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: '当前接口未返回可用模型',
});
});
});

View 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,
});

View 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');
});
});

View 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;
};

View 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',
});
});
});

View 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',
};