From 02ced6dd31dbd75a948478dd4f7d4d286dfa3a84 Mon Sep 17 00:00:00 2001 From: facquan <261348963@qq.com> Date: Thu, 19 Mar 2026 10:51:27 +0800 Subject: [PATCH 1/2] feat(chat): centralize LLM model configs and add provider model presets --- .../components/ChatPanel/index.module.scss | 32 +++ .../src/components/ChatPanel/index.tsx | 64 ++++- .../src/lib/__tests__/llmClient.test.ts | 33 ++- apps/webuiapps/src/lib/llmClient.ts | 164 +++++-------- apps/webuiapps/src/lib/llmModels.ts | 232 ++++++++++++++++++ 5 files changed, 406 insertions(+), 119 deletions(-) create mode 100644 apps/webuiapps/src/lib/llmModels.ts diff --git a/apps/webuiapps/src/components/ChatPanel/index.module.scss b/apps/webuiapps/src/components/ChatPanel/index.module.scss index 54feed3..694e53c 100644 --- a/apps/webuiapps/src/components/ChatPanel/index.module.scss +++ b/apps/webuiapps/src/components/ChatPanel/index.module.scss @@ -356,6 +356,38 @@ cursor: pointer; } +.modelSelectorWrapper { + display: flex; + align-items: center; + gap: 4px; + + .select { + flex: 1; + } + + .fieldInput { + flex: 1; + } +} + +.manualToggleBtn { + padding: 6px 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.6); + transition: all 0.2s; + + &:hover { + background: #282a2a; + color: rgba(255, 255, 255, 0.9); + } +} + .settingsActions { display: flex; gap: 8px; diff --git a/apps/webuiapps/src/components/ChatPanel/index.tsx b/apps/webuiapps/src/components/ChatPanel/index.tsx index 927fb8c..a65ab31 100644 --- a/apps/webuiapps/src/components/ChatPanel/index.tsx +++ b/apps/webuiapps/src/components/ChatPanel/index.tsx @@ -7,6 +7,8 @@ import { Maximize2, ChevronDown, ChevronRight, + Pencil, + List, } from 'lucide-react'; import { chat, @@ -18,6 +20,7 @@ import { type LLMProvider, type ChatMessage, } from '@/lib/llmClient'; +import { PROVIDER_MODELS } from '@/lib/llmModels'; import { loadImageGenConfig, loadImageGenConfigSync, @@ -1124,6 +1127,10 @@ const SettingsModal: React.FC<{ const [baseUrl, setBaseUrl] = useState(config?.baseUrl || getDefaultConfig('minimax').baseUrl); const [model, setModel] = useState(config?.model || getDefaultConfig('minimax').model); const [customHeaders, setCustomHeaders] = useState(config?.customHeaders || ''); + const [manualModelMode, setManualModelMode] = useState(false); + + const isPresetModel = PROVIDER_MODELS[provider]?.includes(model) ?? false; + const showDropdown = !manualModelMode && isPresetModel; // Image gen settings const [igProvider, setIgProvider] = useState( @@ -1143,6 +1150,12 @@ const SettingsModal: React.FC<{ const defaults = getDefaultConfig(p); setBaseUrl(defaults.baseUrl); setModel(defaults.model); + setManualModelMode(false); + }; + + const handleModelChange = (newModel: string) => { + setModel(newModel); + setManualModelMode(false); }; const handleIgProviderChange = (p: ImageGenProvider) => { @@ -1168,6 +1181,8 @@ const SettingsModal: React.FC<{ + + @@ -1193,11 +1208,50 @@ const SettingsModal: React.FC<{
- setModel(e.target.value)} - /> +
+ {showDropdown ? ( + <> + + + + ) : ( + <> + setModel(e.target.value)} + placeholder="e.g. gpt-4-turbo" + /> + {isPresetModel && ( + + )} + + )} +
diff --git a/apps/webuiapps/src/lib/__tests__/llmClient.test.ts b/apps/webuiapps/src/lib/__tests__/llmClient.test.ts index fbd4ca5..d92ee55 100644 --- a/apps/webuiapps/src/lib/__tests__/llmClient.test.ts +++ b/apps/webuiapps/src/lib/__tests__/llmClient.test.ts @@ -14,10 +14,10 @@ import { loadConfigSync, saveConfig, chat, - type LLMConfig, type ChatMessage, type ToolDef, } from '../llmClient'; +import type { LLMConfig } from '../llmModels'; // ─── Constants ──────────────────────────────────────────────────────────────── @@ -98,37 +98,50 @@ describe('getDefaultConfig()', () => { it('returns correct defaults for openai', () => { const cfg = getDefaultConfig('openai'); expect(cfg.provider).toBe('openai'); - expect(cfg.baseUrl).toBe('https://api.openai.com'); - expect(cfg.model).toBe('gpt-5.3-chat-latest'); + expect(cfg.baseUrl).toBe('https://api.openai.com/v1'); + expect(cfg.model).toBe('gpt-5.4'); expect('apiKey' in cfg).toBe(false); }); it('returns correct defaults for anthropic', () => { const cfg = getDefaultConfig('anthropic'); expect(cfg.provider).toBe('anthropic'); - expect(cfg.baseUrl).toBe('https://api.anthropic.com'); - expect(cfg.model).toBe('claude-opus-4-6'); + expect(cfg.baseUrl).toBe('https://api.anthropic.com/v1'); + expect(cfg.model).toBe('claude-sonnet-4-6'); }); it('returns correct defaults for deepseek', () => { const cfg = getDefaultConfig('deepseek'); expect(cfg.provider).toBe('deepseek'); - expect(cfg.baseUrl).toBe('https://api.deepseek.com'); + expect(cfg.baseUrl).toBe('https://api.deepseek.com/v1'); expect(cfg.model).toBe('deepseek-chat'); }); it('returns correct defaults for minimax', () => { const cfg = getDefaultConfig('minimax'); expect(cfg.provider).toBe('minimax'); - expect(cfg.baseUrl).toBe('https://api.minimax.io/anthropic'); + expect(cfg.baseUrl).toBe('https://api.minimax.io/anthropic/v1'); expect(cfg.model).toBe('MiniMax-M2.5'); }); - it('returns the same stable reference for the same provider', () => { - // getDefaultConfig returns a direct reference to the internal constant (by design) + it('returns correct defaults for z.ai', () => { + const cfg = getDefaultConfig('z.ai'); + expect(cfg.provider).toBe('z.ai'); + expect(cfg.baseUrl).toBe('https://api.z.ai/api/coding/paas/v4'); + expect(cfg.model).toBe('glm-5'); + }); + + it('returns correct defaults for kimi', () => { + const cfg = getDefaultConfig('kimi'); + expect(cfg.provider).toBe('kimi'); + expect(cfg.baseUrl).toBe('https://api.moonshot.cn/v1'); + expect(cfg.model).toBe('kimi-k2-5'); + }); + + it('returns consistent values for the same provider', () => { const a = getDefaultConfig('openai'); const b = getDefaultConfig('openai'); - expect(a).toBe(b); + expect(a).toStrictEqual(b); }); }); diff --git a/apps/webuiapps/src/lib/llmClient.ts b/apps/webuiapps/src/lib/llmClient.ts index af794af..29bbc3a 100644 --- a/apps/webuiapps/src/lib/llmClient.ts +++ b/apps/webuiapps/src/lib/llmClient.ts @@ -3,99 +3,20 @@ * Supports OpenAI / DeepSeek / Anthropic formats */ -export type LLMProvider = 'openai' | 'anthropic' | 'deepseek' | 'minimax'; +import { getDefaultProviderConfig, type LLMConfig, type LLMProvider } from './llmModels'; -export interface LLMConfig { - provider: LLMProvider; - apiKey: string; - baseUrl: string; - model: string; - /** Custom headers, format "Key: Value", one per line */ - customHeaders?: string; -} - -/** Parse custom headers string into Record, adding x-custom- prefix for proxy forwarding */ -function parseCustomHeaders(raw?: string): Record { - if (!raw) return {}; - const headers: Record = {}; - for (const line of raw.split('\n')) { - const trimmed = line.trim(); - if (!trimmed) continue; - const idx = trimmed.indexOf(':'); - if (idx > 0) { - const key = trimmed.slice(0, idx).trim().toLowerCase(); - const val = trimmed.slice(idx + 1).trim(); - headers[`x-custom-${key}`] = val; - } - } - return headers; -} - -export interface ChatMessage { - role: 'system' | 'user' | 'assistant' | 'tool'; - content: string; - tool_call_id?: string; - tool_calls?: ToolCall[]; -} - -export interface ToolCall { - id: string; - type: 'function'; - function: { - name: string; - arguments: string; - }; -} - -export interface ToolDef { - type: 'function'; - function: { - name: string; - description: string; - parameters: { - type: 'object'; - properties: Record; - required: string[]; - }; - }; -} - -interface LLMResponse { - content: string; - toolCalls: ToolCall[]; -} +export type { LLMConfig, LLMProvider }; import { logger } from './logger'; import { loadPersistedConfig, savePersistedConfig } from './configPersistence'; const CONFIG_KEY = 'webuiapps-llm-config'; -const DEFAULT_CONFIGS: Record> = { - openai: { provider: 'openai', baseUrl: 'https://api.openai.com', model: 'gpt-5.3-chat-latest' }, - deepseek: { provider: 'deepseek', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' }, - anthropic: { - provider: 'anthropic', - baseUrl: 'https://api.anthropic.com', - model: 'claude-opus-4-6', - }, - minimax: { - provider: 'minimax', - baseUrl: 'https://api.minimax.io/anthropic', - model: 'MiniMax-M2.5', - }, -}; - export function getDefaultConfig(provider: LLMProvider): Omit { - return DEFAULT_CONFIGS[provider]; + return getDefaultProviderConfig(provider); } -/** - * Load config — priority: local file (~/.openroom/config.json) > localStorage. - * Falls back gracefully if the dev server API is unavailable (e.g. production build). - * Handles both legacy flat format and new { llm, imageGen? } format. - */ export async function loadConfig(): Promise { - // 1. Try local file via dev-server API (handles legacy + new format) try { const persisted = await loadPersistedConfig(); if (persisted?.llm) { @@ -103,10 +24,9 @@ export async function loadConfig(): Promise { return persisted.llm; } } catch { - // API not available (production / network error) — fall through + // API not available (production / network error) } - // 2. Fall back to localStorage try { const raw = localStorage.getItem(CONFIG_KEY); return raw ? JSON.parse(raw) : null; @@ -115,28 +35,22 @@ export async function loadConfig(): Promise { } } -/** - * Save config — writes to both localStorage and local file (~/.openroom/config.json). - * Optionally accepts imageGenConfig to persist both atomically. - */ export async function saveConfig( config: LLMConfig, imageGenConfig?: import('./imageGenClient').ImageGenConfig | null, ): Promise { - // Always write localStorage (sync, instant) localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); - // Build persisted config — include imageGen if provided - const persisted: import('./configPersistence').PersistedConfig = { llm: config }; + const persisted: import('./configPersistence').PersistedConfig = { + llm: config, + }; if (imageGenConfig) { persisted.imageGen = imageGenConfig; } - // Best-effort write to local file await savePersistedConfig(persisted); } -/** Synchronous read from localStorage cache (use after loadConfig() has been awaited once). */ export function loadConfigSync(): LLMConfig | null { try { const raw = localStorage.getItem(CONFIG_KEY); @@ -146,9 +60,56 @@ export function loadConfigSync(): LLMConfig | null { } } -/** - * Call LLM API (non-streaming, simple version) - */ +export interface ChatMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string; + tool_call_id?: string; + tool_calls?: ToolCall[]; +} + +export interface ToolCall { + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; +} + +export interface ToolDef { + type: 'function'; + function: { + name: string; + description: string; + parameters: { + type: 'object'; + properties: Record; + required: string[]; + }; + }; +} + +interface LLMResponse { + content: string; + toolCalls: ToolCall[]; +} + +function parseCustomHeaders(raw?: string): Record { + if (!raw) return {}; + const headers: Record = {}; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + const idx = trimmed.indexOf(':'); + if (idx > 0) { + const key = trimmed.slice(0, idx).trim().toLowerCase(); + const val = trimmed.slice(idx + 1).trim(); + headers[`x-custom-${key}`] = val; + } + } + return headers; +} + export async function chat( messages: ChatMessage[], tools: ToolDef[], @@ -182,10 +143,8 @@ async function chatOpenAI( body.tools = tools; } - const targetUrl = `${config.baseUrl}/v1/chat/completions`; - const toolNames = Array.isArray(tools) - ? tools.map((t: { function?: { name?: string } }) => t.function?.name).filter(Boolean) - : []; + const targetUrl = `${config.baseUrl}/chat/completions`; + const toolNames = Array.isArray(tools) ? tools.map((t) => t.function?.name).filter(Boolean) : []; logger.info('ToolLog', 'LLM Request: toolCount=', tools.length, 'toolNames=', toolNames); logger.info('LLM', 'Request:', { targetUrl, @@ -236,11 +195,9 @@ async function chatAnthropic( tools: ToolDef[], config: LLMConfig, ): Promise { - // Extract system message const systemMsg = messages.find((m) => m.role === 'system')?.content || ''; const nonSystemMessages = messages.filter((m) => m.role !== 'system'); - // Convert message format const anthropicMessages = nonSystemMessages.map((m) => { if (m.role === 'tool') { return { @@ -271,7 +228,6 @@ async function chatAnthropic( return { role: m.role as 'user' | 'assistant', content: m.content }; }); - // Convert tools const anthropicTools = tools.map((t) => ({ name: t.function.name, description: t.function.description, @@ -286,7 +242,7 @@ async function chatAnthropic( if (systemMsg) body.system = systemMsg; if (anthropicTools.length > 0) body.tools = anthropicTools; - const anthropicToolNames = anthropicTools.map((t: { name?: string }) => t.name).filter(Boolean); + const anthropicToolNames = anthropicTools.map((t) => t.name).filter(Boolean); logger.info( 'ToolLog', 'Anthropic Request: toolCount=', diff --git a/apps/webuiapps/src/lib/llmModels.ts b/apps/webuiapps/src/lib/llmModels.ts new file mode 100644 index 0000000..9654eb4 --- /dev/null +++ b/apps/webuiapps/src/lib/llmModels.ts @@ -0,0 +1,232 @@ +export type LLMProvider = 'openai' | 'anthropic' | 'deepseek' | 'minimax' | 'z.ai' | 'kimi'; + +export type ModelCategory = 'flagship' | 'general' | 'coding' | 'lightweight' | 'thinking'; + +export interface ModelInfo { + id: string; + name: string; + category?: ModelCategory; + description?: string; +} + +export interface LLMConfig { + provider: LLMProvider; + apiKey: string; + baseUrl: string; + model: string; + customHeaders?: string; +} + +export interface ProviderModelConfig { + displayName: string; + baseUrl: string; + defaultModel: string; + models: ModelInfo[]; +} + +export const LLM_PROVIDER_CONFIGS: Record = { + openai: { + displayName: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + defaultModel: 'gpt-5.4', + models: [ + { id: 'gpt-5.4', name: 'GPT-5.4', category: 'flagship', description: '当前最强' }, + { + id: 'gpt-5.4-pro', + name: 'GPT-5.4 Pro', + category: 'flagship', + description: '更高精度/更贵', + }, + { + id: 'gpt-5.4-thinking', + name: 'GPT-5.4 Thinking', + category: 'thinking', + description: '强化推理', + }, + { id: 'gpt-5.3', name: 'GPT-5.3', category: 'general', description: '通用' }, + { + id: 'gpt-5.3-instant', + name: 'GPT-5.3 Instant', + category: 'general', + description: '更快更便宜', + }, + { + id: 'gpt-5.3-codex', + name: 'GPT-5.3 Codex', + category: 'coding', + description: '最强代码 Agent', + }, + { + id: 'gpt-5.3-codex-spark', + name: 'GPT-5.3 Codex Spark', + category: 'coding', + description: '超低延迟实时编程', + }, + { id: 'gpt-5-mini', name: 'GPT-5 mini', category: 'lightweight' }, + { id: 'gpt-5-nano', name: 'GPT-5 nano', category: 'lightweight' }, + { id: 'gpt-4.1', name: 'GPT-4.1', category: 'general' }, + { id: 'gpt-4.1-mini', name: 'GPT-4.1 mini', category: 'lightweight' }, + { id: 'gpt-4.1-nano', name: 'GPT-4.1 nano', category: 'lightweight' }, + { id: 'gpt-4o', name: 'GPT-4o', category: 'general', description: '多模态' }, + { id: 'gpt-4o-mini', name: 'GPT-4o mini', category: 'lightweight' }, + { id: 'gpt-4-turbo', name: 'GPT-4 Turbo', category: 'general' }, + ], + }, + + anthropic: { + displayName: 'Anthropic', + baseUrl: 'https://api.anthropic.com/v1', + defaultModel: 'claude-sonnet-4-6', + models: [ + { + id: 'claude-opus-4-6', + name: 'Claude Opus 4.6', + category: 'flagship', + description: '最强推理', + }, + { + id: 'claude-opus-4-5', + name: 'Claude Opus 4.5', + category: 'flagship', + description: '上一代旗舰', + }, + { + id: 'claude-sonnet-4-6', + name: 'Claude Sonnet 4.6', + category: 'general', + description: '性价比最高', + }, + { + id: 'claude-sonnet-4-5', + name: 'Claude Sonnet 4.5', + category: 'general', + description: '编程主力', + }, + { + id: 'claude-haiku-4-5', + name: 'Claude Haiku 4.5', + category: 'lightweight', + description: '超快/超便宜', + }, + ], + }, + + deepseek: { + displayName: 'DeepSeek', + baseUrl: 'https://api.deepseek.com/v1', + defaultModel: 'deepseek-chat', + models: [ + { id: 'deepseek-chat', name: 'DeepSeek Chat', category: 'general', description: '通用对话' }, + { + id: 'deepseek-reasoner', + name: 'DeepSeek Reasoner', + category: 'thinking', + description: '强化推理', + }, + ], + }, + + minimax: { + displayName: 'MiniMax', + baseUrl: 'https://api.minimax.io/anthropic/v1', + defaultModel: 'MiniMax-M2.5', + models: [ + { id: 'MiniMax-M2.5', name: 'MiniMax M2.5', category: 'flagship', description: '最新主力' }, + { + id: 'MiniMax-M2.5-highspeed', + name: 'MiniMax M2.5 Highspeed', + category: 'general', + description: '高速版', + }, + { id: 'MiniMax-M2.1', name: 'MiniMax M2.1', category: 'coding', description: '多语言编程' }, + { + id: 'MiniMax-M2.1-highspeed', + name: 'MiniMax M2.1 Highspeed', + category: 'coding', + description: '高速版', + }, + { id: 'MiniMax-M2.7', name: 'MiniMax M2.7', category: 'flagship', description: '自我改进' }, + { + id: 'MiniMax-M2.7-highspeed', + name: 'MiniMax M2.7 Highspeed', + category: 'general', + description: '高速版', + }, + { id: 'MiniMax-M2', name: 'MiniMax M2', category: 'general', description: 'Agent能力' }, + ], + }, + + 'z.ai': { + displayName: 'Z.ai', + baseUrl: 'https://api.z.ai/api/coding/paas/v4', + defaultModel: 'glm-5', + models: [ + { id: 'glm-5', name: 'GLM-5', category: 'flagship', description: '最新旗舰' }, + { id: 'glm-5-code', name: 'GLM-5 Code', category: 'coding', description: '代码专用' }, + { id: 'glm-4.7', name: 'GLM-4.7', category: 'general' }, + { id: 'glm-4.6', name: 'GLM-4.6', category: 'general' }, + { id: 'glm-4.5', name: 'GLM-4.5', category: 'general' }, + { id: 'glm-4.5-x', name: 'GLM-4.5-X', category: 'general', description: '增强版' }, + { id: 'glm-4.5-air', name: 'GLM-4.5 Air', category: 'lightweight', description: '高性价比' }, + { id: 'glm-4.5-airx', name: 'GLM-4.5 AirX', category: 'lightweight' }, + { id: 'glm-4.7-flash', name: 'GLM-4.7 Flash', category: 'lightweight', description: '高速' }, + { + id: 'glm-4.7-flashx', + name: 'GLM-4.7 FlashX', + category: 'lightweight', + description: '超高速', + }, + { id: 'glm-4.5-flash', name: 'GLM-4.5 Flash', category: 'lightweight' }, + { id: 'glm-4-32b-0414-128k', name: 'GLM-4 32B (128K)', category: 'general' }, + ], + }, + + kimi: { + displayName: 'Kimi', + baseUrl: 'https://api.moonshot.cn/v1', + defaultModel: 'kimi-k2-5', + models: [ + { id: 'kimi-k2-5', name: 'Kimi K2.5', category: 'flagship', description: '最新强化版' }, + { id: 'kimi-k2', name: 'Kimi K2', category: 'flagship', description: '开源/Agent导向' }, + { + id: 'kimi-k2-thinking', + name: 'Kimi K2 Thinking', + category: 'thinking', + description: '强化推理', + }, + { id: 'kimi-k2-turbo', name: 'Kimi K2 Turbo', category: 'general', description: '高速版' }, + ], + }, +}; + +export const PROVIDER_MODELS: Record = Object.fromEntries( + Object.entries(LLM_PROVIDER_CONFIGS).map(([provider, config]) => [ + provider, + config.models.map((m) => m.id), + ]), +) as Record; + +export function getDefaultProviderConfig(provider: LLMProvider): Omit { + const config = LLM_PROVIDER_CONFIGS[provider]; + return { + provider, + baseUrl: config.baseUrl, + model: config.defaultModel, + }; +} + +export function getModelInfo(provider: LLMProvider, modelId: string): ModelInfo | undefined { + return LLM_PROVIDER_CONFIGS[provider]?.models.find((m) => m.id === modelId); +} + +export function getModelsByCategory(provider: LLMProvider, category: ModelCategory): ModelInfo[] { + return LLM_PROVIDER_CONFIGS[provider]?.models.filter((m) => m.category === category) ?? []; +} + +export function isPresetModel(provider: LLMProvider, modelId: string): boolean { + return LLM_PROVIDER_CONFIGS[provider]?.models.some((m) => m.id === modelId) ?? false; +} + +export function getProviderDisplayName(provider: LLMProvider): string { + return LLM_PROVIDER_CONFIGS[provider]?.displayName ?? provider; +} From fc6c8120e888c5186076ebaccab59dd17bb6e098 Mon Sep 17 00:00:00 2001 From: facquan <261348963@qq.com> Date: Thu, 19 Mar 2026 11:42:31 +0800 Subject: [PATCH 2/2] feat(chat): unify llm model config usage and stabilize tests --- .../src/components/ChatPanel/index.tsx | 21 ++- .../lib/__tests__/chatHistoryStorage.test.ts | 116 ++++---------- .../lib/__tests__/configPersistence.test.ts | 2 +- .../src/lib/__tests__/llmClient.test.ts | 47 ++++-- apps/webuiapps/src/lib/configPersistence.ts | 2 +- apps/webuiapps/src/lib/llmClient.ts | 28 ++-- apps/webuiapps/src/lib/llmModels.ts | 145 ++++-------------- e2e/app.spec.ts | 13 +- 8 files changed, 139 insertions(+), 235 deletions(-) diff --git a/apps/webuiapps/src/components/ChatPanel/index.tsx b/apps/webuiapps/src/components/ChatPanel/index.tsx index a65ab31..03cad88 100644 --- a/apps/webuiapps/src/components/ChatPanel/index.tsx +++ b/apps/webuiapps/src/components/ChatPanel/index.tsx @@ -10,17 +10,13 @@ import { Pencil, List, } from 'lucide-react'; +import { chat, loadConfig, loadConfigSync, saveConfig, type ChatMessage } from '@/lib/llmClient'; import { - chat, - loadConfig, - loadConfigSync, - saveConfig, - getDefaultConfig, + PROVIDER_MODELS, + getDefaultProviderConfig, type LLMConfig, type LLMProvider, - type ChatMessage, -} from '@/lib/llmClient'; -import { PROVIDER_MODELS } from '@/lib/llmModels'; +} from '@/lib/llmModels'; import { loadImageGenConfig, loadImageGenConfigSync, @@ -1011,6 +1007,7 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({ {messages.map((msg) => (
(config?.provider || 'minimax'); const [apiKey, setApiKey] = useState(config?.apiKey || ''); - const [baseUrl, setBaseUrl] = useState(config?.baseUrl || getDefaultConfig('minimax').baseUrl); - const [model, setModel] = useState(config?.model || getDefaultConfig('minimax').model); + const [baseUrl, setBaseUrl] = useState( + config?.baseUrl || getDefaultProviderConfig('minimax').baseUrl, + ); + const [model, setModel] = useState(config?.model || getDefaultProviderConfig('minimax').model); const [customHeaders, setCustomHeaders] = useState(config?.customHeaders || ''); const [manualModelMode, setManualModelMode] = useState(false); @@ -1147,7 +1146,7 @@ const SettingsModal: React.FC<{ const handleProviderChange = (p: LLMProvider) => { setProvider(p); - const defaults = getDefaultConfig(p); + const defaults = getDefaultProviderConfig(p); setBaseUrl(defaults.baseUrl); setModel(defaults.model); setManualModelMode(false); diff --git a/apps/webuiapps/src/lib/__tests__/chatHistoryStorage.test.ts b/apps/webuiapps/src/lib/__tests__/chatHistoryStorage.test.ts index 4d8033a..8663c18 100644 --- a/apps/webuiapps/src/lib/__tests__/chatHistoryStorage.test.ts +++ b/apps/webuiapps/src/lib/__tests__/chatHistoryStorage.test.ts @@ -5,7 +5,11 @@ import type { ChatMessage } from '../llmClient'; const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); -const STORAGE_KEY = 'webuiapps-chat-history'; +const SESSION_PATH = 'char-1/mod-1'; + +function expectedUrl(file: string): string { + return `/api/session-data?path=${encodeURIComponent(`${SESSION_PATH}/chat/${file}`)}`; +} const sampleMessages: DisplayMessage[] = [ { id: '1', role: 'user', content: 'Hello' }, @@ -24,49 +28,18 @@ function makeSavedData(msgs = sampleMessages, history = sampleChatHistory): Chat describe('chatHistoryStorage', () => { beforeEach(() => { fetchMock.mockReset(); - localStorage.clear(); vi.resetModules(); }); - // ============ loadChatHistorySync ============ - describe('loadChatHistorySync', () => { - it('returns null when localStorage is empty', async () => { - const { loadChatHistorySync } = await import('../chatHistoryStorage'); - expect(loadChatHistorySync()).toBeNull(); - }); - - it('returns data from localStorage', async () => { - const data = makeSavedData(); - localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); - const { loadChatHistorySync } = await import('../chatHistoryStorage'); - const result = loadChatHistorySync(); - expect(result).not.toBeNull(); - expect(result!.messages).toHaveLength(2); - expect(result!.chatHistory).toHaveLength(2); - expect(result!.version).toBe(1); - }); - - it('returns null for invalid JSON', async () => { - localStorage.setItem(STORAGE_KEY, 'not-json'); - const { loadChatHistorySync } = await import('../chatHistoryStorage'); - expect(loadChatHistorySync()).toBeNull(); - }); - - it('returns null for wrong version', async () => { - localStorage.setItem( - STORAGE_KEY, - JSON.stringify({ version: 99, savedAt: 0, messages: [], chatHistory: [] }), - ); + it('returns null', async () => { const { loadChatHistorySync } = await import('../chatHistoryStorage'); - expect(loadChatHistorySync()).toBeNull(); + expect(loadChatHistorySync(SESSION_PATH)).toBeNull(); }); }); - // ============ loadChatHistory (async) ============ - describe('loadChatHistory', () => { - it('loads from API and syncs to localStorage', async () => { + it('loads from API', async () => { const data = makeSavedData(); fetchMock.mockResolvedValueOnce({ ok: true, @@ -74,107 +47,82 @@ describe('chatHistoryStorage', () => { }); const { loadChatHistory } = await import('../chatHistoryStorage'); - const result = await loadChatHistory(); + const result = await loadChatHistory(SESSION_PATH); - expect(fetchMock).toHaveBeenCalledWith('/api/chat-history'); + expect(fetchMock).toHaveBeenCalledWith(expectedUrl('chat.json')); expect(result).not.toBeNull(); expect(result!.messages).toEqual(sampleMessages); - // Verify synced to localStorage - const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); - expect(stored.version).toBe(1); }); - it('falls back to localStorage when API returns non-ok', async () => { - const data = makeSavedData(); - localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + it('returns null when API returns non-ok', async () => { fetchMock.mockResolvedValueOnce({ ok: false, status: 404 }); const { loadChatHistory } = await import('../chatHistoryStorage'); - const result = await loadChatHistory(); + const result = await loadChatHistory(SESSION_PATH); - expect(result).not.toBeNull(); - expect(result!.messages).toEqual(sampleMessages); + expect(result).toBeNull(); }); - it('falls back to localStorage when fetch throws', async () => { - const data = makeSavedData(); - localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + it('returns null when fetch throws', async () => { fetchMock.mockRejectedValueOnce(new Error('network error')); const { loadChatHistory } = await import('../chatHistoryStorage'); - const result = await loadChatHistory(); + const result = await loadChatHistory(SESSION_PATH); - expect(result).not.toBeNull(); - expect(result!.messages).toEqual(sampleMessages); + expect(result).toBeNull(); }); - it('returns null when both API and localStorage are empty', async () => { + it('returns null when API is empty', async () => { fetchMock.mockResolvedValueOnce({ ok: false, status: 404 }); const { loadChatHistory } = await import('../chatHistoryStorage'); - const result = await loadChatHistory(); + const result = await loadChatHistory(SESSION_PATH); expect(result).toBeNull(); }); }); - // ============ saveChatHistory ============ - describe('saveChatHistory', () => { - it('saves to localStorage and POSTs to API', async () => { + it('POSTs to API with expected payload', async () => { fetchMock.mockResolvedValueOnce({ ok: true }); const { saveChatHistory } = await import('../chatHistoryStorage'); - await saveChatHistory(sampleMessages, sampleChatHistory); - - // Check localStorage - const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); - expect(stored.version).toBe(1); - expect(stored.messages).toEqual(sampleMessages); - expect(stored.chatHistory).toEqual(sampleChatHistory); - expect(typeof stored.savedAt).toBe('number'); + await saveChatHistory(SESSION_PATH, sampleMessages, sampleChatHistory); - // Check fetch call expect(fetchMock).toHaveBeenCalledOnce(); const [url, options] = fetchMock.mock.calls[0]; - expect(url).toBe('/api/chat-history'); + expect(url).toBe(expectedUrl('chat.json')); expect(options.method).toBe('POST'); const body = JSON.parse(options.body); expect(body.version).toBe(1); + expect(body.messages).toEqual(sampleMessages); + expect(body.chatHistory).toEqual(sampleChatHistory); }); - it('saves to localStorage even when fetch fails', async () => { + it('does not throw when fetch fails', async () => { fetchMock.mockRejectedValueOnce(new Error('network error')); const { saveChatHistory } = await import('../chatHistoryStorage'); - await saveChatHistory(sampleMessages, sampleChatHistory); - - const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); - expect(stored.messages).toEqual(sampleMessages); + await expect( + saveChatHistory(SESSION_PATH, sampleMessages, sampleChatHistory), + ).resolves.toBeUndefined(); }); }); - // ============ clearChatHistory ============ - describe('clearChatHistory', () => { - it('removes from localStorage and sends DELETE to API', async () => { - localStorage.setItem(STORAGE_KEY, JSON.stringify(makeSavedData())); + it('sends DELETE to API', async () => { fetchMock.mockResolvedValueOnce({ ok: true }); const { clearChatHistory } = await import('../chatHistoryStorage'); - await clearChatHistory(); + await clearChatHistory(SESSION_PATH); - expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); - expect(fetchMock).toHaveBeenCalledWith('/api/chat-history', { method: 'DELETE' }); + expect(fetchMock).toHaveBeenCalledWith(expectedUrl('chat.json'), { method: 'DELETE' }); }); - it('clears localStorage even when DELETE fetch fails', async () => { - localStorage.setItem(STORAGE_KEY, JSON.stringify(makeSavedData())); + it('does not throw when DELETE fetch fails', async () => { fetchMock.mockRejectedValueOnce(new Error('network error')); const { clearChatHistory } = await import('../chatHistoryStorage'); - await clearChatHistory(); - - expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + await expect(clearChatHistory(SESSION_PATH)).resolves.toBeUndefined(); }); }); }); diff --git a/apps/webuiapps/src/lib/__tests__/configPersistence.test.ts b/apps/webuiapps/src/lib/__tests__/configPersistence.test.ts index fdc3efd..06758b0 100644 --- a/apps/webuiapps/src/lib/__tests__/configPersistence.test.ts +++ b/apps/webuiapps/src/lib/__tests__/configPersistence.test.ts @@ -10,7 +10,7 @@ import { savePersistedConfig, type PersistedConfig, } from '../configPersistence'; -import type { LLMConfig } from '../llmClient'; +import type { LLMConfig } from '../llmModels'; import type { ImageGenConfig } from '../imageGenClient'; // ─── Constants ────────────────────────────────────────────────────────────────── diff --git a/apps/webuiapps/src/lib/__tests__/llmClient.test.ts b/apps/webuiapps/src/lib/__tests__/llmClient.test.ts index d92ee55..b91aade 100644 --- a/apps/webuiapps/src/lib/__tests__/llmClient.test.ts +++ b/apps/webuiapps/src/lib/__tests__/llmClient.test.ts @@ -9,7 +9,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { - getDefaultConfig, loadConfig, loadConfigSync, saveConfig, @@ -17,7 +16,7 @@ import { type ChatMessage, type ToolDef, } from '../llmClient'; -import type { LLMConfig } from '../llmModels'; +import { getDefaultProviderConfig, type LLMConfig } from '../llmModels'; // ─── Constants ──────────────────────────────────────────────────────────────── @@ -92,11 +91,9 @@ afterEach(() => { vi.restoreAllMocks(); }); -// ─── getDefaultConfig() ─────────────────────────────────────────────────────── - -describe('getDefaultConfig()', () => { +describe('getDefaultProviderConfig()', () => { it('returns correct defaults for openai', () => { - const cfg = getDefaultConfig('openai'); + const cfg = getDefaultProviderConfig('openai'); expect(cfg.provider).toBe('openai'); expect(cfg.baseUrl).toBe('https://api.openai.com/v1'); expect(cfg.model).toBe('gpt-5.4'); @@ -104,43 +101,43 @@ describe('getDefaultConfig()', () => { }); it('returns correct defaults for anthropic', () => { - const cfg = getDefaultConfig('anthropic'); + const cfg = getDefaultProviderConfig('anthropic'); expect(cfg.provider).toBe('anthropic'); expect(cfg.baseUrl).toBe('https://api.anthropic.com/v1'); expect(cfg.model).toBe('claude-sonnet-4-6'); }); it('returns correct defaults for deepseek', () => { - const cfg = getDefaultConfig('deepseek'); + const cfg = getDefaultProviderConfig('deepseek'); expect(cfg.provider).toBe('deepseek'); expect(cfg.baseUrl).toBe('https://api.deepseek.com/v1'); expect(cfg.model).toBe('deepseek-chat'); }); it('returns correct defaults for minimax', () => { - const cfg = getDefaultConfig('minimax'); + const cfg = getDefaultProviderConfig('minimax'); expect(cfg.provider).toBe('minimax'); expect(cfg.baseUrl).toBe('https://api.minimax.io/anthropic/v1'); expect(cfg.model).toBe('MiniMax-M2.5'); }); it('returns correct defaults for z.ai', () => { - const cfg = getDefaultConfig('z.ai'); + const cfg = getDefaultProviderConfig('z.ai'); expect(cfg.provider).toBe('z.ai'); expect(cfg.baseUrl).toBe('https://api.z.ai/api/coding/paas/v4'); expect(cfg.model).toBe('glm-5'); }); it('returns correct defaults for kimi', () => { - const cfg = getDefaultConfig('kimi'); + const cfg = getDefaultProviderConfig('kimi'); expect(cfg.provider).toBe('kimi'); expect(cfg.baseUrl).toBe('https://api.moonshot.cn/v1'); expect(cfg.model).toBe('kimi-k2-5'); }); it('returns consistent values for the same provider', () => { - const a = getDefaultConfig('openai'); - const b = getDefaultConfig('openai'); + const a = getDefaultProviderConfig('openai'); + const b = getDefaultProviderConfig('openai'); expect(a).toStrictEqual(b); }); }); @@ -345,6 +342,16 @@ describe('chat()', () => { expect(headers['Authorization']).toBe('Bearer sk-test-key'); }); + it('uses v1/chat/completions when baseUrl has no version suffix', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce(makeOpenAIResponse('ok')); + globalThis.fetch = mockFetch; + + await chat(MOCK_MESSAGES, [], MOCK_OPENAI_CONFIG); + + const headers = mockFetch.mock.calls[0][1].headers as Record; + expect(headers['X-LLM-Target-URL']).toBe('https://api.openai.com/v1/chat/completions'); + }); + it('includes tools in body when tools array is non-empty', async () => { const mockFetch = vi.fn().mockResolvedValueOnce(makeOpenAIResponse('ok')); globalThis.fetch = mockFetch; @@ -421,6 +428,20 @@ describe('chat()', () => { expect(headers['x-api-key']).toBe('ant-test-key'); }); + it('uses /messages when baseUrl already includes /v1', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce(makeAnthropicResponse('Anthropic response')); + globalThis.fetch = mockFetch; + + const configWithVersion: LLMConfig = { + ...MOCK_ANTHROPIC_CONFIG, + baseUrl: 'https://api.anthropic.com/v1', + }; + await chat(MOCK_MESSAGES, [], configWithVersion); + + const headers = mockFetch.mock.calls[0][1].headers as Record; + expect(headers['X-LLM-Target-URL']).toBe('https://api.anthropic.com/v1/messages'); + }); + it('extracts system message to top-level system field', async () => { const messages: ChatMessage[] = [ { role: 'system', content: 'You are helpful.' }, diff --git a/apps/webuiapps/src/lib/configPersistence.ts b/apps/webuiapps/src/lib/configPersistence.ts index 2653693..fa8a0ab 100644 --- a/apps/webuiapps/src/lib/configPersistence.ts +++ b/apps/webuiapps/src/lib/configPersistence.ts @@ -6,7 +6,7 @@ * automatically migrated on read. */ -import type { LLMConfig } from './llmClient'; +import type { LLMConfig } from './llmModels'; import type { ImageGenConfig } from './imageGenClient'; export interface PersistedConfig { diff --git a/apps/webuiapps/src/lib/llmClient.ts b/apps/webuiapps/src/lib/llmClient.ts index 29bbc3a..cae2d61 100644 --- a/apps/webuiapps/src/lib/llmClient.ts +++ b/apps/webuiapps/src/lib/llmClient.ts @@ -3,19 +3,13 @@ * Supports OpenAI / DeepSeek / Anthropic formats */ -import { getDefaultProviderConfig, type LLMConfig, type LLMProvider } from './llmModels'; - -export type { LLMConfig, LLMProvider }; +import type { LLMConfig } from './llmModels'; import { logger } from './logger'; import { loadPersistedConfig, savePersistedConfig } from './configPersistence'; const CONFIG_KEY = 'webuiapps-llm-config'; -export function getDefaultConfig(provider: LLMProvider): Omit { - return getDefaultProviderConfig(provider); -} - export async function loadConfig(): Promise { try { const persisted = await loadPersistedConfig(); @@ -94,6 +88,22 @@ interface LLMResponse { toolCalls: ToolCall[]; } +function hasVersionSuffix(url: string): boolean { + return /\/v\d+\/?$/.test(url); +} + +function joinUrl(baseUrl: string, path: string): string { + return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; +} + +function getOpenAICompletionsPath(baseUrl: string): string { + return hasVersionSuffix(baseUrl) ? 'chat/completions' : 'v1/chat/completions'; +} + +function getAnthropicMessagesPath(baseUrl: string): string { + return hasVersionSuffix(baseUrl) ? 'messages' : 'v1/messages'; +} + function parseCustomHeaders(raw?: string): Record { if (!raw) return {}; const headers: Record = {}; @@ -143,7 +153,7 @@ async function chatOpenAI( body.tools = tools; } - const targetUrl = `${config.baseUrl}/chat/completions`; + const targetUrl = joinUrl(config.baseUrl, getOpenAICompletionsPath(config.baseUrl)); const toolNames = Array.isArray(tools) ? tools.map((t) => t.function?.name).filter(Boolean) : []; logger.info('ToolLog', 'LLM Request: toolCount=', tools.length, 'toolNames=', toolNames); logger.info('LLM', 'Request:', { @@ -250,7 +260,7 @@ async function chatAnthropic( 'toolNames=', anthropicToolNames, ); - const targetUrl = `${config.baseUrl}/v1/messages`; + const targetUrl = joinUrl(config.baseUrl, getAnthropicMessagesPath(config.baseUrl)); logger.info('LLM', 'Anthropic Request:', { targetUrl, model: config.model, diff --git a/apps/webuiapps/src/lib/llmModels.ts b/apps/webuiapps/src/lib/llmModels.ts index 9654eb4..0b71d79 100644 --- a/apps/webuiapps/src/lib/llmModels.ts +++ b/apps/webuiapps/src/lib/llmModels.ts @@ -6,7 +6,6 @@ export interface ModelInfo { id: string; name: string; category?: ModelCategory; - description?: string; } export interface LLMConfig { @@ -30,44 +29,19 @@ export const LLM_PROVIDER_CONFIGS: Record = { baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-5.4', models: [ - { id: 'gpt-5.4', name: 'GPT-5.4', category: 'flagship', description: '当前最强' }, - { - id: 'gpt-5.4-pro', - name: 'GPT-5.4 Pro', - category: 'flagship', - description: '更高精度/更贵', - }, - { - id: 'gpt-5.4-thinking', - name: 'GPT-5.4 Thinking', - category: 'thinking', - description: '强化推理', - }, - { id: 'gpt-5.3', name: 'GPT-5.3', category: 'general', description: '通用' }, - { - id: 'gpt-5.3-instant', - name: 'GPT-5.3 Instant', - category: 'general', - description: '更快更便宜', - }, - { - id: 'gpt-5.3-codex', - name: 'GPT-5.3 Codex', - category: 'coding', - description: '最强代码 Agent', - }, - { - id: 'gpt-5.3-codex-spark', - name: 'GPT-5.3 Codex Spark', - category: 'coding', - description: '超低延迟实时编程', - }, + { id: 'gpt-5.4', name: 'GPT-5.4', category: 'flagship' }, + { id: 'gpt-5.4-pro', name: 'GPT-5.4 Pro', category: 'flagship' }, + { id: 'gpt-5.4-thinking', name: 'GPT-5.4 Thinking', category: 'thinking' }, + { id: 'gpt-5.3', name: 'GPT-5.3', category: 'general' }, + { id: 'gpt-5.3-instant', name: 'GPT-5.3 Instant', category: 'general' }, + { id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', category: 'coding' }, + { id: 'gpt-5.3-codex-spark', name: 'GPT-5.3 Codex Spark', category: 'coding' }, { id: 'gpt-5-mini', name: 'GPT-5 mini', category: 'lightweight' }, { id: 'gpt-5-nano', name: 'GPT-5 nano', category: 'lightweight' }, { id: 'gpt-4.1', name: 'GPT-4.1', category: 'general' }, { id: 'gpt-4.1-mini', name: 'GPT-4.1 mini', category: 'lightweight' }, { id: 'gpt-4.1-nano', name: 'GPT-4.1 nano', category: 'lightweight' }, - { id: 'gpt-4o', name: 'GPT-4o', category: 'general', description: '多模态' }, + { id: 'gpt-4o', name: 'GPT-4o', category: 'general' }, { id: 'gpt-4o-mini', name: 'GPT-4o mini', category: 'lightweight' }, { id: 'gpt-4-turbo', name: 'GPT-4 Turbo', category: 'general' }, ], @@ -78,36 +52,11 @@ export const LLM_PROVIDER_CONFIGS: Record = { baseUrl: 'https://api.anthropic.com/v1', defaultModel: 'claude-sonnet-4-6', models: [ - { - id: 'claude-opus-4-6', - name: 'Claude Opus 4.6', - category: 'flagship', - description: '最强推理', - }, - { - id: 'claude-opus-4-5', - name: 'Claude Opus 4.5', - category: 'flagship', - description: '上一代旗舰', - }, - { - id: 'claude-sonnet-4-6', - name: 'Claude Sonnet 4.6', - category: 'general', - description: '性价比最高', - }, - { - id: 'claude-sonnet-4-5', - name: 'Claude Sonnet 4.5', - category: 'general', - description: '编程主力', - }, - { - id: 'claude-haiku-4-5', - name: 'Claude Haiku 4.5', - category: 'lightweight', - description: '超快/超便宜', - }, + { id: 'claude-opus-4-6', name: 'Claude Opus 4.6', category: 'flagship' }, + { id: 'claude-opus-4-5', name: 'Claude Opus 4.5', category: 'flagship' }, + { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', category: 'general' }, + { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', category: 'general' }, + { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', category: 'lightweight' }, ], }, @@ -116,13 +65,8 @@ export const LLM_PROVIDER_CONFIGS: Record = { baseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [ - { id: 'deepseek-chat', name: 'DeepSeek Chat', category: 'general', description: '通用对话' }, - { - id: 'deepseek-reasoner', - name: 'DeepSeek Reasoner', - category: 'thinking', - description: '强化推理', - }, + { id: 'deepseek-chat', name: 'DeepSeek Chat', category: 'general' }, + { id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', category: 'thinking' }, ], }, @@ -131,28 +75,13 @@ export const LLM_PROVIDER_CONFIGS: Record = { baseUrl: 'https://api.minimax.io/anthropic/v1', defaultModel: 'MiniMax-M2.5', models: [ - { id: 'MiniMax-M2.5', name: 'MiniMax M2.5', category: 'flagship', description: '最新主力' }, - { - id: 'MiniMax-M2.5-highspeed', - name: 'MiniMax M2.5 Highspeed', - category: 'general', - description: '高速版', - }, - { id: 'MiniMax-M2.1', name: 'MiniMax M2.1', category: 'coding', description: '多语言编程' }, - { - id: 'MiniMax-M2.1-highspeed', - name: 'MiniMax M2.1 Highspeed', - category: 'coding', - description: '高速版', - }, - { id: 'MiniMax-M2.7', name: 'MiniMax M2.7', category: 'flagship', description: '自我改进' }, - { - id: 'MiniMax-M2.7-highspeed', - name: 'MiniMax M2.7 Highspeed', - category: 'general', - description: '高速版', - }, - { id: 'MiniMax-M2', name: 'MiniMax M2', category: 'general', description: 'Agent能力' }, + { id: 'MiniMax-M2.5', name: 'MiniMax M2.5', category: 'flagship' }, + { id: 'MiniMax-M2.5-highspeed', name: 'MiniMax M2.5 Highspeed', category: 'general' }, + { id: 'MiniMax-M2.1', name: 'MiniMax M2.1', category: 'coding' }, + { id: 'MiniMax-M2.1-highspeed', name: 'MiniMax M2.1 Highspeed', category: 'coding' }, + { id: 'MiniMax-M2.7', name: 'MiniMax M2.7', category: 'flagship' }, + { id: 'MiniMax-M2.7-highspeed', name: 'MiniMax M2.7 Highspeed', category: 'general' }, + { id: 'MiniMax-M2', name: 'MiniMax M2', category: 'general' }, ], }, @@ -161,21 +90,16 @@ export const LLM_PROVIDER_CONFIGS: Record = { baseUrl: 'https://api.z.ai/api/coding/paas/v4', defaultModel: 'glm-5', models: [ - { id: 'glm-5', name: 'GLM-5', category: 'flagship', description: '最新旗舰' }, - { id: 'glm-5-code', name: 'GLM-5 Code', category: 'coding', description: '代码专用' }, + { id: 'glm-5', name: 'GLM-5', category: 'flagship' }, + { id: 'glm-5-code', name: 'GLM-5 Code', category: 'coding' }, { id: 'glm-4.7', name: 'GLM-4.7', category: 'general' }, { id: 'glm-4.6', name: 'GLM-4.6', category: 'general' }, { id: 'glm-4.5', name: 'GLM-4.5', category: 'general' }, - { id: 'glm-4.5-x', name: 'GLM-4.5-X', category: 'general', description: '增强版' }, - { id: 'glm-4.5-air', name: 'GLM-4.5 Air', category: 'lightweight', description: '高性价比' }, + { id: 'glm-4.5-x', name: 'GLM-4.5-X', category: 'general' }, + { id: 'glm-4.5-air', name: 'GLM-4.5 Air', category: 'lightweight' }, { id: 'glm-4.5-airx', name: 'GLM-4.5 AirX', category: 'lightweight' }, - { id: 'glm-4.7-flash', name: 'GLM-4.7 Flash', category: 'lightweight', description: '高速' }, - { - id: 'glm-4.7-flashx', - name: 'GLM-4.7 FlashX', - category: 'lightweight', - description: '超高速', - }, + { id: 'glm-4.7-flash', name: 'GLM-4.7 Flash', category: 'lightweight' }, + { id: 'glm-4.7-flashx', name: 'GLM-4.7 FlashX', category: 'lightweight' }, { id: 'glm-4.5-flash', name: 'GLM-4.5 Flash', category: 'lightweight' }, { id: 'glm-4-32b-0414-128k', name: 'GLM-4 32B (128K)', category: 'general' }, ], @@ -186,15 +110,10 @@ export const LLM_PROVIDER_CONFIGS: Record = { baseUrl: 'https://api.moonshot.cn/v1', defaultModel: 'kimi-k2-5', models: [ - { id: 'kimi-k2-5', name: 'Kimi K2.5', category: 'flagship', description: '最新强化版' }, - { id: 'kimi-k2', name: 'Kimi K2', category: 'flagship', description: '开源/Agent导向' }, - { - id: 'kimi-k2-thinking', - name: 'Kimi K2 Thinking', - category: 'thinking', - description: '强化推理', - }, - { id: 'kimi-k2-turbo', name: 'Kimi K2 Turbo', category: 'general', description: '高速版' }, + { id: 'kimi-k2-5', name: 'Kimi K2.5', category: 'flagship' }, + { id: 'kimi-k2', name: 'Kimi K2', category: 'flagship' }, + { id: 'kimi-k2-thinking', name: 'Kimi K2 Thinking', category: 'thinking' }, + { id: 'kimi-k2-turbo', name: 'Kimi K2 Turbo', category: 'general' }, ], }, }; diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 5a332b8..059806c 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -47,12 +47,19 @@ test.describe('Chat panel – visibility toggle', () => { await expect(panel).toBeVisible(); }); - test('chat panel shows empty state prompt when no messages', async ({ page }) => { + test('chat panel shows either setup hint or chat messages', async ({ page }) => { await page.goto('/'); const messages = page.locator('[data-testid="chat-messages"]'); await expect(messages).toBeVisible(); - // Should contain the empty-state text (either "Start a conversation" or "configure") - await expect(messages).toContainText(/Start a conversation|configure/i); + + const emptyStateHint = messages.getByText(/configure your LLM API key|is ready to chat/i); + const chatMessages = page.locator('[data-testid="chat-message"]'); + + await expect + .poll(async () => { + return (await emptyStateHint.count()) + (await chatMessages.count()); + }) + .toBeGreaterThan(0); }); });