diff --git a/public/chat/welcome_local_llm.md b/public/chat/welcome_local_llm.md new file mode 100644 index 0000000..724767a --- /dev/null +++ b/public/chat/welcome_local_llm.md @@ -0,0 +1,21 @@ +## Local LLM Mode + +Welcome to **K.G.Studio Musician Assistant** in local LLM mode. + +- No external API calls are required. Everything runs directly in your browser, with no extra API cost. +- This mode uses **Gemma 4 E4B** through **LiteRT-LM** with **WebGPU** acceleration. +- Recommended hardware: a GPU with at least **8 GB VRAM** or a system with at least **16 GB unified RAM**. +- Performance is more limited than larger cloud-hosted models, especially on harder planning, editing, and multi-step tasks. + +### Recommended Workflow +- Keep requests small and focused. +- Guide the model step by step toward the final goal. +- Work on smaller music regions instead of large full-song edits. +- Prefer simpler music arrangements when possible. +- Start a new conversation for each standalone task. + +### Use an External LLM Instead +- If you want a larger cloud or self-hosted model, open **Settings -> General -> LLM Provider** and switch away from **Local LLM (Browser)**. +- For a cloud model, you can use **OpenAI**, or choose **OpenAI Compatible** and enter a provider such as OpenRouter. +- For a self-hosted model, choose **OpenAI Compatible** and enter your server's **Base URL** and **Model**. +- After switching providers, start a new conversation so the chat uses the new model cleanly. diff --git a/src/util/messageFilter/UserMessageFilter.test.ts b/src/util/messageFilter/UserMessageFilter.test.ts new file mode 100644 index 0000000..8c50965 --- /dev/null +++ b/src/util/messageFilter/UserMessageFilter.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { processUserMessage } from './UserMessageFilter'; +import { addWelcomeMessage } from '../../utils/chatMessageUtils'; + +const configState = new Map(); + +const configManagerMock = { + getIsInitialized: vi.fn(() => true), + initialize: vi.fn().mockResolvedValue(undefined), + get: vi.fn((key: string) => configState.get(key)), +}; + +vi.mock('../../core/config/ConfigManager', () => ({ + ConfigManager: { + instance: () => configManagerMock, + }, +})); + +vi.mock('../chatUtil', () => ({ + clearChatHistoryAndUI: vi.fn(), +})); + +vi.mock('../../stores/projectStore', () => ({ + useProjectStore: { + getState: () => ({ + setStatus: vi.fn(), + activeRegionId: null, + selectedRegionIds: [], + }), + }, +})); + +vi.mock('../../agent/core/SystemPrompts', () => ({ + SystemPrompts: { + getPromptWithContext: vi.fn(async (value: string) => value), + }, +})); + +describe('processUserMessage /welcome', () => { + beforeEach(() => { + configState.clear(); + configState.set('general.llm_provider', 'local_browser'); + configState.set('general.openai.api_key', ''); + configState.set('general.gemini.api_key', ''); + configState.set('general.claude.api_key', ''); + configState.set('general.claude_openrouter.api_key', ''); + configState.set('general.openai_compatible.base_url', ''); + configState.set('general.openai_compatible.model', ''); + + configManagerMock.getIsInitialized.mockReturnValue(true); + configManagerMock.initialize.mockClear(); + configManagerMock.get.mockClear(); + + vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => { + const url = String(input); + return { + ok: true, + status: 200, + text: async () => `content:${url}`, + }; + })); + }); + + it('uses the local welcome for the local browser provider', async () => { + configState.set('general.llm_provider', 'local_browser'); + configState.set('general.openai.api_key', ''); + configState.set('general.openai_compatible.base_url', ''); + configState.set('general.openai_compatible.model', ''); + + const result = await processUserMessage('/welcome'); + + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/welcome_local_llm.md')); + expect(result.metadata).toMatchObject({ command: 'welcome', variant: 'local' }); + expect(result.pseudoAssistantResponse).toContain('welcome_local_llm.md'); + }); + + it('uses the new-user welcome for non-local providers without required config', async () => { + configState.set('general.llm_provider', 'openai'); + configState.set('general.openai.api_key', ''); + + const result = await processUserMessage('/welcome'); + + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/welcome_new.md')); + expect(result.metadata).toMatchObject({ command: 'welcome', variant: 'new' }); + expect(result.pseudoAssistantResponse).toContain('welcome_new.md'); + }); + + it('uses the returning-user welcome for configured non-local providers', async () => { + configState.set('general.llm_provider', 'openai_compatible'); + configState.set('general.openai_compatible.base_url', 'https://openrouter.ai/api/v1'); + configState.set('general.openai_compatible.model', 'qwen/qwen3-30b-a3b:free'); + + const result = await processUserMessage('/welcome'); + + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/welcome_again.md')); + expect(result.metadata).toMatchObject({ command: 'welcome', variant: 'again' }); + expect(result.pseudoAssistantResponse).toContain('welcome_again.md'); + }); + + it('reuses the same welcome routing through addWelcomeMessage', async () => { + configState.set('general.llm_provider', 'local_browser'); + + const message = await addWelcomeMessage(); + + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('chat/welcome_local_llm.md')); + expect(message?.role).toBe('assistant'); + expect(message?.content).toContain('welcome_local_llm.md'); + }); +}); diff --git a/src/util/messageFilter/UserMessageFilter.ts b/src/util/messageFilter/UserMessageFilter.ts index b314b18..b168f85 100644 --- a/src/util/messageFilter/UserMessageFilter.ts +++ b/src/util/messageFilter/UserMessageFilter.ts @@ -17,6 +17,48 @@ export interface UserMessageFilterResult { metadata?: Record; } +function hasText(value: unknown): boolean { + return typeof value === 'string' && value.trim() !== ''; +} + +function getWelcomeVariant(configManager: ConfigManager): 'local' | 'new' | 'again' { + const provider = (configManager.get('general.llm_provider') as string) || LOCAL_LLM_PROVIDER_KEY; + + if (provider === LOCAL_LLM_PROVIDER_KEY) { + return 'local'; + } + + switch (provider) { + case 'openai': + return hasText(configManager.get('general.openai.api_key')) ? 'again' : 'new'; + case 'gemini': + return hasText(configManager.get('general.gemini.api_key')) ? 'again' : 'new'; + case 'claude': + return hasText(configManager.get('general.claude.api_key')) ? 'again' : 'new'; + case 'claude_openrouter': + return hasText(configManager.get('general.claude_openrouter.api_key')) ? 'again' : 'new'; + case 'openai_compatible': + return hasText(configManager.get('general.openai_compatible.base_url')) + && hasText(configManager.get('general.openai_compatible.model')) + ? 'again' + : 'new'; + default: + return 'new'; + } +} + +function getWelcomeUrl(variant: 'local' | 'new' | 'again'): string { + switch (variant) { + case 'local': + return `${import.meta.env.BASE_URL}chat/welcome_local_llm.md`; + case 'again': + return `${import.meta.env.BASE_URL}chat/welcome_again.md`; + case 'new': + default: + return `${import.meta.env.BASE_URL}chat/welcome_new.md`; + } +} + /** * Process a user message before it is displayed or sent to the LLM. * Handles slash-commands and returns a structured decision. @@ -53,12 +95,8 @@ export async function processUserMessage(originalMessage: string): Promise