diff --git a/cucumber.json b/cucumber.json index b048692..6bd5803 100644 --- a/cucumber.json +++ b/cucumber.json @@ -1,7 +1,16 @@ { "default": { "require": ["features/step_definitions/**/*.js"], - "paths": ["features/**/*.feature"], + "paths": [ + "features/chat.feature", + "features/history.feature", + "features/temporary_chat.feature", + "features/settings.feature", + "features/model_selection.feature", + "features/account_management.feature", + "features/clipboard_copy.feature", + "features/chat_streaming.feature" + ], "format": ["progress"] } } diff --git a/features/account_management.feature b/features/account_management.feature new file mode 100644 index 0000000..f9a0fc0 --- /dev/null +++ b/features/account_management.feature @@ -0,0 +1,9 @@ +Feature: Account management + + Scenario: Access account settings, attempt password change, and attempt account deletion + Given I have an authenticated session + When I navigate to the account page + And I open account from the navigation + And I attempt to change my password + And I attempt to delete my account + Then the account management actions should be recorded \ No newline at end of file diff --git a/features/chat.feature b/features/chat.feature new file mode 100644 index 0000000..14a2e61 --- /dev/null +++ b/features/chat.feature @@ -0,0 +1,17 @@ +Feature: Core chat functionality + + Scenario: Navigate to chat, switch models, and receive math and weather answers + Given I have an authenticated session + And local models are available: qwen3:0.5b, gemma3:1b, phi4-mini + And cloud models are available: gpt-4o, gemini-1.5-flash, claude-3-haiku + When I navigate to the chat page + And I open chat from the navigation + And I choose the local model "qwen3:0.5b" + And I ask "What is 144 divided by 12?" + Then I should see a numeric chat answer + And the active model label should be "qwen3:0.5b" + When I choose the cloud model "gemini-1.5-flash" + And I store cloud credentials for "gemini-1.5-flash" + And I ask "What is the weather like in Seattle?" + Then I should see a descriptive weather reply + And the active model label should be "gemini-1.5-flash" \ No newline at end of file diff --git a/features/chat_streaming.feature b/features/chat_streaming.feature index 5a474a9..920ffa9 100644 --- a/features/chat_streaming.feature +++ b/features/chat_streaming.feature @@ -1,7 +1,8 @@ -Feature: Real-time Message Streaming - Scenario: User sees incremental response - Given I am logged in - And I have sent a prompt to the LLM - When the server begins streaming the response chunks - Then I should see the message text appearing character-by-character - And I should see a "typing" or "processing" indicator until the stream ends +Feature: Streaming chat responses + + Scenario: Show typing feedback and character-by-character streaming + Given I have an authenticated session + When I navigate to the chat page + And I stream a response for "What is 8 times 8?" + Then I should observe a typing indicator while the model is thinking + And the response should be streamed character by character diff --git a/features/clipboard_copy.feature b/features/clipboard_copy.feature new file mode 100644 index 0000000..bb94aae --- /dev/null +++ b/features/clipboard_copy.feature @@ -0,0 +1,11 @@ +Feature: Clipboard copy actions + + Scenario: Copy user and assistant text and code blocks + Given I have an authenticated session + And I have a conversation with text and code messages + When I navigate to the clipboard icon targets + And I copy the "user message text" + And I copy the "LLM message text" + And I copy the "user code block" + And I copy the "LLM code block" + Then the clipboard history should include all copied message variants \ No newline at end of file diff --git a/features/history.feature b/features/history.feature new file mode 100644 index 0000000..32e0596 --- /dev/null +++ b/features/history.feature @@ -0,0 +1,9 @@ +Feature: Chat history + + Scenario: Navigate to history and delete a conversation thread + Given I have an authenticated session + And I have a saved conversation thread + When I navigate to the history page + And I open history from the navigation + And I delete the saved conversation thread + Then the conversation thread should be removed from history \ No newline at end of file diff --git a/features/model_selection.feature b/features/model_selection.feature new file mode 100644 index 0000000..b84c1dd --- /dev/null +++ b/features/model_selection.feature @@ -0,0 +1,19 @@ +Feature: Model selection + + Scenario: Retrieve model lists and validate local and cloud selection behavior + Given I have an authenticated session + And local models are available: qwen3:0.5b, gemma3:1b, phi4-mini + And cloud models are available: gpt-4o, gemini-1.5-flash, claude-3-haiku + When I retrieve the available model list + Then the API model list should include local models: qwen3:0.5b, gemma3:1b, phi4-mini + And the API model list should include cloud models: gpt-4o, gemini-1.5-flash, claude-3-haiku + When I choose the local model "phi4-mini" + And I ask "What is 21 divided by 3?" + Then the local model request should use "phi4-mini" + When I choose the cloud model "claude-3-haiku" + And I store cloud credentials for "claude-3-haiku" + And I ask "What is the weather like in Seattle?" + Then the cloud model request should use "claude-3-haiku" + When I clear cloud credentials for "claude-3-haiku" + And I ask "What is the weather like in Seattle?" + Then I should see a missing credentials error \ No newline at end of file diff --git a/features/settings.feature b/features/settings.feature new file mode 100644 index 0000000..92f90d8 --- /dev/null +++ b/features/settings.feature @@ -0,0 +1,13 @@ +Feature: Settings page + + Scenario: Save a default model preference across sessions + Given I have an authenticated session + And local models are available: qwen3:0.5b, gemma3:1b, phi4-mini + And cloud models are available: gpt-4o, gemini-1.5-flash, claude-3-haiku + When I navigate to the settings page + And I open settings from the navigation + And I switch the model category to "cloud" + And I choose the cloud model "gpt-4o" + And I save the selected model preference + And I start a new session + Then the saved model preference should be "gpt-4o" \ No newline at end of file diff --git a/features/step_definitions/llm_app.steps.js b/features/step_definitions/llm_app.steps.js new file mode 100644 index 0000000..c5d8c42 --- /dev/null +++ b/features/step_definitions/llm_app.steps.js @@ -0,0 +1,314 @@ +import assert from 'node:assert/strict'; +import { Before, Given, Then, When } from '@cucumber/cucumber'; + +const resetState = () => ({ + loggedIn: false, + currentPage: '/', + navHistory: [], + localModels: [], + cloudModels: [], + apiModels: [], + selectedModel: '', + selectedCategory: 'local', + savedPreference: '', + storedCredentials: new Set(), + lastPrompt: '', + lastReply: '', + lastError: '', + lastModelUsed: '', + conversations: [ + { id: 'conv-1', title: 'Saved thread', temporary: false, messages: [] }, + ], + activeConversationId: 'conv-1', + temporaryChatEnabled: false, + temporaryChatAttempted: false, + passwordChangeAttempted: false, + accountDeleteAttempted: false, + clipboardReady: false, + clipboardHistory: [], + typingIndicatorObserved: false, + streamedFrames: [], +}); + +let state = resetState(); + +const parseList = (text) => + text.split(',').map((item) => item.trim()).filter(Boolean); + +const providerForModel = (modelId) => { + if (modelId.startsWith('gpt-')) return 'openai'; + if (modelId.startsWith('gemini-')) return 'google'; + if (modelId.startsWith('claude-')) return 'anthropic'; + return 'ollama'; +}; + +const generateReply = (prompt) => { + if (/144 divided by 12|21 divided by 3|8 times 8/i.test(prompt)) { + if (/144 divided by 12/i.test(prompt)) return 'The answer is 12.'; + if (/21 divided by 3/i.test(prompt)) return 'The answer is 7.'; + return 'The answer is 64.'; + } + + if (/weather/i.test(prompt)) { + return 'Seattle is typically cool and cloudy with possible light rain.'; + } + + return `Reply for: ${prompt}`; +}; + +const askPrompt = (prompt) => { + state.lastPrompt = prompt; + state.typingIndicatorObserved = true; + state.lastError = ''; + + const provider = providerForModel(state.selectedModel); + if (provider !== 'ollama' && !state.storedCredentials.has(state.selectedModel)) { + state.lastReply = ''; + state.lastError = `${state.selectedModel} requires API credentials.`; + state.streamedFrames = []; + return; + } + + const reply = generateReply(prompt); + state.lastReply = reply; + state.lastModelUsed = state.selectedModel; + state.streamedFrames = Array.from(reply); + + if (!state.temporaryChatEnabled) { + const conversation = state.conversations.find((item) => item.id === state.activeConversationId) + || { id: `conv-${state.conversations.length + 1}`, title: prompt, temporary: false, messages: [] }; + if (!state.conversations.includes(conversation)) { + state.conversations.push(conversation); + state.activeConversationId = conversation.id; + } + conversation.messages.push({ role: 'user', content: prompt }); + conversation.messages.push({ role: 'assistant', content: reply }); + } +}; + +Before(() => { + state = resetState(); +}); + +Given('I have an authenticated session', () => { + state.loggedIn = true; +}); + +Given('local models are available: qwen3:0.5b, gemma3:1b, phi4-mini', () => { + state.localModels = ['qwen3:0.5b', 'gemma3:1b', 'phi4-mini']; +}); + +Given('cloud models are available: gpt-4o, gemini-1.5-flash, claude-3-haiku', () => { + state.cloudModels = ['gpt-4o', 'gemini-1.5-flash', 'claude-3-haiku']; +}); + +Given('I have a saved conversation thread', () => { + state.conversations = [{ id: 'conv-1', title: 'Saved thread', temporary: false, messages: [] }]; +}); + +Given('I have a conversation with text and code messages', () => { + state.clipboardTargets = { + 'user message text': 'user says hello', + 'LLM message text': 'assistant says hello', + 'user code block': 'const userValue = 1;', + 'LLM code block': 'function answer() { return 42; }', + }; +}); + +When('I navigate to the chat page', () => { + assert.equal(state.loggedIn, true); + state.currentPage = 'chat'; +}); + +When('I navigate to the history page', () => { + assert.equal(state.loggedIn, true); + state.currentPage = 'history'; +}); + +When('I navigate to the settings page', () => { + assert.equal(state.loggedIn, true); + state.currentPage = 'settings'; +}); + +When('I navigate to the account page', () => { + assert.equal(state.loggedIn, true); + state.currentPage = 'account'; +}); + +When('I open chat from the navigation', () => { + state.navHistory.push('chat'); + state.currentPage = 'chat'; +}); + +When('I open history from the navigation', () => { + state.navHistory.push('history'); + state.currentPage = 'history'; +}); + +When('I open settings from the navigation', () => { + state.navHistory.push('settings'); + state.currentPage = 'settings'; +}); + +When('I open account from the navigation', () => { + state.navHistory.push('account'); + state.currentPage = 'account'; +}); + +When('I choose the local model {string}', (modelId) => { + assert.ok(state.localModels.includes(modelId)); + state.selectedCategory = 'local'; + state.selectedModel = modelId; +}); + +When('I choose the cloud model {string}', (modelId) => { + assert.ok(state.cloudModels.includes(modelId)); + state.selectedCategory = 'cloud'; + state.selectedModel = modelId; +}); + +When('I ask {string}', (prompt) => { + askPrompt(prompt); +}); + +When('I enable temporary chat', () => { + state.temporaryChatEnabled = true; +}); + +When('I disable temporary chat', () => { + state.temporaryChatEnabled = false; +}); + +When('I try to reopen the previous temporary chat', () => { + state.temporaryChatAttempted = true; +}); + +When('I switch the model category to {string}', (category) => { + assert.ok(['local', 'cloud'].includes(category)); + state.selectedCategory = category; +}); + +When('I save the selected model preference', () => { + state.savedPreference = state.selectedModel; +}); + +When('I start a new session', () => { + state.currentPage = '/'; + state.selectedModel = state.savedPreference; +}); + +When('I retrieve the available model list', () => { + state.apiModels = [ + ...state.localModels.map((id) => ({ id, category: 'local' })), + ...state.cloudModels.map((id) => ({ id, category: 'cloud' })), + ]; +}); + +When('I store cloud credentials for {string}', (modelId) => { + state.storedCredentials.add(modelId); +}); + +When('I clear cloud credentials for {string}', (modelId) => { + state.storedCredentials.delete(modelId); +}); + +When('I delete the saved conversation thread', () => { + state.conversations = state.conversations.filter((item) => item.id !== 'conv-1'); +}); + +When('I attempt to change my password', () => { + state.passwordChangeAttempted = true; +}); + +When('I attempt to delete my account', () => { + state.accountDeleteAttempted = true; +}); + +When('I navigate to the clipboard icon targets', () => { + state.clipboardReady = true; +}); + +When('I copy the {string}', (target) => { + assert.equal(state.clipboardReady, true); + const copied = state.clipboardTargets?.[target]; + assert.ok(copied); + state.clipboardHistory.push(copied); +}); + +When('I stream a response for {string}', (prompt) => { + state.currentPage = 'chat'; + state.selectedModel = state.selectedModel || 'qwen3:0.5b'; + askPrompt(prompt); +}); + +Then('I should see a numeric chat answer', () => { + assert.match(state.lastReply, /\d+/); +}); + +Then('I should see a descriptive weather reply', () => { + assert.match(state.lastReply, /Seattle|cloudy|rain/i); +}); + +Then('the active model label should be {string}', (modelId) => { + assert.equal(state.selectedModel, modelId); + assert.equal(state.lastModelUsed || state.selectedModel, modelId); +}); + +Then('the conversation thread should be removed from history', () => { + assert.equal(state.conversations.some((item) => item.id === 'conv-1'), false); +}); + +Then('the temporary chat should not persist in history', () => { + assert.equal(state.temporaryChatAttempted, true); + assert.equal(state.conversations.some((item) => item.temporary), false); +}); + +Then('the saved model preference should be {string}', (modelId) => { + assert.equal(state.savedPreference, modelId); + assert.equal(state.selectedModel, modelId); +}); + +Then('the API model list should include local models: qwen3:0.5b, gemma3:1b, phi4-mini', () => { + const localIds = state.apiModels.filter((item) => item.category === 'local').map((item) => item.id); + assert.deepEqual(localIds, ['qwen3:0.5b', 'gemma3:1b', 'phi4-mini']); +}); + +Then('the API model list should include cloud models: gpt-4o, gemini-1.5-flash, claude-3-haiku', () => { + const cloudIds = state.apiModels.filter((item) => item.category === 'cloud').map((item) => item.id); + assert.deepEqual(cloudIds, ['gpt-4o', 'gemini-1.5-flash', 'claude-3-haiku']); +}); + +Then('the local model request should use {string}', (modelId) => { + assert.equal(state.lastModelUsed, modelId); +}); + +Then('the cloud model request should use {string}', (modelId) => { + assert.equal(state.lastModelUsed, modelId); +}); + +Then('I should see a missing credentials error', () => { + assert.match(state.lastError, /credentials/i); +}); + +Then('the account management actions should be recorded', () => { + assert.equal(state.currentPage, 'account'); + assert.equal(state.passwordChangeAttempted, true); + assert.equal(state.accountDeleteAttempted, true); +}); + +Then('the clipboard history should include all copied message variants', () => { + assert.equal(state.clipboardHistory.length, 4); + assert.ok(state.clipboardHistory.some((entry) => entry.includes('user says hello'))); + assert.ok(state.clipboardHistory.some((entry) => entry.includes('assistant says hello'))); + assert.ok(state.clipboardHistory.some((entry) => entry.includes('const userValue = 1;'))); + assert.ok(state.clipboardHistory.some((entry) => entry.includes('return 42;'))); +}); + +Then('I should observe a typing indicator while the model is thinking', () => { + assert.equal(state.typingIndicatorObserved, true); +}); + +Then('the response should be streamed character by character', () => { + assert.ok(state.streamedFrames.length > 1); + assert.equal(state.streamedFrames.join(''), state.lastReply); +}); \ No newline at end of file diff --git a/features/temporary_chat.feature b/features/temporary_chat.feature new file mode 100644 index 0000000..5cc791b --- /dev/null +++ b/features/temporary_chat.feature @@ -0,0 +1,10 @@ +Feature: Temporary chat handling + + Scenario: Toggle temporary chat and verify it does not persist + Given I have an authenticated session + When I navigate to the chat page + And I enable temporary chat + And I ask "Remember this temporary note" + And I disable temporary chat + And I try to reopen the previous temporary chat + Then the temporary chat should not persist in history \ No newline at end of file diff --git a/jasmine.json b/jasmine.json index 80cef15..8daa087 100644 --- a/jasmine.json +++ b/jasmine.json @@ -2,12 +2,23 @@ "spec_dir": "tests", "spec_files": [ "app.spec.ts", + "account-ui.spec.ts", "router.spec.ts", "auth-routes.spec.ts", "api-account.spec.ts", "api-auth.spec.ts", + "api-chat-math.spec.ts", + "api-chat-weather.spec.ts", + "chat-ui.spec.ts", + "history-ui.spec.ts", + "home-ui.spec.ts", + "login-ui.spec.ts", + "model-selection-ui.spec.ts", + "api-models.spec.ts", "api-iteration3.spec.ts", "route-modules.spec.ts", + "settings-ui.spec.ts", + "signup-ui.spec.ts", "matrix-rain.spec.ts", "**/*[sS]pec.js" ], diff --git a/jasmine.puppeteer.json b/jasmine.puppeteer.json index 4d0bcbf..18300d8 100644 --- a/jasmine.puppeteer.json +++ b/jasmine.puppeteer.json @@ -1,8 +1,10 @@ { "spec_dir": "tests", + "random": false, "spec_files": [ "puppeteer.spec.ts", "puppeteer-auth.spec.ts", + "puppeteer-models.spec.ts", "puppeteer-visible.spec.ts" ], "helpers": [] diff --git a/package-lock.json b/package-lock.json index 0f5db48..f24457e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -180,6 +180,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -203,6 +204,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -220,6 +222,7 @@ "integrity": "sha512-7A/9CJpJDxv1SQ7hAZU0zPn2yRxx6XMR+LO4T94Enm3cYNWsEEj+RGX38NLX4INT+H6w5raX3Csb/qs4vUBsOA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cucumber/ci-environment": "13.0.0", "@cucumber/cucumber-expressions": "19.0.0", @@ -288,6 +291,7 @@ "integrity": "sha512-duEXK+KDfQUzu3vsSzXjkxQ2tirF5PRsc1Xrts6THKHJO6mjw4RjM8RV+vliuDasmhhrmdLcOcM7d9nurNTJKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cucumber/messages": ">=31.0.0 <33" } @@ -380,6 +384,7 @@ "integrity": "sha512-Kxap9uP5jD8tHUZVjTWgzxemi/0uOsbGjd4LBOSxcJoOCRbESFwemUzilJuzNTB8pcTQUh8D5oudUyxfkJOKmA==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@cucumber/messages": ">=17.1.1" } @@ -390,6 +395,7 @@ "integrity": "sha512-1OSoW+GQvFUNAl6tdP2CTBexTXMNJF0094goVUcvugtQeXtJ0K8sCP0xbq7GGoiezs/eJAAOD03+zAPT64orHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "class-transformer": "0.5.1", "reflect-metadata": "0.2.2" @@ -433,31 +439,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -465,7 +446,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1694,6 +1674,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2413,7 +2394,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/diff": { "version": "4.0.4", @@ -2665,6 +2647,7 @@ "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -4841,6 +4824,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5529,6 +5513,7 @@ "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" @@ -5929,6 +5914,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -5996,6 +5982,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/server.ts b/server.ts index bbf3ad5..da014c7 100755 --- a/server.ts +++ b/server.ts @@ -28,6 +28,7 @@ const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen3:8b'; const OLLAMA_REQUEST_TIMEOUT_MS = Number(process.env.OLLAMA_REQUEST_TIMEOUT_MS || '60000'); const OLLAMA_PROBE_TIMEOUT_MS = Number(process.env.OLLAMA_PROBE_TIMEOUT_MS || '1500'); const OLLAMA_PROBE_TTL_MS = Number(process.env.OLLAMA_PROBE_TTL_MS || '3000'); +const OLLAMA_MODELS_CACHE_TTL_MS = Number(process.env.OLLAMA_MODELS_CACHE_TTL_MS || '3000'); const OPENAI_BASE_URL = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'; const GOOGLE_BASE_URL = process.env.GOOGLE_BASE_URL || 'https://generativelanguage.googleapis.com/v1'; const ANTHROPIC_BASE_URL = process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com/v1'; @@ -52,6 +53,7 @@ const getOllamaUrlCandidates = (baseUrl: string) => { }; let ollamaProbeCache: { ok: boolean; checkedAt: number } | null = null; +let ollamaModelsCache: { modelIds: string[]; checkedAt: number } | null = null; const probeOllama = async () => { const now = Date.now(); @@ -132,14 +134,7 @@ const HttpError = class extends Error { } }; -const MODEL_CATALOG: ModelCatalogItem[] = [ - { id: 'qwen3:0.5b', label: 'qwen3:0.5b', provider: 'ollama', category: 'local' }, - { id: 'qwen3:0.6b', label: 'qwen3:0.6b', provider: 'ollama', category: 'local' }, - { id: 'qwen3:8b', label: 'qwen3:8b', provider: 'ollama', category: 'local' }, - { id: 'qwen2.5:0.5b', label: 'qwen2.5:0.5b', provider: 'ollama', category: 'local' }, - { id: 'qwen2.5:1.5b', label: 'qwen2.5:1.5b', provider: 'ollama', category: 'local' }, - { id: 'qwen2.5:3b', label: 'qwen2.5:3b', provider: 'ollama', category: 'local' }, - { id: 'tinyllama:1.1b', label: 'tinyllama:1.1b', provider: 'ollama', category: 'local' }, +const CLOUD_MODEL_CATALOG: ModelCatalogItem[] = [ { id: 'gpt-4o', label: 'gpt-4o', provider: 'openai', category: 'cloud', envVar: 'OPENAI_API_KEY' }, { id: 'gpt-4o-mini', label: 'gpt-4o-mini', provider: 'openai', category: 'cloud', envVar: 'OPENAI_API_KEY' }, { id: 'gpt-4', label: 'gpt-4', provider: 'openai', category: 'cloud' }, @@ -154,10 +149,72 @@ const MODEL_CATALOG: ModelCatalogItem[] = [ { id: 'claude-3-5-sonnet', label: 'claude-3-5-sonnet', provider: 'anthropic', category: 'cloud', envVar: 'ANTHROPIC_API_KEY' }, ]; -const DEFAULT_MODEL_ID = MODEL_CATALOG.find((m) => m.id === 'qwen3:0.6b')?.id || OLLAMA_MODEL; +const DEFAULT_MODEL_ID = OLLAMA_MODEL; -const getModelById = (id?: string): ModelCatalogItem | undefined => - MODEL_CATALOG.find((m) => m.id === id); +const buildLocalModel = (id: string): ModelCatalogItem => ({ + id, + label: id, + provider: 'ollama', + category: 'local', +}); + +const fetchOllamaModelIdsAt = async (baseUrl: string): Promise => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), OLLAMA_PROBE_TIMEOUT_MS); + + try { + const res = await fetch(`${baseUrl}/api/tags`, { method: 'GET', signal: controller.signal }); + if (!res.ok) { + throw new Error(`Ollama tags request failed with ${res.status}`); + } + + const data = await res.json() as { models?: Array<{ name?: string; model?: string }> }; + const seen = new Set(); + const modelIds: string[] = []; + + for (const entry of data.models || []) { + const id = String(entry?.name || entry?.model || '').trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + modelIds.push(id); + } + + return modelIds; + } finally { + clearTimeout(timeoutId); + } +}; + +const fetchOllamaModelIds = async (): Promise => { + const now = Date.now(); + if (ollamaModelsCache && now - ollamaModelsCache.checkedAt < OLLAMA_MODELS_CACHE_TTL_MS) { + return ollamaModelsCache.modelIds; + } + + const candidates = getOllamaUrlCandidates(OLLAMA_URL); + for (const baseUrl of candidates) { + try { + const modelIds = await fetchOllamaModelIdsAt(baseUrl); + ollamaModelsCache = { modelIds, checkedAt: now }; + return modelIds; + } catch { + // Try the next Ollama candidate. + } + } + + ollamaModelsCache = { modelIds: [], checkedAt: now }; + return []; +}; + +const getModelById = async (id?: string): Promise => { + const normalized = String(id || '').trim(); + if (!normalized) return undefined; + + const cloudModel = CLOUD_MODEL_CATALOG.find((model) => model.id === normalized); + if (cloudModel) return cloudModel; + + return buildLocalModel(normalized); +}; const resolveProviderModelId = (modelId: string) => PROVIDER_MODEL_ALIASES[modelId] || modelId; @@ -173,8 +230,11 @@ const isCloudModelAvailable = (model: ModelCatalogItem, user?: any): boolean => return Boolean(getEffectiveApiKey(user, provider)); }; -const modelsResponse = (user?: any) => - MODEL_CATALOG.map((model) => ({ +const modelsResponse = async (user?: any) => { + const localModels = (await fetchOllamaModelIds()).map(buildLocalModel); + const catalog = [...localModels, ...CLOUD_MODEL_CATALOG]; + + return catalog.map((model) => ({ id: model.id, label: model.label, provider: model.provider, @@ -183,6 +243,7 @@ const modelsResponse = (user?: any) => envVar: model.envVar, available: model.category === 'local' ? true : isCloudModelAvailable(model, user), })); +}; const stripThinkTags = (text: string) => text.replace(/[\s\S]*?<\/think>/g, '').trim(); @@ -552,8 +613,8 @@ app.post('/api/sessions', (req, res, next) => { // Helper for protected routes const authenticateJWT = passport.authenticate('jwt', { session: false }); -app.get('/api/models', authenticateJWT, (req, res) => { - res.json(modelsResponse(req.user)); +app.get('/api/models', authenticateJWT, async (req, res) => { + res.json(await modelsResponse(req.user)); }); app.get('/api/keys/me', authenticateJWT, async (req, res) => { @@ -650,9 +711,11 @@ app.get('/api/settings/me', authenticateJWT, async (req, res) => { try { const userId = (req.user as any)._id; const user = await User.findById(userId).lean(); - const prefs = user?.preferences || {}; + const prefs = (user?.preferences || {}) as { defaultModel?: string; modelCategory?: string }; + const localModels = await fetchOllamaModelIds(); + const preferredDefault = String(prefs.defaultModel || '').trim(); res.json({ - defaultModel: prefs.defaultModel || DEFAULT_MODEL_ID, + defaultModel: preferredDefault || localModels[0] || DEFAULT_MODEL_ID, modelCategory: prefs.modelCategory || 'local', }); } catch (err: any) { @@ -665,7 +728,7 @@ app.put('/api/settings/me', authenticateJWT, async (req, res) => { const userId = (req.user as any)._id; const { defaultModel, modelCategory } = req.body as { defaultModel?: string; modelCategory?: string }; - if (defaultModel && !getModelById(defaultModel)) { + if (defaultModel && !await getModelById(defaultModel)) { return res.status(400).json({ error: 'Unknown model' }); } if (modelCategory && !['local', 'cloud'].includes(modelCategory)) { @@ -750,7 +813,7 @@ app.patch('/api/users/me', authenticateJWT, async (req, res) => { if (typeof preferences.lightMode === 'boolean') updates['preferences.lightMode'] = preferences.lightMode; if (validFonts.includes(preferences.font)) updates['preferences.font'] = preferences.font; if (validColors.includes(preferences.themeColor)) updates['preferences.themeColor'] = preferences.themeColor; - if (preferences.defaultModel && getModelById(preferences.defaultModel)) { + if (preferences.defaultModel && await getModelById(preferences.defaultModel)) { updates['preferences.defaultModel'] = preferences.defaultModel; } if (['local', 'cloud'].includes(preferences.modelCategory)) { @@ -826,7 +889,7 @@ async function queryOllamaAt(baseUrl: string, messages: { role: string; content: inputTokens, outputTokens, tokenCost: inputTokens + outputTokens, - exact: true, + exact: true as const, source: 'ollama', } : undefined, @@ -869,7 +932,7 @@ async function queryOllama(messages: { role: string; content: string }[], modelI } async function generateReply(messages: { role: string; content: string }[], modelId: string, user?: any): Promise { - const model = getModelById(modelId); + const model = await getModelById(modelId); if (!model) { throw new HttpError(400, `Unknown model: ${modelId}`); } @@ -941,7 +1004,7 @@ app.post('/api/chat', authenticateJWT, async (req, res) => { return res.status(400).json({ error: 'message is required' }); } - if (!getModelById(selectedModelId)) { + if (!await getModelById(selectedModelId)) { return res.status(400).json({ error: `Unknown model: ${selectedModelId}` }); } @@ -1006,7 +1069,7 @@ app.post('/api/chat/stream', authenticateJWT, async (req, res) => { return res.status(400).json({ error: 'message is required' }); } - if (!getModelById(selectedModelId)) { + if (!await getModelById(selectedModelId)) { return res.status(400).json({ error: 'Unknown model' }); } @@ -1211,7 +1274,7 @@ app.post('/api/chats', authenticateJWT, async (req, res) => { const prompt = typeof req.body?.prompt === 'string' ? req.body.prompt : ''; const expiresAt = req.body?.expiresAt ? new Date(req.body.expiresAt) : null; - if (!getModelById(modelId)) { + if (!await getModelById(modelId)) { return res.status(400).json({ error: 'Unknown model' }); } diff --git a/src/routes/chat.ts b/src/routes/chat.ts index bf8e77e..124ea21 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -4,7 +4,7 @@ const html = `
-

Chat - qwen3:0.6b

+

Chat