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..03cad88 100644 --- a/apps/webuiapps/src/components/ChatPanel/index.tsx +++ b/apps/webuiapps/src/components/ChatPanel/index.tsx @@ -7,17 +7,16 @@ import { Maximize2, ChevronDown, ChevronRight, + 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'; +} from '@/lib/llmModels'; import { loadImageGenConfig, loadImageGenConfigSync, @@ -1008,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); + + const isPresetModel = PROVIDER_MODELS[provider]?.includes(model) ?? false; + const showDropdown = !manualModelMode && isPresetModel; // Image gen settings const [igProvider, setIgProvider] = useState( @@ -1140,9 +1146,15 @@ 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); + }; + + const handleModelChange = (newModel: string) => { + setModel(newModel); + setManualModelMode(false); }; const handleIgProviderChange = (p: ImageGenProvider) => { @@ -1168,6 +1180,8 @@ const SettingsModal: React.FC<{ + +
@@ -1193,11 +1207,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__/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 fbd4ca5..b91aade 100644 --- a/apps/webuiapps/src/lib/__tests__/llmClient.test.ts +++ b/apps/webuiapps/src/lib/__tests__/llmClient.test.ts @@ -9,15 +9,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { - getDefaultConfig, loadConfig, loadConfigSync, saveConfig, chat, - type LLMConfig, type ChatMessage, type ToolDef, } from '../llmClient'; +import { getDefaultProviderConfig, type LLMConfig } from '../llmModels'; // ─── Constants ──────────────────────────────────────────────────────────────── @@ -92,43 +91,54 @@ 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'); - 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'); + const cfg = getDefaultProviderConfig('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'); + const cfg = getDefaultProviderConfig('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'); + const cfg = getDefaultProviderConfig('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) - const a = getDefaultConfig('openai'); - const b = getDefaultConfig('openai'); - expect(a).toBe(b); + it('returns correct defaults for 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 = 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 = getDefaultProviderConfig('openai'); + const b = getDefaultProviderConfig('openai'); + expect(a).toStrictEqual(b); }); }); @@ -332,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; @@ -408,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 af794af..cae2d61 100644 --- a/apps/webuiapps/src/lib/llmClient.ts +++ b/apps/webuiapps/src/lib/llmClient.ts @@ -3,32 +3,55 @@ * Supports OpenAI / DeepSeek / Anthropic formats */ -export type LLMProvider = 'openai' | 'anthropic' | 'deepseek' | 'minimax'; - -export interface LLMConfig { - provider: LLMProvider; - apiKey: string; - baseUrl: string; - model: string; - /** Custom headers, format "Key: Value", one per line */ - customHeaders?: string; -} +import type { LLMConfig } from './llmModels'; -/** 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; +import { logger } from './logger'; +import { loadPersistedConfig, savePersistedConfig } from './configPersistence'; + +const CONFIG_KEY = 'webuiapps-llm-config'; + +export async function loadConfig(): Promise { + try { + const persisted = await loadPersistedConfig(); + if (persisted?.llm) { + localStorage.setItem(CONFIG_KEY, JSON.stringify(persisted.llm)); + return persisted.llm; } + } catch { + // API not available (production / network error) + } + + try { + const raw = localStorage.getItem(CONFIG_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +export async function saveConfig( + config: LLMConfig, + imageGenConfig?: import('./imageGenClient').ImageGenConfig | null, +): Promise { + localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); + + const persisted: import('./configPersistence').PersistedConfig = { + llm: config, + }; + if (imageGenConfig) { + persisted.imageGen = imageGenConfig; + } + + await savePersistedConfig(persisted); +} + +export function loadConfigSync(): LLMConfig | null { + try { + const raw = localStorage.getItem(CONFIG_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; } - return headers; } export interface ChatMessage { @@ -65,90 +88,38 @@ interface LLMResponse { toolCalls: ToolCall[]; } -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]; +function hasVersionSuffix(url: string): boolean { + return /\/v\d+\/?$/.test(url); } -/** - * 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) { - localStorage.setItem(CONFIG_KEY, JSON.stringify(persisted.llm)); - return persisted.llm; - } - } catch { - // API not available (production / network error) — fall through - } - - // 2. Fall back to localStorage - try { - const raw = localStorage.getItem(CONFIG_KEY); - return raw ? JSON.parse(raw) : null; - } catch { - return null; - } +function joinUrl(baseUrl: string, path: string): string { + return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; } -/** - * 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 }; - if (imageGenConfig) { - persisted.imageGen = imageGenConfig; - } +function getOpenAICompletionsPath(baseUrl: string): string { + return hasVersionSuffix(baseUrl) ? 'chat/completions' : 'v1/chat/completions'; +} - // Best-effort write to local file - await savePersistedConfig(persisted); +function getAnthropicMessagesPath(baseUrl: string): string { + return hasVersionSuffix(baseUrl) ? 'messages' : 'v1/messages'; } -/** Synchronous read from localStorage cache (use after loadConfig() has been awaited once). */ -export function loadConfigSync(): LLMConfig | null { - try { - const raw = localStorage.getItem(CONFIG_KEY); - return raw ? JSON.parse(raw) : null; - } catch { - return null; +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; } -/** - * Call LLM API (non-streaming, simple version) - */ export async function chat( messages: ChatMessage[], tools: ToolDef[], @@ -182,10 +153,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 = 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:', { targetUrl, @@ -236,11 +205,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 +238,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 +252,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=', @@ -294,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 new file mode 100644 index 0000000..0b71d79 --- /dev/null +++ b/apps/webuiapps/src/lib/llmModels.ts @@ -0,0 +1,151 @@ +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; +} + +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' }, + { 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' }, + { 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' }, + { 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' }, + ], + }, + + deepseek: { + displayName: 'DeepSeek', + baseUrl: 'https://api.deepseek.com/v1', + defaultModel: 'deepseek-chat', + models: [ + { id: 'deepseek-chat', name: 'DeepSeek Chat', category: 'general' }, + { id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', category: 'thinking' }, + ], + }, + + 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' }, + { 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' }, + ], + }, + + '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' }, + { 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' }, + { 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' }, + { 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' }, + ], + }, + + kimi: { + displayName: 'Kimi', + baseUrl: 'https://api.moonshot.cn/v1', + defaultModel: 'kimi-k2-5', + models: [ + { 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' }, + ], + }, +}; + +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; +} 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); }); });