diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 4dbff16..8b5503c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,7 +1,7 @@ --- name: Bug Report about: Report a bug or unexpected behavior -title: "[Bug]: " +title: '[Bug]: ' labels: bug assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index aebe037..b6efd2d 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,7 +1,7 @@ --- name: Feature Request about: Suggest a new feature or improvement -title: "[Feature]: " +title: '[Feature]: ' labels: enhancement assignees: '' --- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b2946cc..77e1c16 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Release on: push: tags: - - "v*" + - 'v*' # Ensure only one release runs at a time concurrency: @@ -50,8 +50,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "22" - registry-url: "https://registry.npmjs.org" + node-version: '22' + registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: npm ci diff --git a/.gitignore b/.gitignore index 4b1cfcb..732a8ba 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ out .eslintcache *.log* release +private/ +CLAUDE.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38e988f..bc2cf17 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,12 +13,14 @@ Thank you for your interest in contributing to openwork! This document provides ### Getting Started 1. Fork and clone the repository: + ```bash git clone https://github.com/YOUR_USERNAME/openwork.git cd openwork ``` 2. Install dependencies: + ```bash npm install ``` @@ -86,15 +88,15 @@ openwork uses a tactical/SCADA-inspired design system: ### Colors -| Role | Variable | Hex | -|------|----------|-----| -| Background | `--background` | `#0D0D0F` | -| Elevated | `--background-elevated` | `#141418` | -| Border | `--border` | `#2A2A32` | -| Critical | `--status-critical` | `#E53E3E` | -| Warning | `--status-warning` | `#F59E0B` | -| Nominal | `--status-nominal` | `#22C55E` | -| Info | `--status-info` | `#3B82F6` | +| Role | Variable | Hex | +| ---------- | ----------------------- | --------- | +| Background | `--background` | `#0D0D0F` | +| Elevated | `--background-elevated` | `#141418` | +| Border | `--border` | `#2A2A32` | +| Critical | `--status-critical` | `#E53E3E` | +| Warning | `--status-warning` | `#F59E0B` | +| Nominal | `--status-nominal` | `#22C55E` | +| Info | `--status-info` | `#3B82F6` | ### Typography @@ -145,20 +147,21 @@ Use conventional commits: We use labels to organize issues: -| Label | Description | -|-------|-------------| -| `bug` | Something isn't working | -| `enhancement` | New feature or improvement | -| `good first issue` | Good for newcomers | -| `help wanted` | Extra attention needed | -| `documentation` | Documentation improvements | -| `question` | Further information requested | -| `wontfix` | This will not be worked on | +| Label | Description | +| ------------------ | ----------------------------- | +| `bug` | Something isn't working | +| `enhancement` | New feature or improvement | +| `good first issue` | Good for newcomers | +| `help wanted` | Extra attention needed | +| `documentation` | Documentation improvements | +| `question` | Further information requested | +| `wontfix` | This will not be worked on | ## Questions? Open an issue or start a discussion on GitHub. changes + - `chore:` Build/tooling changes ## Questions? diff --git a/README.md b/README.md index 4d36f2e..a5c5376 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,44 @@ cd openwork npm install npm run dev ``` -Or configure them in-app via the settings panel. ## Supported Models -| Provider | Models | -| --------- | ----------------------------------------------------------------- | +Currently we configure the below provider and models. You can add your own providers and models by configuring them with the +provider/model select button below the UI's chat input box. + +| Provider | Models | +| --------- | -------------------------------------------------------------------------------------- | | Anthropic | Claude Opus 4.5, Claude Sonnet 4.5, Claude Haiku 4.5, Claude Opus 4.1, Claude Sonnet 4 | -| OpenAI | GPT-5.2, GPT-5.1, o3, o3 Mini, o4 Mini, o1, GPT-4.1, GPT-4o | -| Google | Gemini 3 Pro Preview, Gemini 2.5 Pro, Gemini 2.5 Flash, Gemini 2.5 Flash Lite | +| OpenAI | GPT-5.2, GPT-5.1, o3, o3 Mini, o4 Mini, o1, GPT-4.1, GPT-4o | +| Google | Gemini 3 Pro Preview, Gemini 2.5 Pro, Gemini 2.5 Flash, Gemini 2.5 Flash Lite | + +## Configuration + +### API Keys + +Configure API keys using one of these methods: + +**In-App (Recommended):** + +1. Open the Model Switcher (bottom left of chat) +2. Click a provider → "Configure API Key" +3. Enter your API key and save + +**Settings Dialog:** + +1. Click the gear icon to open Settings +2. Enter API keys for each provider + +**Environment Variables:** + +You can also set environment variables directly. Keys are stored in `~/.openwork/.env`. + +| Provider | Environment Variable | +| --------- | -------------------- | +| Anthropic | `ANTHROPIC_API_KEY` | +| OpenAI | `OPENAI_API_KEY` | +| Google | `GOOGLE_API_KEY` | ## Contributing diff --git a/bin/cli.js b/bin/cli.js index ce819a6..37e1aff 100644 --- a/bin/cli.js +++ b/bin/cli.js @@ -43,6 +43,7 @@ const child = spawn(electron, [mainPath, ...args], { }) // Forward signals to child process +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -- JS file function forwardSignal(signal) { if (child.pid) { process.kill(child.pid, signal) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 033ffde..e9099d6 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -3,11 +3,12 @@ import { readFileSync, copyFileSync, existsSync, mkdirSync } from 'fs' import { defineConfig } from 'electron-vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +import type { Plugin } from 'vite' const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')) // Plugin to copy resources to output -function copyResources() { +function copyResources(): Plugin { return { name: 'copy-resources', closeBundle() { diff --git a/eslint.config.mjs b/eslint.config.mjs index 70c950e..aff5d3f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,32 +1,32 @@ -import { defineConfig } from "eslint/config"; -import tseslint from "@electron-toolkit/eslint-config-ts"; -import eslintConfigPrettier from "@electron-toolkit/eslint-config-prettier"; -import eslintPluginReact from "eslint-plugin-react"; -import eslintPluginReactHooks from "eslint-plugin-react-hooks"; -import eslintPluginReactRefresh from "eslint-plugin-react-refresh"; +import { defineConfig } from 'eslint/config' +import tseslint from '@electron-toolkit/eslint-config-ts' +import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier' +import eslintPluginReact from 'eslint-plugin-react' +import eslintPluginReactHooks from 'eslint-plugin-react-hooks' +import eslintPluginReactRefresh from 'eslint-plugin-react-refresh' export default defineConfig( - { ignores: ["**/node_modules", "**/dist", "**/out"] }, + { ignores: ['**/node_modules', '**/dist', '**/out'] }, tseslint.configs.recommended, eslintPluginReact.configs.flat.recommended, - eslintPluginReact.configs.flat["jsx-runtime"], + eslintPluginReact.configs.flat['jsx-runtime'], { settings: { react: { - version: "detect", - }, - }, + version: 'detect' + } + } }, { - files: ["**/*.{ts,tsx}"], + files: ['**/*.{ts,tsx}'], plugins: { - "react-hooks": eslintPluginReactHooks, - "react-refresh": eslintPluginReactRefresh, + 'react-hooks': eslintPluginReactHooks, + 'react-refresh': eslintPluginReactRefresh }, rules: { ...eslintPluginReactHooks.configs.recommended.rules, - ...eslintPluginReactRefresh.configs.vite.rules, - }, + ...eslintPluginReactRefresh.configs.vite.rules + } }, eslintConfigPrettier -); +) diff --git a/resources/README.md b/resources/README.md index 29d22ed..95d141b 100644 --- a/resources/README.md +++ b/resources/README.md @@ -9,6 +9,7 @@ Place your app icon here: ## Creating an Icon You can create an icon using tools like: + - [Figma](https://figma.com) - [IconKitchen](https://icon.kitchen) - [MakeAppIcon](https://makeappicon.com) diff --git a/src/main/agent/runtime.ts b/src/main/agent/runtime.ts index 1073be1..cfc0ca1 100644 --- a/src/main/agent/runtime.ts +++ b/src/main/agent/runtime.ts @@ -1,9 +1,14 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { createDeepAgent } from 'deepagents' -import { getDefaultModel } from '../ipc/models' -import { getApiKey, getCheckpointDbPath } from '../storage' +import { getDefaultModel, getModelConfigById } from '../ipc/models' +import { + getApiKey, + getCheckpointDbPath, + getActiveProviderConfig, + getUserProviders +} from '../storage' import { ChatAnthropic } from '@langchain/anthropic' -import { ChatOpenAI } from '@langchain/openai' +import { ChatOpenAI, AzureChatOpenAI } from '@langchain/openai' import { ChatGoogleGenerativeAI } from '@langchain/google-genai' import { SqlJsSaver } from '../checkpointer/sqljs-saver' import { LocalSandbox } from './local-sandbox' @@ -12,6 +17,8 @@ import type * as _lcTypes from 'langchain' import type * as _lcMessages from '@langchain/core/messages' import type * as _lcLanggraph from '@langchain/langgraph' import type * as _lcZodTypes from '@langchain/core/utils/types' +import type { ProviderId, ProviderApiType } from '../../shared/types' +import { isBuiltInProvider } from '../../shared/providers' import { BASE_SYSTEM_PROMPT } from './system-prompt' @@ -47,51 +54,268 @@ export async function getCheckpointer(): Promise { return checkpointer } -// Get the appropriate model instance based on configuration -function getModelInstance(modelId?: string): ChatAnthropic | ChatOpenAI | ChatGoogleGenerativeAI | string { - const model = modelId || getDefaultModel() - console.log('[Runtime] Using model:', model) - - // Determine provider from model ID - if (model.startsWith('claude')) { - const apiKey = getApiKey('anthropic') - console.log('[Runtime] Anthropic API key present:', !!apiKey) - if (!apiKey) { - throw new Error('Anthropic API key not configured') - } - return new ChatAnthropic({ - model, - anthropicApiKey: apiKey +// Helper to extract base URL and instance name from Azure endpoint/URI +function parseAzureEndpoint(endpoint: string): { baseUrl: string; instanceName: string } { + // Handle full URI like: https://resource.cognitiveservices.azure.com/openai/deployments/... + // Or base URL like: https://resource.openai.azure.com + // Or base URL like: https://resource.cognitiveservices.azure.com + + // Extract base URL (remove path after domain) + const urlMatch = endpoint.match(/^(https?:\/\/[^/]+)/) + const baseUrl = urlMatch ? urlMatch[1] : endpoint + + // Extract instance name from various Azure URL patterns + const instanceMatch = baseUrl.match( + /https?:\/\/([^.]+)\.(openai\.azure\.com|cognitiveservices\.azure\.com)/ + ) + const instanceName = instanceMatch ? instanceMatch[1] : baseUrl + + return { baseUrl, instanceName } +} + +/** + * Create the appropriate LLM instance based on the model configuration. + * + * This function: + * 1. Looks up the ModelConfig by ID to get the actual API model name + * 2. Uses ModelConfig.provider to determine which SDK to use + * 3. Uses ModelConfig.model as the actual model name sent to the API + * 4. Falls back to inferring provider from model ID prefix if not in AVAILABLE_MODELS + * + * @param modelId - The model ID (from AVAILABLE_MODELS or custom) + * @returns An LLM instance configured for the appropriate provider + */ +function getModelInstance( + modelId?: string +): ChatAnthropic | ChatOpenAI | AzureChatOpenAI | ChatGoogleGenerativeAI | string { + const selectedModelId = modelId || getDefaultModel() + console.log('[Runtime] Selected model ID:', selectedModelId) + + // Look up the ModelConfig to get the actual API model name and provider + const modelConfig = getModelConfigById(selectedModelId) + + if (modelConfig) { + // Found in AVAILABLE_MODELS - use the config's model name and provider + console.log('[Runtime] Found ModelConfig:', { + id: modelConfig.id, + model: modelConfig.model, + provider: modelConfig.provider }) - } else if ( - model.startsWith('gpt') || - model.startsWith('o1') || - model.startsWith('o3') || - model.startsWith('o4') - ) { - const apiKey = getApiKey('openai') - console.log('[Runtime] OpenAI API key present:', !!apiKey) - if (!apiKey) { - throw new Error('OpenAI API key not configured') + return createLLMForProvider(modelConfig.provider, modelConfig.model) + } + + // Not in AVAILABLE_MODELS - fall back to inferring provider from ID prefix + // This supports custom models not in the predefined list + console.log('[Runtime] Model not in AVAILABLE_MODELS, inferring provider from ID prefix') + const inferredProvider = inferProviderFromModelId(selectedModelId) + return createLLMForProvider(inferredProvider, selectedModelId) +} + +/** + * Infer the provider from a model ID based on naming conventions. + * Used as fallback when model is not in AVAILABLE_MODELS. + * + * Special format: {providerId}:deployment - for custom provider deployments + */ +function inferProviderFromModelId(modelId: string): ProviderId { + // Check for custom provider deployment format: {uuid}:deployment + if (modelId.endsWith(':deployment')) { + const providerId = modelId.replace(':deployment', '') + // If it looks like a UUID, it's a custom provider + if (providerId.includes('-') && providerId.length > 30) { + return providerId } - return new ChatOpenAI({ - model, - openAIApiKey: apiKey - }) - } else if (model.startsWith('gemini')) { - const apiKey = getApiKey('google') - console.log('[Runtime] Google API key present:', !!apiKey) - if (!apiKey) { - throw new Error('Google API key not configured') + } + + if (modelId.startsWith('claude')) return 'anthropic' + if ( + modelId.startsWith('gpt') || + modelId.startsWith('o1') || + modelId.startsWith('o3') || + modelId.startsWith('o4') + ) + return 'openai' + if (modelId.startsWith('gemini')) return 'google' + // Default to anthropic if we can't determine + return 'anthropic' +} + +/** + * Create an LLM instance for a specific provider with the given model name. + * + * For built-in providers (anthropic, openai, google), uses the provider's API key. + * For custom providers, looks up the provider's apiType and uses the active config. + * + * @param provider - The provider ID (built-in or custom provider UUID) + * @param modelName - The actual model name to send to the API + * @returns An LLM instance configured for the provider + */ +function createLLMForProvider( + provider: ProviderId, + modelName: string +): ChatAnthropic | ChatOpenAI | AzureChatOpenAI | ChatGoogleGenerativeAI | string { + console.log('[Runtime] Creating LLM for provider:', provider, 'with model:', modelName) + + // Check for built-in providers first + if (isBuiltInProvider(provider)) { + return createLLMForBuiltInProvider(provider, modelName) + } + + // Custom provider - look up its apiType and config + return createLLMForCustomProvider(provider, modelName) +} + +/** + * Create LLM for built-in providers (anthropic, openai, google, ollama) + */ +function createLLMForBuiltInProvider( + provider: 'anthropic' | 'openai' | 'google' | 'ollama', + modelName: string +): ChatAnthropic | ChatOpenAI | ChatGoogleGenerativeAI | string { + switch (provider) { + case 'anthropic': { + const apiKey = getApiKey('anthropic') + console.log('[Runtime] Anthropic API key present:', !!apiKey) + if (!apiKey) { + throw new Error('Anthropic API key not configured') + } + return new ChatAnthropic({ + model: modelName, + anthropicApiKey: apiKey + }) } - return new ChatGoogleGenerativeAI({ - model, - apiKey: apiKey - }) + + case 'openai': { + const apiKey = getApiKey('openai') + console.log('[Runtime] OpenAI API key present:', !!apiKey) + if (!apiKey) { + throw new Error('OpenAI API key not configured') + } + return new ChatOpenAI({ + model: modelName, + openAIApiKey: apiKey + }) + } + + case 'google': { + const apiKey = getApiKey('google') + console.log('[Runtime] Google API key present:', !!apiKey) + if (!apiKey) { + throw new Error('Google API key not configured') + } + return new ChatGoogleGenerativeAI({ + model: modelName, + apiKey: apiKey + }) + } + + case 'ollama': + default: { + // Ollama or unknown - return model string and let deepagents handle it + console.log('[Runtime] Ollama/unknown provider, returning model string:', modelName) + return modelName + } + } +} + +/** + * Create LLM for custom providers. + * Looks up the provider's apiType and uses the active config. + */ +function createLLMForCustomProvider( + providerId: string, + modelName: string +): ChatAnthropic | ChatOpenAI | AzureChatOpenAI | ChatGoogleGenerativeAI | string { + console.log('[Runtime] Creating LLM for custom provider:', providerId) + + // Find the custom provider to get its apiType + const userProviders = getUserProviders() + const customProvider = userProviders.find((p) => p.id === providerId) + + if (!customProvider) { + console.log('[Runtime] Custom provider not found, returning model string:', modelName) + return modelName + } + + // Get the active config for this provider + const activeConfig = getActiveProviderConfig(providerId) + if (!activeConfig) { + throw new Error(`No configuration found for provider "${customProvider.name}"`) } - // Default to model string (let deepagents handle it) - return model + const apiType: ProviderApiType = customProvider.apiType + console.log('[Runtime] Custom provider apiType:', apiType, 'config:', { + modelName: activeConfig.config.modelName, + endpoint: activeConfig.config.endpoint, + hasApiKey: !!activeConfig.config.apiKey + }) + + // Create LLM based on apiType + switch (apiType) { + case 'azure': { + // Azure-style API (uses endpoint, api key, model name, optional api version) + const { endpoint, apiKey, apiVersion } = activeConfig.config + if (!endpoint || !apiKey) { + throw new Error( + `Azure configuration incomplete for "${customProvider.name}". Please configure endpoint and API key.` + ) + } + const { instanceName } = parseAzureEndpoint(endpoint) + const deploymentName = activeConfig.config.modelName || modelName + return new AzureChatOpenAI({ + azureOpenAIApiKey: apiKey, + azureOpenAIApiInstanceName: instanceName, + azureOpenAIApiDeploymentName: deploymentName, + azureOpenAIApiVersion: apiVersion || '2024-05-01-preview' + }) + } + + case 'openai': { + // OpenAI-compatible API (uses api key, optional base URL) + const { apiKey, endpoint } = activeConfig.config + if (!apiKey) { + throw new Error(`API key not configured for "${customProvider.name}"`) + } + const llmModel = activeConfig.config.modelName || modelName + return new ChatOpenAI({ + model: llmModel, + openAIApiKey: apiKey, + configuration: endpoint ? { baseURL: endpoint } : undefined + }) + } + + case 'anthropic': { + // Anthropic-compatible API + const { apiKey, endpoint } = activeConfig.config + if (!apiKey) { + throw new Error(`API key not configured for "${customProvider.name}"`) + } + const llmModel = activeConfig.config.modelName || modelName + return new ChatAnthropic({ + model: llmModel, + anthropicApiKey: apiKey, + clientOptions: endpoint ? { baseURL: endpoint } : undefined + }) + } + + case 'google': { + // Google-compatible API + const { apiKey } = activeConfig.config + if (!apiKey) { + throw new Error(`API key not configured for "${customProvider.name}"`) + } + const llmModel = activeConfig.config.modelName || modelName + return new ChatGoogleGenerativeAI({ + model: llmModel, + apiKey: apiKey + }) + } + + default: { + console.log('[Runtime] Unknown apiType, returning model string:', modelName) + return modelName + } + } } export interface CreateAgentRuntimeOptions { diff --git a/src/main/index.ts b/src/main/index.ts index ce7b355..6eb722a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,6 +4,7 @@ import { registerAgentHandlers } from './ipc/agent' import { registerThreadHandlers } from './ipc/threads' import { registerModelHandlers } from './ipc/models' import { initializeDatabase } from './db' +import { migrateEnvToJsonConfigs } from './storage' let mainWindow: BrowserWindow | null = null @@ -47,7 +48,10 @@ function createWindow(): void { }) } +console.log('[OpenWork] Main process starting...') + app.whenReady().then(async () => { + console.log('[OpenWork] App is ready, initializing...') // Set app user model id for windows if (process.platform === 'win32') { app.setAppUserModelId(isDev ? process.execPath : 'com.langchain.openwork') @@ -81,11 +85,43 @@ app.whenReady().then(async () => { // Initialize database await initializeDatabase() + // Migrate existing .env configs to JSON format (runs once) + migrateEnvToJsonConfigs() + // Register IPC handlers registerAgentHandlers(ipcMain) registerThreadHandlers(ipcMain) registerModelHandlers(ipcMain) + // Debug: Log available models and configs on startup + console.log('\n========== OPENWORK STARTUP DEBUG ==========') + const { getAvailableModels } = await import('./ipc/models') + const { getProviderConfigs, hasProviderConfig } = await import('./storage') + + const models = getAvailableModels() + console.log(`[OpenWork] Total available models: ${models.length}`) + + // Group models by provider + const modelsByProvider: Record = {} + for (const m of models) { + if (!modelsByProvider[m.provider]) modelsByProvider[m.provider] = [] + modelsByProvider[m.provider].push(m.id) + } + console.log('[OpenWork] Models by provider:', modelsByProvider) + + // Log provider configs + const providerIds = ['anthropic', 'openai', 'azure', 'google'] + console.log('[OpenWork] Provider configuration status:') + for (const providerId of providerIds) { + const configs = getProviderConfigs(providerId) + const hasConfig = hasProviderConfig(providerId) + console.log( + ` ${providerId}: hasConfig=${hasConfig}, configs=${configs.length}`, + configs.length > 0 ? configs.map((c) => c.name) : '' + ) + } + console.log('=============================================\n') + createWindow() app.on('activate', () => { diff --git a/src/main/ipc/agent.ts b/src/main/ipc/agent.ts index 936ea19..95aff56 100644 --- a/src/main/ipc/agent.ts +++ b/src/main/ipc/agent.ts @@ -14,13 +14,17 @@ export function registerAgentHandlers(ipcMain: IpcMain): void { // Handle agent invocation with streaming ipcMain.on( 'agent:invoke', - async (event, { threadId, message }: { threadId: string; message: string }) => { + async ( + event, + { threadId, message, modelId }: { threadId: string; message: string; modelId?: string } + ) => { const channel = `agent:stream:${threadId}` const window = BrowserWindow.fromWebContents(event.sender) console.log('[Agent] Received invoke request:', { threadId, - message: message.substring(0, 50) + message: message.substring(0, 50), + modelId }) if (!window) { @@ -62,7 +66,7 @@ export function registerAgentHandlers(ipcMain: IpcMain): void { return } - const agent = await createAgentRuntime({ workspacePath }) + const agent = await createAgentRuntime({ workspacePath, modelId }) const humanMessage = new HumanMessage(message) // Stream with both modes: @@ -115,13 +119,14 @@ export function registerAgentHandlers(ipcMain: IpcMain): void { event, { threadId, - command - }: { threadId: string; command: { resume?: { decision?: string } } } + command, + modelId + }: { threadId: string; command: { resume?: { decision?: string } }; modelId?: string } ) => { const channel = `agent:stream:${threadId}` const window = BrowserWindow.fromWebContents(event.sender) - console.log('[Agent] Received resume request:', { threadId, command }) + console.log('[Agent] Received resume request:', { threadId, command, modelId }) if (!window) { console.error('[Agent] No window found for resume') @@ -152,7 +157,7 @@ export function registerAgentHandlers(ipcMain: IpcMain): void { activeRuns.set(threadId, abortController) try { - const agent = await createAgentRuntime({ workspacePath }) + const agent = await createAgentRuntime({ workspacePath, modelId }) const config = { configurable: { thread_id: threadId }, signal: abortController.signal, @@ -193,7 +198,14 @@ export function registerAgentHandlers(ipcMain: IpcMain): void { // Handle HITL interrupt response ipcMain.on( 'agent:interrupt', - async (event, { threadId, decision }: { threadId: string; decision: HITLDecision }) => { + async ( + event, + { + threadId, + decision, + modelId + }: { threadId: string; decision: HITLDecision; modelId?: string } + ) => { const channel = `agent:stream:${threadId}` const window = BrowserWindow.fromWebContents(event.sender) @@ -226,7 +238,7 @@ export function registerAgentHandlers(ipcMain: IpcMain): void { activeRuns.set(threadId, abortController) try { - const agent = await createAgentRuntime({ workspacePath }) + const agent = await createAgentRuntime({ workspacePath, modelId }) const config = { configurable: { thread_id: threadId }, signal: abortController.signal, diff --git a/src/main/ipc/models.ts b/src/main/ipc/models.ts index 64d3b4b..1d08a84 100644 --- a/src/main/ipc/models.ts +++ b/src/main/ipc/models.ts @@ -4,7 +4,34 @@ import * as fs from 'fs/promises' import * as path from 'path' import type { ModelConfig, Provider } from '../types' import { startWatching, stopWatching } from '../services/workspace-watcher' -import { getOpenworkDir, getApiKey, setApiKey, deleteApiKey, hasApiKey } from '../storage' +import { + getOpenworkDir, + getProviderConfig, + setProviderConfig, + deleteProviderConfig, + hasProviderConfig, + getProviderConfigs, + getActiveProviderConfig, + saveProviderConfig, + deleteProviderConfigById, + setActiveProviderConfigId, + getUserModels, + addUserModel, + updateUserModel, + deleteUserModel, + getUserProviders, + addUserProvider, + updateUserProvider, + deleteUserProvider, + type ProviderConfig +} from '../storage' +import type { + SavedProviderConfig, + UserProvider, + ProviderApiType, + ProviderPresetType +} from '../../shared/types' +import { PROVIDER_REGISTRY, isBuiltInProvider } from '../../shared/providers' // Store for non-sensitive settings only (no encryption needed) const store = new Store({ @@ -13,7 +40,8 @@ const store = new Store({ }) // Provider configurations -const PROVIDERS: Omit[] = [ +// Note: modelSelection is added dynamically from PROVIDER_REGISTRY in listProviders handler +const PROVIDERS: Omit[] = [ { id: 'anthropic', name: 'Anthropic' }, { id: 'openai', name: 'OpenAI' }, { id: 'google', name: 'Google' } @@ -189,14 +217,73 @@ const AVAILABLE_MODELS: ModelConfig[] = [ } ] +/** + * Get all models (default + user-added) merged together. + * User models override default models with the same ID. + */ +function getAllModels(): ModelConfig[] { + const defaultModels = [...AVAILABLE_MODELS] + const userModels = getUserModels() + + // Create a map for efficient lookup + const modelMap = new Map() + + // Add default models first + for (const model of defaultModels) { + modelMap.set(model.id, model) + } + + // User models override defaults with same ID, or add new ones + for (const model of userModels) { + modelMap.set(model.id, model) + } + + return Array.from(modelMap.values()) +} + +/** + * Get models for a specific provider. + * For 'multi' providers: returns models from AVAILABLE_MODELS + user models + * For 'deployment' providers: returns nothing (configs ARE the models) + */ +function getModelsForProvider(providerId: string): ModelConfig[] { + // Check if it's a built-in provider + if (isBuiltInProvider(providerId)) { + const provider = PROVIDER_REGISTRY[providerId] + // For deployment-based providers, configs are the models - no dropdown needed + if (provider.modelSelection === 'deployment') { + return [] + } + // For multi-model providers, return all models for this provider + return getAllModels().filter((m) => m.provider === providerId) + } + + // Custom providers are always deployment-based, so return empty + return [] +} + export function registerModelHandlers(ipcMain: IpcMain): void { - // List available models + console.log('[IPC] Registering model handlers') + + // List all available models (default + user-added) ipcMain.handle('models:list', async () => { - // Check which models have API keys configured - return AVAILABLE_MODELS.map((model) => ({ + const allModels = getAllModels() + console.log('[IPC] models:list called, returning', allModels.length, 'models') + // Merge default and user models, check availability + return allModels.map((model) => ({ + ...model, + available: hasProviderConfig(model.provider) + })) + }) + + // List models for a specific provider (for dropdown) + ipcMain.handle('models:listByProvider', async (_event, providerId: string) => { + const models = getModelsForProvider(providerId).map((model) => ({ ...model, - available: hasApiKey(model.provider) + available: hasProviderConfig(model.provider) })) + console.log(`[IPC] models:listByProvider(${providerId}):`, models.length, 'models') + return models }) // Get default model @@ -209,30 +296,167 @@ export function registerModelHandlers(ipcMain: IpcMain): void { store.set('defaultModel', modelId) }) - // Set API key for a provider (stored in ~/.openwork/.env) + // List providers with their configuration status and model selection type + ipcMain.handle('models:listProviders', async () => { + // Get built-in providers + const builtInProviders = PROVIDERS.map((provider) => { + const registry = PROVIDER_REGISTRY[provider.id as keyof typeof PROVIDER_REGISTRY] + const hasKey = hasProviderConfig(provider.id) + return { + ...provider, + hasApiKey: hasKey, + modelSelection: registry?.modelSelection || 'multi', + isCustom: false + } + }) + + // Get custom providers + const customProviders = getUserProviders().map((userProvider) => ({ + id: userProvider.id, + name: userProvider.name, + hasApiKey: hasProviderConfig(userProvider.id), + modelSelection: 'deployment' as const, // Custom providers are always deployment-based + isCustom: true, + apiType: userProvider.apiType, + presetType: userProvider.presetType || 'api' + })) + + const result = [...builtInProviders, ...customProviders] + console.log( + '[IPC] models:listProviders:', + result.map((p) => ({ id: p.id, hasApiKey: p.hasApiKey, isCustom: p.isCustom })) + ) + return result + }) + + // Unified provider config handlers + ipcMain.handle('models:getProviderConfig', async (_event, providerId: string) => { + return getProviderConfig(providerId) ?? null + }) + ipcMain.handle( - 'models:setApiKey', - async (_event, { provider, apiKey }: { provider: string; apiKey: string }) => { - setApiKey(provider, apiKey) + 'models:setProviderConfig', + async (_event, { providerId, config }: { providerId: string; config: ProviderConfig }) => { + setProviderConfig(providerId, config) } ) - // Get API key for a provider (from ~/.openwork/.env or process.env) - ipcMain.handle('models:getApiKey', async (_event, provider: string) => { - return getApiKey(provider) ?? null + ipcMain.handle('models:deleteProviderConfig', async (_event, providerId: string) => { + deleteProviderConfig(providerId) }) - // Delete API key for a provider - ipcMain.handle('models:deleteApiKey', async (_event, provider: string) => { - deleteApiKey(provider) + // ========================================================================== + // Multi-config handlers + // ========================================================================== + + // List all saved configs for a provider + ipcMain.handle('models:listProviderConfigs', async (_event, providerId: string) => { + console.log(`[IPC] models:listProviderConfigs called with providerId: ${providerId}`) + const configs = getProviderConfigs(providerId) + console.log( + `[IPC] models:listProviderConfigs(${providerId}): returning ${configs.length} configs:`, + configs.map((c) => ({ id: c.id, name: c.name })) + ) + return configs }) - // List providers with their API key status - ipcMain.handle('models:listProviders', async () => { - return PROVIDERS.map((provider) => ({ - ...provider, - hasApiKey: hasApiKey(provider.id) - })) + // Get active config for a provider + ipcMain.handle('models:getActiveProviderConfig', async (_event, providerId: string) => { + return getActiveProviderConfig(providerId) ?? null + }) + + // Save a config (create new or update existing) + ipcMain.handle( + 'models:saveProviderConfig', + async (_event, { providerId, config }: { providerId: string; config: SavedProviderConfig }) => { + saveProviderConfig(providerId, config) + } + ) + + // Delete a specific config by ID + ipcMain.handle( + 'models:deleteProviderConfigById', + async (_event, { providerId, configId }: { providerId: string; configId: string }) => { + deleteProviderConfigById(providerId, configId) + } + ) + + // Set active config + ipcMain.handle( + 'models:setActiveProviderConfigId', + async (_event, { providerId, configId }: { providerId: string; configId: string }) => { + setActiveProviderConfigId(providerId, configId) + } + ) + + // ========================================================================== + // User Model handlers (for adding custom models to the dropdown) + // ========================================================================== + + // List user-added models + ipcMain.handle('models:listUserModels', async () => { + return getUserModels() + }) + + // Add a new user model + ipcMain.handle('models:addUserModel', async (_event, model: Omit) => { + return addUserModel(model) + }) + + // Update an existing user model + ipcMain.handle( + 'models:updateUserModel', + async (_event, { modelId, updates }: { modelId: string; updates: Partial }) => { + return updateUserModel(modelId, updates) + } + ) + + // Delete a user model + ipcMain.handle('models:deleteUserModel', async (_event, modelId: string) => { + return deleteUserModel(modelId) + }) + + // ========================================================================== + // User Provider handlers (for custom providers) + // ========================================================================== + + // List all user-created providers + ipcMain.handle('providers:listUserProviders', async () => { + return getUserProviders() + }) + + // Add a new user provider + ipcMain.handle( + 'providers:addUserProvider', + async ( + _event, + { + name, + apiType, + presetType + }: { name: string; apiType: ProviderApiType; presetType: ProviderPresetType } + ) => { + console.log('[IPC] providers:addUserProvider called with:', { name, apiType, presetType }) + const result = addUserProvider(name, apiType, presetType) + console.log('[IPC] providers:addUserProvider result:', result) + return result + } + ) + + // Update an existing user provider + ipcMain.handle( + 'providers:updateUserProvider', + async ( + _event, + { providerId, updates }: { providerId: string; updates: Partial } + ) => { + return updateUserProvider(providerId, updates) + } + ) + + // Delete a user provider + ipcMain.handle('providers:deleteUserProvider', async (_event, providerId: string) => { + return deleteUserProvider(providerId) }) // Sync version info @@ -509,3 +733,22 @@ export { getApiKey } from '../storage' export function getDefaultModel(): string { return store.get('defaultModel', 'claude-sonnet-4-5-20250929') as string } + +/** + * Look up a ModelConfig by its ID. + * Searches both default models and user-added models. + * + * This should be used by the runtime to get the actual API model name + * from ModelConfig.model, rather than using the ID directly. + */ +export function getModelConfigById(modelId: string): ModelConfig | null { + return getAllModels().find((m) => m.id === modelId) || null +} + +/** + * Get the list of all available models (default + user-added). + * Used by the runtime to validate and look up model configurations. + */ +export function getAvailableModels(): ModelConfig[] { + return getAllModels() +} diff --git a/src/main/ipc/threads.ts b/src/main/ipc/threads.ts index 4353dbb..06d48ca 100644 --- a/src/main/ipc/threads.ts +++ b/src/main/ipc/threads.ts @@ -11,7 +11,7 @@ import { getCheckpointer } from '../agent/runtime' import { generateTitle } from '../services/title-generator' import type { Thread } from '../types' -export function registerThreadHandlers(ipcMain: IpcMain) { +export function registerThreadHandlers(ipcMain: IpcMain): void { // List all threads ipcMain.handle('threads:list', async () => { const threads = getAllThreads() @@ -67,9 +67,9 @@ export function registerThreadHandlers(ipcMain: IpcMain) { if (updates.title !== undefined) updateData.title = updates.title if (updates.status !== undefined) updateData.status = updates.status - if (updates.metadata !== undefined) - updateData.metadata = JSON.stringify(updates.metadata) - if (updates.thread_values !== undefined) updateData.thread_values = JSON.stringify(updates.thread_values) + if (updates.metadata !== undefined) updateData.metadata = JSON.stringify(updates.metadata) + if (updates.thread_values !== undefined) + updateData.thread_values = JSON.stringify(updates.thread_values) const row = dbUpdateThread(threadId, updateData) if (!row) throw new Error('Thread not found') @@ -89,7 +89,7 @@ export function registerThreadHandlers(ipcMain: IpcMain) { // Delete a thread ipcMain.handle('threads:delete', async (_event, threadId: string) => { console.log('[Threads] Deleting thread:', threadId) - + // Delete from our metadata store dbDeleteThread(threadId) console.log('[Threads] Deleted from metadata store') diff --git a/src/main/services/title-generator.ts b/src/main/services/title-generator.ts index 7bdaf66..a4311e3 100644 --- a/src/main/services/title-generator.ts +++ b/src/main/services/title-generator.ts @@ -1,45 +1,45 @@ /** * Generate a short, descriptive title from a user's first message. - * + * * Uses heuristics to extract a meaningful title: * - For short messages: use as-is * - For questions: use the first sentence/question * - For longer text: use first N words - * + * * @param message - The user's first message * @returns A short title (max ~50 chars) */ export function generateTitle(message: string): string { // Clean up the message const cleaned = message.trim().replace(/\s+/g, ' ') - + // If already short enough, use as-is if (cleaned.length <= 50) { return cleaned } - + // Try to extract first sentence/question const sentenceMatch = cleaned.match(/^[^.!?]+[.!?]/) if (sentenceMatch && sentenceMatch[0].length <= 60) { return sentenceMatch[0].trim() } - + // Extract first N words const words = cleaned.split(/\s+/) let title = '' - + for (const word of words) { if ((title + ' ' + word).length > 47) { break } title = title ? title + ' ' + word : word } - + // Add ellipsis if we truncated if (words.join(' ').length > title.length) { title += '...' } - + return title } diff --git a/src/main/storage.ts b/src/main/storage.ts index bb20614..e6a0b40 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -1,16 +1,27 @@ import { homedir } from 'os' import { join } from 'path' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { randomUUID } from 'crypto' +import { PROVIDER_REGISTRY, isBuiltInProvider } from '../shared/providers' +import type { + ProviderId, + SavedProviderConfig, + ProviderConfigs, + UserProvider, + ProviderApiType, + ProviderPresetType +} from '../shared/types' const OPENWORK_DIR = join(homedir(), '.openwork') const ENV_FILE = join(OPENWORK_DIR, '.env') +const CONFIGS_FILE = join(OPENWORK_DIR, 'provider-configs.json') +const USER_PROVIDERS_FILE = join(OPENWORK_DIR, 'user-providers.json') -// Environment variable names for each provider -const ENV_VAR_NAMES: Record = { - anthropic: 'ANTHROPIC_API_KEY', - openai: 'OPENAI_API_KEY', - google: 'GOOGLE_API_KEY' -} +// In-memory cache for provider configs +let configsCache: Record | null = null + +// Force clear cache on module load to ensure fresh reads +console.log('[Storage] Module loaded, cache is null') export function getOpenworkDir(): string { if (!existsSync(OPENWORK_DIR)) { @@ -52,52 +63,683 @@ function parseEnvFile(): Record { return result } -// Write object back to .env file -function writeEnvFile(env: Record): void { +// ============================================================================= +// JSON-based Multi-Config Storage +// ============================================================================= + +// Read all provider configs from JSON file +function readConfigsFile(): Record { + if (configsCache) { + console.log('[Storage] Using cached configs, providers:', Object.keys(configsCache)) + return configsCache + } + + const configPath = CONFIGS_FILE + console.log(`[Storage] Cache is null, reading from disk: ${configPath}`) + + if (!existsSync(configPath)) { + console.log('[Storage] Config file does not exist, returning empty') + configsCache = {} + return configsCache + } + + try { + const content = readFileSync(configPath, 'utf-8') + configsCache = JSON.parse(content) + // Log details about what was loaded + for (const [providerId, data] of Object.entries(configsCache!)) { + console.log( + `[Storage] Loaded ${providerId}: ${data.configs?.length || 0} configs, active: ${data.activeConfigId}` + ) + } + return configsCache! + } catch (e) { + console.error('[Storage] Failed to parse provider-configs.json:', e) + configsCache = {} + return configsCache + } +} + +// Write all provider configs to JSON file +function writeConfigsFile(configs: Record): void { getOpenworkDir() // ensure dir exists - const lines = Object.entries(env) - .filter(([_, v]) => v) - .map(([k, v]) => `${k}=${v}`) - writeFileSync(getEnvFilePath(), lines.join('\n') + '\n') + console.log(`[Storage] Writing configs to ${CONFIGS_FILE}`) + writeFileSync(CONFIGS_FILE, JSON.stringify(configs, null, 2)) + configsCache = configs + console.log('[Storage] Configs written and cached') } -// API key management -export function getApiKey(provider: string): string | undefined { - const envVarName = ENV_VAR_NAMES[provider] - if (!envVarName) return undefined +/** + * Migrate existing .env configuration to JSON format. + * Called on first load to preserve existing configs. + */ +export function migrateEnvToJsonConfigs(): void { + // Only migrate if JSON file doesn't exist yet + if (existsSync(CONFIGS_FILE)) return - // Check .env file first const env = parseEnvFile() - if (env[envVarName]) return env[envVarName] + if (Object.keys(env).length === 0) return + + const configs: Record = {} + + for (const [providerId, provider] of Object.entries(PROVIDER_REGISTRY)) { + if (!provider.requiresConfig || provider.fields.length === 0) continue + + // Check if this provider has any env values + const config: Record = {} + let hasValue = false + + for (const field of provider.fields) { + const value = env[field.envVar] || '' + if (value) { + config[field.key] = value + hasValue = true + } + } - // Fall back to process environment - return process.env[envVarName] + if (hasValue) { + // Create a saved config from the .env values + const savedConfig: SavedProviderConfig = { + id: randomUUID(), + name: getDefaultConfigName(providerId as ProviderId, config), + config, + createdAt: new Date().toISOString() + } + + configs[providerId] = { + activeConfigId: savedConfig.id, + configs: [savedConfig] + } + } + } + + if (Object.keys(configs).length > 0) { + writeConfigsFile(configs) + console.log('[Storage] Migrated .env configs to provider-configs.json') + } } -export function setApiKey(provider: string, apiKey: string): void { - const envVarName = ENV_VAR_NAMES[provider] - if (!envVarName) return +// Get a default name for a config based on provider type +function getDefaultConfigName(providerId: ProviderId, config: Record): string { + // Check built-in providers first + if (isBuiltInProvider(providerId)) { + const provider = PROVIDER_REGISTRY[providerId] + if (provider.nameField && config[provider.nameField]) { + return config[provider.nameField] + } + return 'Default' + } - const env = parseEnvFile() - env[envVarName] = apiKey - writeEnvFile(env) + // For custom providers, use the deploymentName field + if (config.deploymentName) { + return config.deploymentName + } - // Also set in process.env for current session - process.env[envVarName] = apiKey + return 'Default' } -export function deleteApiKey(provider: string): void { - const envVarName = ENV_VAR_NAMES[provider] - if (!envVarName) return +/** + * Get all saved configs for a provider. + */ +export function getProviderConfigs(providerId: string): SavedProviderConfig[] { + migrateEnvToJsonConfigs() + const allConfigs = readConfigsFile() + const configs = allConfigs[providerId]?.configs || [] + console.log(`[Storage] getProviderConfigs(${providerId}):`, configs.length, 'configs') + return configs +} - const env = parseEnvFile() - delete env[envVarName] - writeEnvFile(env) +/** + * Get the currently active config for a provider. + */ +export function getActiveProviderConfig(providerId: string): SavedProviderConfig | undefined { + migrateEnvToJsonConfigs() + const allConfigs = readConfigsFile() + const providerConfigs = allConfigs[providerId] + + if (!providerConfigs || providerConfigs.configs.length === 0) { + return undefined + } + + // Find the active config + if (providerConfigs.activeConfigId) { + const active = providerConfigs.configs.find((c) => c.id === providerConfigs.activeConfigId) + if (active) return active + } - // Also clear from process.env - delete process.env[envVarName] + // Fallback to first config if no active set + return providerConfigs.configs[0] } +/** + * Save a new config or update an existing one. + */ +export function saveProviderConfig(providerId: string, config: SavedProviderConfig): void { + console.log(`[Storage] saveProviderConfig(${providerId}):`, config.id, config.name) + migrateEnvToJsonConfigs() + const allConfigs = readConfigsFile() + + if (!allConfigs[providerId]) { + allConfigs[providerId] = { + activeConfigId: config.id, + configs: [] + } + } + + const existingIndex = allConfigs[providerId].configs.findIndex((c) => c.id === config.id) + if (existingIndex >= 0) { + allConfigs[providerId].configs[existingIndex] = config + } else { + allConfigs[providerId].configs.push(config) + } + + // If this is the first config, make it active + if (allConfigs[providerId].configs.length === 1) { + allConfigs[providerId].activeConfigId = config.id + } + + writeConfigsFile(allConfigs) + + // Also update process.env for current session with active config (only for built-in providers) + const activeConfig = getActiveProviderConfig(providerId) + if (activeConfig && isBuiltInProvider(providerId)) { + const provider = PROVIDER_REGISTRY[providerId] + for (const field of provider.fields) { + if (activeConfig.config[field.key] && field.envVar) { + process.env[field.envVar] = activeConfig.config[field.key] + } + } + } + // Custom providers don't use process.env - their configs are accessed directly +} + +/** + * Delete a specific config by ID. + */ +export function deleteProviderConfigById(providerId: string, configId: string): void { + migrateEnvToJsonConfigs() + const allConfigs = readConfigsFile() + + if (!allConfigs[providerId]) return + + allConfigs[providerId].configs = allConfigs[providerId].configs.filter((c) => c.id !== configId) + + // If we deleted the active config, set new active + if (allConfigs[providerId].activeConfigId === configId) { + allConfigs[providerId].activeConfigId = + allConfigs[providerId].configs.length > 0 ? allConfigs[providerId].configs[0].id : null + } + + // If no configs left, remove the provider entry + if (allConfigs[providerId].configs.length === 0) { + delete allConfigs[providerId] + } + + writeConfigsFile(allConfigs) + + // Clear process.env for this provider (only for built-in providers) + if (isBuiltInProvider(providerId)) { + const provider = PROVIDER_REGISTRY[providerId] + for (const field of provider.fields) { + if (field.envVar) { + delete process.env[field.envVar] + } + } + + // Set new active config in process.env if there is one + const activeConfig = getActiveProviderConfig(providerId) + if (activeConfig) { + for (const field of provider.fields) { + if (activeConfig.config[field.key] && field.envVar) { + process.env[field.envVar] = activeConfig.config[field.key] + } + } + } + } + // Custom providers don't use process.env +} + +/** + * Set which config is active for a provider. + */ +export function setActiveProviderConfigId(providerId: string, configId: string): void { + migrateEnvToJsonConfigs() + const allConfigs = readConfigsFile() + + if (!allConfigs[providerId]) return + + // Verify the config exists + const config = allConfigs[providerId].configs.find((c) => c.id === configId) + if (!config) return + + allConfigs[providerId].activeConfigId = configId + writeConfigsFile(allConfigs) + + // Update process.env with the new active config (only for built-in providers) + if (isBuiltInProvider(providerId)) { + const provider = PROVIDER_REGISTRY[providerId] + for (const field of provider.fields) { + if (config.config[field.key] && field.envVar) { + process.env[field.envVar] = config.config[field.key] + } + } + } + // Custom providers don't use process.env +} + +// ============================================================================= +// Unified Provider Config API (uses active config from multi-config storage) +// ============================================================================= + +export type ProviderConfig = Record + +/** + * Get configuration for a provider. + * Returns the active config's values, or undefined if not configured. + */ +export function getProviderConfig(providerId: string): ProviderConfig | undefined { + // For built-in providers, check if they have fields + if (isBuiltInProvider(providerId)) { + const provider = PROVIDER_REGISTRY[providerId] + if (provider.fields.length === 0) return undefined + } + + const activeConfig = getActiveProviderConfig(providerId) + if (!activeConfig) return undefined + + return activeConfig.config +} + +/** + * Set configuration for a provider (legacy - creates/updates single config). + * For multi-config support, use saveProviderConfig instead. + */ +export function setProviderConfig(providerId: string, config: ProviderConfig): void { + // For built-in providers, verify it exists + if (isBuiltInProvider(providerId)) { + const provider = PROVIDER_REGISTRY[providerId] + if (!provider) return + } + // For custom providers, verify it exists in user providers + else if (!isUserProvider(providerId)) { + return + } + + // Get existing active config or create new one + const activeConfig = getActiveProviderConfig(providerId) + + if (activeConfig) { + // Update existing config + activeConfig.config = { ...activeConfig.config, ...config } + saveProviderConfig(providerId, activeConfig) + } else { + // Create new config + const newConfig: SavedProviderConfig = { + id: randomUUID(), + name: getDefaultConfigName(providerId, config), + config, + createdAt: new Date().toISOString() + } + saveProviderConfig(providerId, newConfig) + } +} + +/** + * Delete all configurations for a provider. + */ +export function deleteProviderConfig(providerId: string): void { + migrateEnvToJsonConfigs() + const allConfigs = readConfigsFile() + + if (allConfigs[providerId]) { + delete allConfigs[providerId] + writeConfigsFile(allConfigs) + } + + // Clear process.env for this provider (only for built-in providers) + if (isBuiltInProvider(providerId)) { + const provider = PROVIDER_REGISTRY[providerId] + for (const field of provider.fields) { + if (field.envVar) { + delete process.env[field.envVar] + } + } + } + // Custom providers don't use process.env +} + +/** + * Check if a provider has configuration. + * For providers with multiple required fields, all must be present. + */ +export function hasProviderConfig(providerId: string): boolean { + // Check built-in providers + if (isBuiltInProvider(providerId)) { + const provider = PROVIDER_REGISTRY[providerId] + if (provider.fields.length === 0) return true // Ollama doesn't need config + + const activeConfig = getActiveProviderConfig(providerId) + if (!activeConfig) return false + + // Check that all required fields have values + return provider.fields.every((field) => !!activeConfig.config[field.key]) + } + + // For custom providers, check based on their preset type + const activeConfig = getActiveProviderConfig(providerId) + if (!activeConfig) return false + + // Get the user provider to check its preset type + const userProvider = getUserProviders().find((p) => p.id === providerId) + if (!userProvider) return false + + const presetType = userProvider.presetType || 'api' + + // Check required fields based on preset type + switch (presetType) { + case 'api': + // API preset needs modelName, endpoint, and apiKey (apiVersion optional) + return !!( + activeConfig.config.modelName && + activeConfig.config.endpoint && + activeConfig.config.apiKey + ) + case 'aws-iam': + // AWS IAM preset needs modelName, accessKeyId, secretAccessKey, and region + return !!( + activeConfig.config.modelName && + activeConfig.config.accessKeyId && + activeConfig.config.secretAccessKey && + activeConfig.config.region + ) + case 'google-cloud': + // Google Cloud preset needs modelName, projectId, region, and serviceAccountJson + return !!( + activeConfig.config.modelName && + activeConfig.config.projectId && + activeConfig.config.region && + activeConfig.config.serviceAccountJson + ) + default: + return false + } +} + +// ============================================================================= +// Legacy API (used by runtime - kept for compatibility) +// ============================================================================= + +/** + * @deprecated Use getProviderConfig instead + */ +export function getApiKey(provider: string): string | undefined { + const config = getProviderConfig(provider) + return config?.apiKey +} + +/** + * @deprecated Use setProviderConfig instead + */ +export function setApiKey(provider: string, apiKey: string): void { + setProviderConfig(provider, { apiKey }) +} + +/** + * @deprecated Use deleteProviderConfig instead + */ +export function deleteApiKey(provider: string): void { + deleteProviderConfig(provider) +} + +/** + * @deprecated Use hasProviderConfig instead + */ export function hasApiKey(provider: string): boolean { - return !!getApiKey(provider) + return hasProviderConfig(provider) +} + +// ============================================================================= +// User-Editable Models Storage +// ============================================================================= +// Users can add custom models to the AVAILABLE_MODELS list via the UI. +// These are stored in a separate JSON file and merged with default models. + +import type { ModelConfig } from '../shared/types' + +const USER_MODELS_FILE = join(OPENWORK_DIR, 'user-models.json') + +// In-memory cache for user models +let userModelsCache: ModelConfig[] | null = null + +/** + * Read user-added models from JSON file. + */ +function readUserModelsFile(): ModelConfig[] { + if (userModelsCache !== null) { + return userModelsCache + } + + getOpenworkDir() // Ensure directory exists + + if (!existsSync(USER_MODELS_FILE)) { + userModelsCache = [] + return [] + } + + try { + const content = readFileSync(USER_MODELS_FILE, 'utf-8') + userModelsCache = JSON.parse(content) as ModelConfig[] + return userModelsCache + } catch (e) { + console.error('Failed to read user models file:', e) + userModelsCache = [] + return [] + } +} + +/** + * Write user-added models to JSON file. + */ +function writeUserModelsFile(models: ModelConfig[]): void { + getOpenworkDir() // Ensure directory exists + writeFileSync(USER_MODELS_FILE, JSON.stringify(models, null, 2), 'utf-8') + userModelsCache = models +} + +/** + * Get all user-added models. + */ +export function getUserModels(): ModelConfig[] { + return readUserModelsFile() +} + +/** + * Add a new user model. + * Returns the created model with a generated ID. + */ +export function addUserModel(model: Omit): ModelConfig { + const models = readUserModelsFile() + + const newModel: ModelConfig = { + ...model, + available: true // User-added models are always available + } + + // Check if model with same ID already exists + const existingIndex = models.findIndex((m) => m.id === newModel.id) + if (existingIndex >= 0) { + // Update existing + models[existingIndex] = newModel + } else { + // Add new + models.push(newModel) + } + + writeUserModelsFile(models) + return newModel +} + +/** + * Update an existing user model. + */ +export function updateUserModel( + modelId: string, + updates: Partial +): ModelConfig | null { + const models = readUserModelsFile() + const index = models.findIndex((m) => m.id === modelId) + + if (index < 0) return null + + models[index] = { ...models[index], ...updates } + writeUserModelsFile(models) + return models[index] +} + +/** + * Delete a user model by ID. + */ +export function deleteUserModel(modelId: string): boolean { + const models = readUserModelsFile() + const index = models.findIndex((m) => m.id === modelId) + + if (index < 0) return false + + models.splice(index, 1) + writeUserModelsFile(models) + return true +} + +/** + * Check if a model ID is a user-added model. + */ +export function isUserModel(modelId: string): boolean { + const models = readUserModelsFile() + return models.some((m) => m.id === modelId) +} + +// ============================================================================= +// User-Created Providers Storage +// ============================================================================= +// Users can create custom providers (e.g., AWS Bedrock, Azure AI Foundry variants) +// that are stored separately from the built-in provider registry. + +// In-memory cache for user providers +let userProvidersCache: UserProvider[] | null = null + +/** + * Read user-created providers from JSON file. + */ +function readUserProvidersFile(): UserProvider[] { + if (userProvidersCache !== null) { + return userProvidersCache + } + + getOpenworkDir() // Ensure directory exists + + if (!existsSync(USER_PROVIDERS_FILE)) { + userProvidersCache = [] + return [] + } + + try { + const content = readFileSync(USER_PROVIDERS_FILE, 'utf-8') + userProvidersCache = JSON.parse(content) as UserProvider[] + return userProvidersCache + } catch (e) { + console.error('Failed to read user providers file:', e) + userProvidersCache = [] + return [] + } +} + +/** + * Write user-created providers to JSON file. + */ +function writeUserProvidersFile(providers: UserProvider[]): void { + getOpenworkDir() // Ensure directory exists + writeFileSync(USER_PROVIDERS_FILE, JSON.stringify(providers, null, 2), 'utf-8') + userProvidersCache = providers +} + +/** + * Get all user-created providers. + */ +export function getUserProviders(): UserProvider[] { + return readUserProvidersFile() +} + +/** + * Add a new user provider. + * Returns the created provider with a generated ID. + */ +export function addUserProvider( + name: string, + apiType: ProviderApiType, + presetType: ProviderPresetType = 'api' +): UserProvider { + const providers = readUserProvidersFile() + + const newProvider: UserProvider = { + id: randomUUID(), + name: name.trim(), + apiType, + presetType, + createdAt: new Date().toISOString() + } + + providers.push(newProvider) + writeUserProvidersFile(providers) + return newProvider +} + +/** + * Update an existing user provider. + */ +export function updateUserProvider( + providerId: string, + updates: Partial +): UserProvider | null { + const providers = readUserProvidersFile() + const index = providers.findIndex((p) => p.id === providerId) + + if (index < 0) return null + + // Don't allow changing the ID - destructure to remove it from updates + const { id: _, ...safeUpdates } = updates + void _ + providers[index] = { ...providers[index], ...safeUpdates } + writeUserProvidersFile(providers) + return providers[index] +} + +/** + * Delete a user provider and all its configurations. + */ +export function deleteUserProvider(providerId: string): boolean { + const providers = readUserProvidersFile() + const index = providers.findIndex((p) => p.id === providerId) + + if (index < 0) return false + + // Remove the provider + providers.splice(index, 1) + writeUserProvidersFile(providers) + + // Also delete all configurations for this provider + const allConfigs = readConfigsFile() + if (allConfigs[providerId]) { + delete allConfigs[providerId] + writeConfigsFile(allConfigs) + } + + return true +} + +/** + * Check if a provider ID is a user-created provider. + */ +export function isUserProvider(providerId: string): boolean { + const providers = readUserProvidersFile() + return providers.some((p) => p.id === providerId) } diff --git a/src/main/types.ts b/src/main/types.ts index 79fb097..b0c69e3 100644 --- a/src/main/types.ts +++ b/src/main/types.ts @@ -1,3 +1,6 @@ +// Re-export shared types +export type { ProviderId, Provider, ModelConfig, AzureConfig } from '../shared/types' + // Thread types matching langgraph-api export type ThreadStatus = 'idle' | 'busy' | 'interrupted' | 'error' @@ -24,25 +27,6 @@ export interface Run { metadata?: Record } -// Provider configuration -export type ProviderId = 'anthropic' | 'openai' | 'google' | 'ollama' - -export interface Provider { - id: ProviderId - name: string - hasApiKey: boolean -} - -// Model configuration -export interface ModelConfig { - id: string - name: string - provider: ProviderId - model: string - description?: string - available: boolean -} - // Subagent types (from deepagentsjs) export interface Subagent { id: string diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 3ad4545..3d5583d 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,4 +1,12 @@ -import type { Thread, ModelConfig, StreamEvent, HITLDecision } from '../main/types' +import type { Thread, ModelConfig, Provider, StreamEvent, HITLDecision } from '../main/types' +import type { + SavedProviderConfig, + UserProvider, + ProviderApiType, + ProviderPresetType +} from '../shared/types' + +type ProviderConfig = Record interface ElectronAPI { ipcRenderer: { @@ -15,16 +23,23 @@ interface ElectronAPI { interface CustomAPI { agent: { - invoke: (threadId: string, message: string, onEvent: (event: StreamEvent) => void) => () => void + invoke: ( + threadId: string, + message: string, + modelId: string | undefined, + onEvent: (event: StreamEvent) => void + ) => () => void streamAgent: ( threadId: string, message: string, command: unknown, + modelId: string | undefined, onEvent: (event: StreamEvent) => void ) => () => void interrupt: ( threadId: string, decision: HITLDecision, + modelId: string | undefined, onEvent?: (event: StreamEvent) => void ) => () => void cancel: (threadId: string) => Promise @@ -42,10 +57,36 @@ interface CustomAPI { list: () => Promise listProviders: () => Promise getDefault: () => Promise - deleteApiKey: (provider: string) => Promise setDefault: (modelId: string) => Promise - setApiKey: (provider: string, apiKey: string) => Promise - getApiKey: (provider: string) => Promise + getProviderConfig: (providerId: string) => Promise + setProviderConfig: (providerId: string, config: ProviderConfig) => Promise + deleteProviderConfig: (providerId: string) => Promise + // Multi-config methods + listProviderConfigs: (providerId: string) => Promise + getActiveProviderConfigById: (providerId: string) => Promise + saveProviderConfigById: (providerId: string, config: SavedProviderConfig) => Promise + deleteProviderConfigById: (providerId: string, configId: string) => Promise + setActiveProviderConfigId: (providerId: string, configId: string) => Promise + // Model list methods + listByProvider: (providerId: string) => Promise + // User model methods + listUserModels: () => Promise + addUserModel: (model: Omit) => Promise + updateUserModel: (modelId: string, updates: Partial) => Promise + deleteUserModel: (modelId: string) => Promise + } + providers: { + listUserProviders: () => Promise + addUserProvider: ( + name: string, + apiType: ProviderApiType, + presetType: ProviderPresetType + ) => Promise + updateUserProvider: ( + providerId: string, + updates: Partial + ) => Promise + deleteUserProvider: (providerId: string) => Promise } workspace: { get: (threadId?: string) => Promise @@ -62,14 +103,20 @@ interface CustomAPI { workspacePath?: string error?: string }> - readFile: (threadId: string, filePath: string) => Promise<{ + readFile: ( + threadId: string, + filePath: string + ) => Promise<{ success: boolean content?: string size?: number modified_at?: string error?: string }> - readBinaryFile: (threadId: string, filePath: string) => Promise<{ + readBinaryFile: ( + threadId: string, + filePath: string + ) => Promise<{ success: boolean content?: string size?: number diff --git a/src/preload/index.ts b/src/preload/index.ts index a701282..52a2108 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,13 @@ import { contextBridge, ipcRenderer } from 'electron' import type { Thread, ModelConfig, Provider, StreamEvent, HITLDecision } from '../main/types' +import type { + SavedProviderConfig, + UserProvider, + ProviderApiType, + ProviderPresetType +} from '../shared/types' + +type ProviderConfig = Record // Simple electron API - replaces @electron-toolkit/preload const electronAPI = { @@ -27,6 +35,7 @@ const api = { invoke: ( threadId: string, message: string, + modelId: string | undefined, onEvent: (event: StreamEvent) => void ): (() => void) => { const channel = `agent:stream:${threadId}` @@ -39,7 +48,7 @@ const api = { } ipcRenderer.on(channel, handler) - ipcRenderer.send('agent:invoke', { threadId, message }) + ipcRenderer.send('agent:invoke', { threadId, message, modelId }) // Return cleanup function return () => { @@ -51,6 +60,7 @@ const api = { threadId: string, message: string, command: unknown, + modelId: string | undefined, onEvent: (event: StreamEvent) => void ): (() => void) => { const channel = `agent:stream:${threadId}` @@ -66,9 +76,9 @@ const api = { // If we have a command, it might be a resume/retry if (command) { - ipcRenderer.send('agent:resume', { threadId, command }) + ipcRenderer.send('agent:resume', { threadId, command, modelId }) } else { - ipcRenderer.send('agent:invoke', { threadId, message }) + ipcRenderer.send('agent:invoke', { threadId, message, modelId }) } // Return cleanup function @@ -79,6 +89,7 @@ const api = { interrupt: ( threadId: string, decision: HITLDecision, + modelId: string | undefined, onEvent?: (event: StreamEvent) => void ): (() => void) => { const channel = `agent:stream:${threadId}` @@ -91,7 +102,7 @@ const api = { } ipcRenderer.on(channel, handler) - ipcRenderer.send('agent:interrupt', { threadId, decision }) + ipcRenderer.send('agent:interrupt', { threadId, decision, modelId }) // Return cleanup function return () => { @@ -138,14 +149,71 @@ const api = { setDefault: (modelId: string): Promise => { return ipcRenderer.invoke('models:setDefault', modelId) }, - setApiKey: (provider: string, apiKey: string): Promise => { - return ipcRenderer.invoke('models:setApiKey', { provider, apiKey }) + getProviderConfig: (providerId: string): Promise => { + return ipcRenderer.invoke('models:getProviderConfig', providerId) + }, + setProviderConfig: (providerId: string, config: ProviderConfig): Promise => { + return ipcRenderer.invoke('models:setProviderConfig', { providerId, config }) + }, + deleteProviderConfig: (providerId: string): Promise => { + return ipcRenderer.invoke('models:deleteProviderConfig', providerId) + }, + // Multi-config methods + listProviderConfigs: (providerId: string): Promise => { + return ipcRenderer.invoke('models:listProviderConfigs', providerId) + }, + getActiveProviderConfigById: (providerId: string): Promise => { + return ipcRenderer.invoke('models:getActiveProviderConfig', providerId) + }, + saveProviderConfigById: (providerId: string, config: SavedProviderConfig): Promise => { + return ipcRenderer.invoke('models:saveProviderConfig', { providerId, config }) + }, + deleteProviderConfigById: (providerId: string, configId: string): Promise => { + return ipcRenderer.invoke('models:deleteProviderConfigById', { providerId, configId }) + }, + setActiveProviderConfigId: (providerId: string, configId: string): Promise => { + return ipcRenderer.invoke('models:setActiveProviderConfigId', { providerId, configId }) + }, + // Model list methods + listByProvider: (providerId: string): Promise => { + return ipcRenderer.invoke('models:listByProvider', providerId) }, - getApiKey: (provider: string): Promise => { - return ipcRenderer.invoke('models:getApiKey', provider) + // User model methods + listUserModels: (): Promise => { + return ipcRenderer.invoke('models:listUserModels') }, - deleteApiKey: (provider: string): Promise => { - return ipcRenderer.invoke('models:deleteApiKey', provider) + addUserModel: (model: Omit): Promise => { + return ipcRenderer.invoke('models:addUserModel', model) + }, + updateUserModel: ( + modelId: string, + updates: Partial + ): Promise => { + return ipcRenderer.invoke('models:updateUserModel', { modelId, updates }) + }, + deleteUserModel: (modelId: string): Promise => { + return ipcRenderer.invoke('models:deleteUserModel', modelId) + } + }, + providers: { + listUserProviders: (): Promise => { + return ipcRenderer.invoke('providers:listUserProviders') + }, + addUserProvider: ( + name: string, + apiType: ProviderApiType, + presetType: ProviderPresetType + ): Promise => { + return ipcRenderer.invoke('providers:addUserProvider', { name, apiType, presetType }) + }, + updateUserProvider: ( + providerId: string, + updates: Partial + ): Promise => { + return ipcRenderer.invoke('providers:updateUserProvider', { providerId, updates }) + }, + deleteUserProvider: (providerId: string): Promise => { + return ipcRenderer.invoke('providers:deleteUserProvider', providerId) } }, workspace: { @@ -158,7 +226,9 @@ const api = { select: (threadId?: string): Promise => { return ipcRenderer.invoke('workspace:select', threadId) }, - loadFromDisk: (threadId: string): Promise<{ + loadFromDisk: ( + threadId: string + ): Promise<{ success: boolean files: Array<{ path: string @@ -171,7 +241,10 @@ const api = { }> => { return ipcRenderer.invoke('workspace:loadFromDisk', { threadId }) }, - readFile: (threadId: string, filePath: string): Promise<{ + readFile: ( + threadId: string, + filePath: string + ): Promise<{ success: boolean content?: string size?: number @@ -180,7 +253,10 @@ const api = { }> => { return ipcRenderer.invoke('workspace:readFile', { threadId, filePath }) }, - readBinaryFile: (threadId: string, filePath: string): Promise<{ + readBinaryFile: ( + threadId: string, + filePath: string + ): Promise<{ success: boolean content?: string size?: number diff --git a/src/renderer/src/components/chat/AddProviderDialog.tsx b/src/renderer/src/components/chat/AddProviderDialog.tsx new file mode 100644 index 0000000..499aa25 --- /dev/null +++ b/src/renderer/src/components/chat/AddProviderDialog.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { API_TYPE_INFO, PRESET_INFO } from '../../../../shared/providers' +import type { ProviderApiType, ProviderPresetType } from '@/types' + +interface AddProviderDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSave: (name: string, apiType: ProviderApiType, presetType: ProviderPresetType) => void +} + +const API_TYPES: ProviderApiType[] = ['openai', 'anthropic', 'google', 'azure'] +const PRESET_TYPES: ProviderPresetType[] = ['api', 'aws-iam', 'google-cloud'] + +export function AddProviderDialog({ + open, + onOpenChange, + onSave +}: AddProviderDialogProps): React.ReactElement { + const [name, setName] = useState('') + const [apiType, setApiType] = useState('openai') + const [presetType, setPresetType] = useState('api') + + function handleSubmit(e: React.FormEvent): void { + e.preventDefault() + if (name.trim()) { + onSave(name.trim(), apiType, presetType) + // Reset form + setName('') + setApiType('openai') + setPresetType('api') + } + } + + function handleClose(): void { + setName('') + setApiType('openai') + setPresetType('api') + onOpenChange(false) + } + + return ( + + + + Add Custom Provider + + Create a custom provider for services like AWS Bedrock, Azure AI Foundry, Together.ai, + Vertex AI, or other LLM APIs. + + + +
+ {/* Provider Name */} +
+ + setName(e.target.value)} + placeholder="e.g., AWS Bedrock, My Local LLM" + autoFocus + /> +
+ + {/* Preset Type (Authentication Pattern) */} +
+ +

How you authenticate with this provider

+
+ {PRESET_TYPES.map((type) => { + const info = PRESET_INFO[type] + const isSelected = presetType === type + return ( + + ) + })} +
+
+ + {/* API Type (only show for 'api' preset) */} + {presetType === 'api' && ( +
+ +

+ Which API format does this provider use? This determines how requests are sent. +

+
+ {API_TYPES.map((type) => { + const info = API_TYPE_INFO[type] + const isSelected = apiType === type + return ( + + ) + })} +
+
+ )} + +
+ + +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/chat/ApiKeyDialog.tsx b/src/renderer/src/components/chat/ApiKeyDialog.tsx deleted file mode 100644 index d89ffed..0000000 --- a/src/renderer/src/components/chat/ApiKeyDialog.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { useState, useEffect } from 'react' -import { Eye, EyeOff, Loader2, Trash2 } from 'lucide-react' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { useAppStore } from '@/lib/store' -import type { Provider } from '@/types' - -interface ApiKeyDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - provider: Provider | null -} - -const PROVIDER_INFO: Record = { - anthropic: { placeholder: 'sk-ant-...', envVar: 'ANTHROPIC_API_KEY' }, - openai: { placeholder: 'sk-...', envVar: 'OPENAI_API_KEY' }, - google: { placeholder: 'AIza...', envVar: 'GOOGLE_API_KEY' } -} - -export function ApiKeyDialog({ open, onOpenChange, provider }: ApiKeyDialogProps) { - const [apiKey, setApiKey] = useState('') - const [showKey, setShowKey] = useState(false) - const [saving, setSaving] = useState(false) - const [deleting, setDeleting] = useState(false) - const [hasExistingKey, setHasExistingKey] = useState(false) - - const { setApiKey: saveApiKey, deleteApiKey } = useAppStore() - - // Check if there's an existing key when dialog opens - useEffect(() => { - if (open && provider) { - setHasExistingKey(provider.hasApiKey) - setApiKey('') - setShowKey(false) - } - }, [open, provider]) - - if (!provider) return null - - const info = PROVIDER_INFO[provider.id] || { placeholder: '...', envVar: '' } - - async function handleSave() { - if (!apiKey.trim()) return - if (!provider) return - - console.log('[ApiKeyDialog] Saving API key for provider:', provider.id) - setSaving(true) - try { - await saveApiKey(provider.id, apiKey.trim()) - console.log('[ApiKeyDialog] API key saved successfully') - onOpenChange(false) - } catch (e) { - console.error('[ApiKeyDialog] Failed to save API key:', e) - } finally { - setSaving(false) - } - } - - async function handleDelete() { - if (!provider) return - setDeleting(true) - try { - await deleteApiKey(provider.id) - onOpenChange(false) - } catch (e) { - console.error('Failed to delete API key:', e) - } finally { - setDeleting(false) - } - } - - return ( - - - - - {hasExistingKey ? `Update ${provider.name} API Key` : `Add ${provider.name} API Key`} - - - {hasExistingKey - ? 'Enter a new API key to replace the existing one, or remove it.' - : `Enter your ${provider.name} API key to use their models.` - } - - - -
-
-
- setApiKey(e.target.value)} - placeholder={hasExistingKey ? '••••••••••••••••' : info.placeholder} - className="pr-10" - autoFocus - /> - -
-

- Environment variable: {info.envVar} -

-
-
- -
- {hasExistingKey ? ( - - ) : ( -
- )} -
- - -
-
- -
- ) -} diff --git a/src/renderer/src/components/chat/ChatContainer.tsx b/src/renderer/src/components/chat/ChatContainer.tsx index 0cb453d..36ea2b8 100644 --- a/src/renderer/src/components/chat/ChatContainer.tsx +++ b/src/renderer/src/components/chat/ChatContainer.tsx @@ -70,6 +70,9 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme todos, errorByThread, workspacePath, + models, + providers, + currentModel, setTodos, setWorkspaceFiles, setWorkspacePath, @@ -83,6 +86,27 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme clearThreadError } = useAppStore() + // Check if we have a valid, usable model configured + // Use models.length === 0 as a proxy for "still loading" to avoid subscribing to modelsLoading + const isInitialLoad = models.length === 0 && providers.length === 0 + + // Check for custom provider deployment format: {providerId}:deployment + const isCustomDeployment = currentModel?.endsWith(':deployment') + const customProviderId = isCustomDeployment ? currentModel.replace(':deployment', '') : null + const customProvider = customProviderId ? providers.find((p) => p.id === customProviderId) : null + + const selectedModel = models.find((m) => m.id === currentModel) + const currentModelProvider = selectedModel + ? providers.find((p) => p.id === selectedModel.provider) + : customProvider + + // While loading, assume we might have a valid model (prevents flash) + // Valid if: regular model with API key OR custom deployment with API key + const hasValidModel = + isInitialLoad || + !!(selectedModel && currentModelProvider?.hasApiKey) || + !!(customProvider && customProvider.hasApiKey) + // Get error for current thread const threadError = errorByThread[threadId] || null @@ -178,22 +202,25 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme }) // Handle approval decision - use stream.submit with resume command - const handleApprovalDecision = useCallback(async (decision: 'approve' | 'reject' | 'edit') => { - if (!pendingApproval) return + const handleApprovalDecision = useCallback( + async (decision: 'approve' | 'reject' | 'edit') => { + if (!pendingApproval) return - // Clear pending approval first - setPendingApproval(null) + // Clear pending approval first + setPendingApproval(null) - // Submit with a resume command - the transport will send to agent:resume - try { - await stream.submit( - null, // No message needed for resume - { command: { resume: { decision } } } - ) - } catch (err) { - console.error('[ChatContainer] Resume command failed:', err) - } - }, [pendingApproval, setPendingApproval, stream]) + // Submit with a resume command - the transport will send to agent:resume + try { + await stream.submit( + null, // No message needed for resume + { command: { resume: { decision } } } + ) + } catch (err) { + console.error('[ChatContainer] Resume command failed:', err) + } + }, + [pendingApproval, setPendingApproval, stream] + ) // Sync todos from stream state const agentValues = stream.values as AgentStreamValues | undefined @@ -270,7 +297,8 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme content: typeof streamMsg.content === 'string' ? streamMsg.content : '', tool_calls: streamMsg.tool_calls, // Include tool_call_id and name for tool messages - ...(role === 'tool' && streamMsg.tool_call_id && { tool_call_id: streamMsg.tool_call_id }), + ...(role === 'tool' && + streamMsg.tool_call_id && { tool_call_id: streamMsg.tool_call_id }), ...(role === 'tool' && streamMsg.name && { name: streamMsg.name }), created_at: new Date() } @@ -312,7 +340,8 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme content: typeof streamMsg.content === 'string' ? streamMsg.content : '', tool_calls: streamMsg.tool_calls, // Include tool_call_id and name for tool messages - ...(role === 'tool' && streamMsg.tool_call_id && { tool_call_id: streamMsg.tool_call_id }), + ...(role === 'tool' && + streamMsg.tool_call_id && { tool_call_id: streamMsg.tool_call_id }), ...(role === 'tool' && streamMsg.name && { name: streamMsg.name }), created_at: new Date() } @@ -390,7 +419,7 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme const handleSubmit = async (e: React.FormEvent): Promise => { e.preventDefault() - if (!input.trim() || stream.isLoading) return + if (!input.trim() || stream.isLoading || !hasValidModel) return // Check if workspace is selected if (!workspacePath) { @@ -502,9 +531,9 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme )} {displayMessages.map((message) => ( - setInput(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Message..." - disabled={stream.isLoading} + placeholder={hasValidModel ? 'Message...' : 'Configure a model to start...'} + disabled={stream.isLoading || !hasValidModel} className="flex-1 min-w-0 resize-none rounded-sm border border-border bg-background px-4 py-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50" rows={1} style={{ minHeight: '48px', maxHeight: '200px' }} @@ -570,7 +599,13 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme ) : ( - )} @@ -584,7 +619,6 @@ export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Eleme - ) } diff --git a/src/renderer/src/components/chat/ChatTodos.tsx b/src/renderer/src/components/chat/ChatTodos.tsx index 1fee68f..ee41122 100644 --- a/src/renderer/src/components/chat/ChatTodos.tsx +++ b/src/renderer/src/components/chat/ChatTodos.tsx @@ -29,8 +29,8 @@ export function ChatTodos({ todos }: ChatTodosProps): React.JSX.Element | null { if (todos.length === 0) return null // Separate active and completed todos - const activeTodos = todos.filter(t => t.status === 'in_progress' || t.status === 'pending') - const completedCount = todos.filter(t => t.status === 'completed').length + const activeTodos = todos.filter((t) => t.status === 'in_progress' || t.status === 'pending') + const completedCount = todos.filter((t) => t.status === 'completed').length const totalCount = todos.length // Calculate progress diff --git a/src/renderer/src/components/chat/MessageBubble.tsx b/src/renderer/src/components/chat/MessageBubble.tsx index e4dbf79..f302f88 100644 --- a/src/renderer/src/components/chat/MessageBubble.tsx +++ b/src/renderer/src/components/chat/MessageBubble.tsx @@ -17,7 +17,13 @@ interface MessageBubbleProps { onApprovalDecision?: (decision: 'approve' | 'reject' | 'edit') => void } -export function MessageBubble({ message, isStreaming, toolResults, pendingApproval, onApprovalDecision }: MessageBubbleProps) { +export function MessageBubble({ + message, + isStreaming, + toolResults, + pendingApproval, + onApprovalDecision +}: MessageBubbleProps): React.ReactElement | null { const isUser = message.role === 'user' const isTool = message.role === 'tool' @@ -26,17 +32,17 @@ export function MessageBubble({ message, isStreaming, toolResults, pendingApprov return null } - const getIcon = () => { + const getIcon = (): React.ReactElement => { if (isUser) return return } - const getLabel = () => { + const getLabel = (): string => { if (isUser) return 'YOU' return 'AGENT' } - const renderContent = () => { + const renderContent = (): React.ReactNode => { if (typeof message.content === 'string') { // Empty content if (!message.content.trim()) { @@ -45,38 +51,32 @@ export function MessageBubble({ message, isStreaming, toolResults, pendingApprov // Use streaming markdown for assistant messages, plain text for user messages if (isUser) { - return ( -
- {message.content} -
- ) + return
{message.content}
} - return ( - - {message.content} - - ) + return {message.content} } // Handle content blocks - const renderedBlocks = message.content.map((block, index) => { - if (block.type === 'text' && block.text) { - // Use streaming markdown for assistant text blocks - if (isUser) { + const renderedBlocks = message.content + .map((block, index) => { + if (block.type === 'text' && block.text) { + // Use streaming markdown for assistant text blocks + if (isUser) { + return ( +
+ {block.text} +
+ ) + } return ( -
+ {block.text} -
+ ) } - return ( - - {block.text} - - ) - } - return null - }).filter(Boolean) + return null + }) + .filter(Boolean) return renderedBlocks.length > 0 ? renderedBlocks : null } @@ -102,18 +102,12 @@ export function MessageBubble({ message, isStreaming, toolResults, pendingApprov {/* Content column - always same width */}
-
- {getLabel()} -
+
{getLabel()}
{content && ( -
+
{content}
)} diff --git a/src/renderer/src/components/chat/ModelSwitcher.tsx b/src/renderer/src/components/chat/ModelSwitcher.tsx index 5b32014..0e1b705 100644 --- a/src/renderer/src/components/chat/ModelSwitcher.tsx +++ b/src/renderer/src/components/chat/ModelSwitcher.tsx @@ -1,161 +1,690 @@ -import { useState, useEffect } from 'react' -import { ChevronDown, Check, AlertCircle, Key } from 'lucide-react' +import { useState, useEffect, useRef, useCallback, forwardRef } from 'react' +import { + ChevronDown, + Check, + AlertCircle, + Key, + Plus, + Pencil, + RefreshCw, + Settings, + Loader2, + Trash2 +} from 'lucide-react' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Button } from '@/components/ui/button' import { useAppStore } from '@/lib/store' import { cn } from '@/lib/utils' -import { ApiKeyDialog } from './ApiKeyDialog' -import type { Provider, ProviderId } from '@/types' +import { ProviderConfigDialog } from './ProviderConfigDialog' +import { AddProviderDialog } from './AddProviderDialog' +import type { + Provider, + ProviderId, + SavedProviderConfig, + ModelConfig, + ProviderApiType, + ProviderPresetType, + UserProvider +} from '@/types' // Provider icons as simple SVG components -function AnthropicIcon({ className }: { className?: string }) { +function AnthropicIcon({ className }: { className?: string }): React.ReactElement { return ( - + ) } -function OpenAIIcon({ className }: { className?: string }) { +function OpenAIIcon({ className }: { className?: string }): React.ReactElement { return ( - + ) } -function GoogleIcon({ className }: { className?: string }) { +function GoogleIcon({ className }: { className?: string }): React.ReactElement { return ( - + ) } -const PROVIDER_ICONS: Record> = { +// Azure AI Foundry icon (monochrome, uses currentColor) +function AzureFoundryIcon({ className }: { className?: string }): React.ReactElement { + return ( + + + + + + ) +} + +// Built-in provider icons - custom providers won't have icons +const PROVIDER_ICONS: Record> = { anthropic: AnthropicIcon, openai: OpenAIIcon, + azure: AzureFoundryIcon, google: GoogleIcon, ollama: () => null // No icon for ollama yet } +// Generic icon for custom providers +function CustomProviderIcon({ className }: { className?: string }): React.ReactElement { + return ( + + + + + ) +} + // Fallback providers in case the backend hasn't loaded them yet const FALLBACK_PROVIDERS: Provider[] = [ - { id: 'anthropic', name: 'Anthropic', hasApiKey: false }, - { id: 'openai', name: 'OpenAI', hasApiKey: false }, - { id: 'google', name: 'Google', hasApiKey: false } + { id: 'anthropic', name: 'Anthropic', hasApiKey: false, modelSelection: 'multi' }, + { id: 'openai', name: 'OpenAI', hasApiKey: false, modelSelection: 'multi' }, + { id: 'google', name: 'Google', hasApiKey: false, modelSelection: 'multi' } ] -export function ModelSwitcher() { +/** + * Get the appropriate icon for a provider. + * For custom providers, checks if name contains "azure" or "foundry" (case-insensitive) + * to use the Azure Foundry icon. + */ +function getProviderIcon(provider: Provider): React.FC<{ className?: string }> | null { + // Built-in providers use their registered icons + if (!provider.isCustom && PROVIDER_ICONS[provider.id]) { + return PROVIDER_ICONS[provider.id] + } + + // Custom providers: check if name contains azure or foundry + if (provider.isCustom) { + const nameLower = provider.name.toLowerCase() + if (nameLower.includes('azure') || nameLower.includes('foundry')) { + return AzureFoundryIcon + } + return CustomProviderIcon + } + + return null +} + +// Isolated button component - only re-renders when its specific state changes +// Uses forwardRef to work with PopoverTrigger's asChild +const ModelSwitcherButton = forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef +>(function ModelSwitcherButton(props, ref) { + const modelsLoading = useAppStore((state) => state.modelsLoading) + const providersLoading = useAppStore((state) => state.providersLoading) + const models = useAppStore((state) => state.models) + const providers = useAppStore((state) => state.providers) + const currentModel = useAppStore((state) => state.currentModel) + + // Still loading if either models or providers are loading + const isLoading = modelsLoading || providersLoading + + // Check if current model is a custom provider deployment (format: {providerId}:deployment) + const isCustomDeployment = currentModel?.endsWith(':deployment') + const customProviderId = isCustomDeployment ? currentModel.replace(':deployment', '') : null + const customProvider = customProviderId ? providers.find((p) => p.id === customProviderId) : null + + const selectedModel = models.find((m) => m.id === currentModel) + const currentModelProvider = selectedModel + ? providers.find((p) => p.id === selectedModel.provider) + : customProvider + + // Valid if we have a regular model with API key OR a custom deployment with API key + const hasValidModel = !!( + (selectedModel && currentModelProvider?.hasApiKey) || + (customProvider && customProvider.hasApiKey) + ) + + // Get display name + const displayName = selectedModel?.id || (customProvider ? customProvider.name : null) + + // Render the appropriate icon for custom providers + const renderCustomProviderIcon = (): React.ReactElement | null => { + if (!customProvider) return null + const nameLower = customProvider.name.toLowerCase() + if (nameLower.includes('azure') || nameLower.includes('foundry')) { + return + } + return + } + + return ( + + ) +}) + +// Unified model/config item component +interface ModelItemProps { + name: string + isActive: boolean + isPending: boolean // Waiting to switch when task finishes + isRunning: boolean + onSelect: () => void + onEdit: () => void +} + +function ModelItem({ + name, + isActive, + isPending, + isRunning, + onSelect, + onEdit +}: ModelItemProps): React.ReactElement { + function handleEdit(e: React.MouseEvent): void { + e.stopPropagation() + onEdit() + } + + return ( +
+ {name} + {/* Active: large green check */} + {isActive && !isPending && } + {/* Pending: rotating yellow refresh icon */} + {isPending && ( +
+ +
+ )} + {/* Inactive (not pending): small gray check on hover */} + {!isActive && !isPending && ( + + )} + +
+ ) +} + +export function ModelSwitcher(): React.ReactElement { const [open, setOpen] = useState(false) const [selectedProviderId, setSelectedProviderId] = useState(null) - const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false) - const [apiKeyProvider, setApiKeyProvider] = useState(null) - - const { - models, - providers, - currentModel, - loadModels, - loadProviders, - setCurrentModel - } = useAppStore() + const [configDialogOpen, setConfigDialogOpen] = useState(false) + const [configProviderId, setConfigProviderId] = useState(null) + const [editingConfig, setEditingConfig] = useState(null) + const [providerConfigs, setProviderConfigs] = useState([]) + const [activeConfigId, setActiveConfigId] = useState(null) + const [providerDataLoading, setProviderDataLoading] = useState(false) + + // Models for the selected provider (for 'multi' type providers) + const [providerModels, setProviderModels] = useState([]) + + // Add model dialog state + const [addModelDialogOpen, setAddModelDialogOpen] = useState(false) + + // Add provider dialog state + const [addProviderDialogOpen, setAddProviderDialogOpen] = useState(false) + + // User providers (for custom provider API type lookup) + const [userProviders, setUserProviders] = useState([]) + + // Pending switch state - for when user wants to switch but task is running + const [pendingConfigId, setPendingConfigId] = useState(null) + const [pendingProviderId, setPendingProviderId] = useState(null) + const wasRunningRef = useRef(false) + + // Use selectors to minimize re-renders + // Note: modelsLoading is NOT subscribed here - it's isolated in ModelSwitcherButton + const models = useAppStore((state) => state.models) + const providers = useAppStore((state) => state.providers) + const currentModel = useAppStore((state) => state.currentModel) + const loadModels = useAppStore((state) => state.loadModels) + const loadProviders = useAppStore((state) => state.loadProviders) + const setCurrentModel = useAppStore((state) => state.setCurrentModel) + const loadingThreadId = useAppStore((state) => state.loadingThreadId) + + /** + * Determines if a task/AI process is currently running. + * + * HOW THIS WORKS: + * - `loadingThreadId` is set in ChatContainer.tsx based on `stream.isLoading` + * from the @langchain/langgraph-sdk useStream hook + * - When the LangGraph agent is actively streaming a response, `stream.isLoading` is true + * - This gets synced to the global store as `loadingThreadId = threadId` + * + * WHAT THIS COVERS: + * - Agent invocations (user sends message, agent responds) + * - Tool executions during agent runs + * - Human-in-the-loop approval flows + * + * POTENTIAL GAPS (future features that might NOT be caught by this check): + * - Background tasks not using LangGraph streaming (e.g., batch file operations) + * - Long-running operations outside the agent (e.g., file indexing, embeddings) + * - Multiple concurrent agent streams (currently only tracks one thread) + * - Operations in the main process that don't emit stream events + * + * If adding new async features, consider: + * 1. Using the existing loadingThreadId mechanism if it's a streaming operation + * 2. Adding a separate loading state to the store for non-streaming operations + * 3. Creating a unified "isBusy" computed property that combines all blocking states + */ + const isRunning = !!loadingThreadId + + // Effect to apply pending config when task finishes + useEffect(() => { + // Detect transition from running to not running + if (wasRunningRef.current && !isRunning && pendingConfigId && pendingProviderId) { + // Task just finished, apply pending config + applyPendingConfig() + } + wasRunningRef.current = isRunning + }, [isRunning, pendingConfigId, pendingProviderId]) + + async function applyPendingConfig(): Promise { + if (!pendingConfigId || !pendingProviderId) return + + try { + await window.api.models.setActiveProviderConfigId(pendingProviderId, pendingConfigId) + setActiveConfigId(pendingConfigId) + loadProviders() + loadModels() + } catch (e) { + console.error('Failed to apply pending config:', e) + } finally { + setPendingConfigId(null) + setPendingProviderId(null) + } + } + + // Load user providers + async function loadUserProviders(): Promise { + try { + const providers = await window.api.providers.listUserProviders() + setUserProviders(providers) + } catch (e) { + console.error('Failed to load user providers:', e) + } + } // Load models and providers on mount useEffect(() => { loadModels() loadProviders() + loadUserProviders() }, [loadModels, loadProviders]) // Use fallback providers if none loaded const displayProviders = providers.length > 0 ? providers : FALLBACK_PROVIDERS - // Set initial selected provider based on current model + // When popover opens, set selected provider based on current model useEffect(() => { - if (!selectedProviderId && currentModel) { - const model = models.find(m => m.id === currentModel) - if (model) { - setSelectedProviderId(model.provider) + if (open) { + let providerId: ProviderId | null = null + if (currentModel) { + // Check if it's a custom deployment (format: {providerId}:deployment) + if (currentModel.endsWith(':deployment')) { + providerId = currentModel.replace(':deployment', '') + } else { + // Regular model - find its provider + const model = models.find((m) => m.id === currentModel) + if (model) { + providerId = model.provider + } + } + } + // Default to first provider if no current model + if (!providerId && displayProviders.length > 0) { + providerId = displayProviders[0].id + } + if (providerId) { + setSelectedProviderId(providerId) } } - // Default to first provider if none selected - if (!selectedProviderId && displayProviders.length > 0) { - setSelectedProviderId(displayProviders[0].id) + }, [open, currentModel, models, displayProviders]) + + // Load configs and models for selected provider (also reloads when popover opens) + useEffect(() => { + if (selectedProviderId && open) { + loadProviderData(selectedProviderId) + } + }, [selectedProviderId, open]) + + async function loadProviderData(providerId: ProviderId): Promise { + setProviderDataLoading(true) + try { + // Load configs + const configs = await window.api.models.listProviderConfigs(providerId) + console.log(`[ModelSwitcher] Provider ${providerId} configs:`, configs) + setProviderConfigs(configs) + + // Get active config ID + const activeConfig = await window.api.models.getActiveProviderConfigById(providerId) + console.log(`[ModelSwitcher] Provider ${providerId} active config:`, activeConfig) + setActiveConfigId(activeConfig?.id || null) + + // Load models for this provider (for 'multi' type providers) + const models = await window.api.models.listByProvider(providerId) + console.log(`[ModelSwitcher] Provider ${providerId} models:`, models) + setProviderModels(models) + } catch (e) { + console.error('Failed to load provider data:', e) + setProviderConfigs([]) + setActiveConfigId(null) + setProviderModels([]) + } finally { + setProviderDataLoading(false) + } + } + + // Reload data when dialog closes + const reloadProviderData = useCallback(() => { + if (selectedProviderId) { + loadProviderData(selectedProviderId) } - }, [currentModel, models, selectedProviderId, displayProviders]) + }, [selectedProviderId]) + + const selectedProvider = displayProviders.find((p) => p.id === selectedProviderId) - const selectedModel = models.find(m => m.id === currentModel) - const filteredModels = selectedProviderId - ? models.filter(m => m.provider === selectedProviderId) - : [] - const selectedProvider = displayProviders.find(p => p.id === selectedProviderId) + // Determine if this is a multi-model or deployment provider + const isMultiModelProvider = selectedProvider?.modelSelection === 'multi' + const isDeploymentProvider = selectedProvider?.modelSelection === 'deployment' - function handleProviderClick(provider: Provider) { + // Debug: Log provider selection info when popover is open + if (open && selectedProvider) { + console.log(`[ModelSwitcher] Selected provider: ${selectedProvider.id}`, { + modelSelection: selectedProvider.modelSelection, + hasApiKey: selectedProvider.hasApiKey, + isMultiModelProvider, + isDeploymentProvider, + providerModelsCount: providerModels.length, + providerConfigsCount: providerConfigs.length + }) + } + + function handleProviderClick(provider: Provider): void { setSelectedProviderId(provider.id) } - function handleModelSelect(modelId: string) { + function handleModelSelect(modelId: string): void { + if (isRunning) return // Don't switch while running setCurrentModel(modelId) setOpen(false) } - function handleConfigureApiKey(provider: Provider) { - setApiKeyProvider(provider) - setApiKeyDialogOpen(true) + function handleModelEdit(_model: ModelConfig): void { + void _model // Parameter required by callback signature but not used here + // For models, edit the active provider config + if (selectedProviderId && activeConfigId) { + const activeConfig = providerConfigs.find((c) => c.id === activeConfigId) + if (activeConfig) { + setConfigProviderId(selectedProviderId) + setEditingConfig(activeConfig) + setConfigDialogOpen(true) + return + } + } + // If no config exists, create new one + if (selectedProviderId) { + setConfigProviderId(selectedProviderId) + setEditingConfig(null) + setConfigDialogOpen(true) + } } - function handleApiKeyDialogClose(isOpen: boolean) { - setApiKeyDialogOpen(isOpen) + function handleAddConfiguration(provider: Provider): void { + setConfigProviderId(provider.id) + // If provider already has configs, edit the active one instead of creating new + if (providerConfigs.length > 0 && activeConfigId) { + const activeConfig = providerConfigs.find((c) => c.id === activeConfigId) + setEditingConfig(activeConfig || providerConfigs[0]) + } else { + setEditingConfig(null) // Create new + } + setConfigDialogOpen(true) + } + + function handleAddNewConfiguration(provider: Provider): void { + setConfigProviderId(provider.id) + setEditingConfig(null) // Create new + setConfigDialogOpen(true) + } + + function handleEditConfiguration(config: SavedProviderConfig): void { + setConfigProviderId(selectedProviderId) + setEditingConfig(config) + setConfigDialogOpen(true) + } + + async function handleSelectConfig(config: SavedProviderConfig): Promise { + if (!selectedProviderId) return + + // Check if this deployment is already the current model + const deploymentModelId = `${selectedProviderId}:deployment` + const isAlreadyCurrentModel = currentModel === deploymentModelId + + // If already active AND already current model, do nothing + if (activeConfigId === config.id && isAlreadyCurrentModel && !pendingConfigId) { + console.log('[ModelSwitcher] Config already active and current, closing popover') + setOpen(false) + return + } + + // If running, set as pending instead of switching immediately + if (isRunning) { + setPendingConfigId(config.id) + setPendingProviderId(selectedProviderId) + return + } + + // Clear any pending state + setPendingConfigId(null) + setPendingProviderId(null) + + console.log( + '[ModelSwitcher] Activating config:', + config.id, + 'for provider:', + selectedProviderId + ) + + try { + // Set this config as active for the provider + await window.api.models.setActiveProviderConfigId(selectedProviderId, config.id) + setActiveConfigId(config.id) + + // Set this deployment as the current model + console.log('[ModelSwitcher] Setting current model to:', deploymentModelId) + setCurrentModel(deploymentModelId) + + // Refresh providers to update hasApiKey status + loadProviders() + loadModels() + + // Close the popover after selection + setOpen(false) + } catch (e) { + console.error('Failed to set active config:', e) + } + } + + function handleCancelPending(): void { + setPendingConfigId(null) + setPendingProviderId(null) + } + + function handleConfigDialogClose(isOpen: boolean): void { + setConfigDialogOpen(isOpen) if (!isOpen) { - // Refresh providers after dialog closes + // Refresh data after dialog closes + reloadProviderData() loadProviders() loadModels() } } + async function handleAddModel(modelId: string, modelName: string): Promise { + if (!selectedProviderId || !modelId.trim()) return + + try { + await window.api.models.addUserModel({ + id: modelId.trim(), + name: modelName.trim() || modelId.trim(), + provider: selectedProviderId, + model: modelId.trim(), // API model name is the same as ID for custom models + description: 'Custom model' + }) + + // Reload models + reloadProviderData() + loadModels() + setAddModelDialogOpen(false) + } catch (e) { + console.error('Failed to add model:', e) + } + } + + async function handleAddProvider( + name: string, + apiType: ProviderApiType, + presetType: ProviderPresetType + ): Promise { + console.log('[ModelSwitcher] handleAddProvider called:', { name, apiType, presetType }) + if (!name.trim()) { + console.log('[ModelSwitcher] Empty name, returning') + return + } + + try { + console.log('[ModelSwitcher] Calling addUserProvider...') + const newProvider = await window.api.providers.addUserProvider( + name.trim(), + apiType, + presetType + ) + console.log('[ModelSwitcher] Created provider:', newProvider) + // Refresh providers list and user providers + await loadProviders() + await loadUserProviders() + console.log('[ModelSwitcher] Providers reloaded') + // Select the new provider + setSelectedProviderId(newProvider.id) + // Close the add provider dialog first + setAddProviderDialogOpen(false) + // Use a small delay to let the dialog close animation complete before opening the next one + setTimeout(() => { + // Open the config dialog to add the first deployment + setConfigProviderId(newProvider.id) + setEditingConfig(null) // Create new config + setConfigDialogOpen(true) + console.log('[ModelSwitcher] Config dialog should be open now') + }, 100) + } catch (e) { + console.error('Failed to add provider:', e) + } + } + + async function handleDeleteProvider(providerId: string): Promise { + try { + await window.api.providers.deleteUserProvider(providerId) + // Refresh providers list and user providers + await loadProviders() + await loadUserProviders() + // If the deleted provider was selected, select the first available provider + if (selectedProviderId === providerId && displayProviders.length > 0) { + setSelectedProviderId(displayProviders[0].id) + } + } catch (e) { + console.error('Failed to delete provider:', e) + } + } + return ( <> - + - -
+
{/* Provider column */}
-
- Provider +
+
+ Provider +
+
{displayProviders.map((provider) => { - const Icon = PROVIDER_ICONS[provider.id] + const Icon = getProviderIcon(provider) return ( - + {/* Delete button for custom providers */} + {provider.isCustom && ( + + )} +
) })}
@@ -171,62 +713,181 @@ export function ModelSwitcher() { {/* Models column */}
-
- Model +
+
+ {isDeploymentProvider ? 'Deployments' : 'Model'} +
+
+ {/* Config button for multi-model providers (shows API key status) */} + {isMultiModelProvider && selectedProvider && ( + <> + + + + )} + {/* Config button for deployment providers */} + {isDeploymentProvider && selectedProvider && ( + <> + {providerConfigs.length > 0 && ( + + )} + + + )} +
- - {selectedProvider && !selectedProvider.hasApiKey ? ( - // No API key configured -
+ + {selectedProvider && !selectedProvider.hasApiKey && isMultiModelProvider ? ( + // No API key configured (only for multi-model providers) +

- API key required for {selectedProvider.name} + Configuration required for {selectedProvider.name}

-
- ) : ( - // Show models list with scrollable area -
+ ) : isMultiModelProvider ? ( + // MULTI-MODEL PROVIDERS (Anthropic, OpenAI, Google) + // Show model list +
- {filteredModels.map((model) => ( -
+
+ ) : ( + // DEPLOYMENT PROVIDERS (Azure) + // Show configs as models - each config IS a deployment +
+
+ {/* Loading state */} + {providerDataLoading ? ( +
+ + + Loading deployments... + +
+ ) : ( + <> + {/* Deployments (configs) */} + {providerConfigs.map((config) => { + // For deployments, isActive should be true ONLY if: + // 1. This is the active config for this provider AND + // 2. This provider's deployment is the current model + const isCurrentProviderDeployment = + currentModel === `${selectedProviderId}:deployment` + const isActiveDeployment = + activeConfigId === config.id && isCurrentProviderDeployment + + return ( + handleSelectConfig(config)} + onEdit={() => handleEditConfiguration(config)} + /> + ) + })} + + {/* Show message if no configs */} + {providerConfigs.length === 0 && ( +

+ No deployments configured +

)} - - ))} - - {filteredModels.length === 0 && ( -

- No models available -

+ )}
- - {/* Configure API key link for providers that have a key */} - {selectedProvider?.hasApiKey && ( - + + {/* Pending switch status message */} + {pendingConfigId && isRunning && ( +
+
+ + + Will switch when task finishes + + +
+
)}
)} @@ -235,11 +896,110 @@ export function ModelSwitcher() { - + + + + ) } + +// Simple dialog for adding a custom model +function AddModelDialog({ + open, + onOpenChange, + onSave, + providerName +}: { + open: boolean + onOpenChange: (open: boolean) => void + onSave: (modelId: string, modelName: string) => void + providerName: string +}): React.ReactElement | null { + const [modelId, setModelId] = useState('') + const [modelName, setModelName] = useState('') + + function handleSubmit(e: React.FormEvent): void { + e.preventDefault() + if (modelId.trim()) { + onSave(modelId, modelName) + setModelId('') + setModelName('') + } + } + + function handleClose(): void { + setModelId('') + setModelName('') + onOpenChange(false) + } + + if (!open) return null + + return ( +
+
+
+

Add Custom Model for {providerName}

+ +
+
+ + setModelId(e.target.value)} + placeholder="e.g., claude-3-opus-20240229" + className="w-full px-3 py-2 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring" + autoFocus + /> +

+ The exact model identifier used by the API +

+
+ +
+ + setModelName(e.target.value)} + placeholder="e.g., Claude 3 Opus" + className="w-full px-3 py-2 text-sm bg-muted border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ +
+ + +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/chat/ProviderConfigDialog.tsx b/src/renderer/src/components/chat/ProviderConfigDialog.tsx new file mode 100644 index 0000000..aa6e2cd --- /dev/null +++ b/src/renderer/src/components/chat/ProviderConfigDialog.tsx @@ -0,0 +1,385 @@ +import { useState, useEffect } from 'react' +import { Eye, EyeOff, Loader2, Trash2 } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + PROVIDER_REGISTRY, + isBuiltInProvider, + getFieldsForPreset, + getNameFieldForPreset, + type FieldConfig +} from '../../../../shared/providers' +import type { ProviderId, SavedProviderConfig, UserProvider } from '@/types' + +type ProviderConfig = Record + +/** + * Extract api-version query parameter from a URL string. + * Handles both `api-version` and `apiVersion` parameter names. + */ +function extractApiVersionFromUrl(url: string): string | null { + try { + const urlObj = new URL(url) + // Try common parameter names + const version = urlObj.searchParams.get('api-version') || urlObj.searchParams.get('apiVersion') + return version + } catch { + // Not a valid URL, try regex as fallback + const match = url.match(/[?&]api-version=([^&]+)/i) + return match ? match[1] : null + } +} + +interface ProviderConfigDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + providerId: ProviderId | null + // Optional: existing config to edit (if not provided, creates new) + editingConfig?: SavedProviderConfig | null + // Optional: user providers list for looking up custom provider preset types + userProviders?: UserProvider[] + // Optional: existing configs for prepopulation when adding new config + existingConfigs?: SavedProviderConfig[] +} + +export function ProviderConfigDialog({ + open, + onOpenChange, + providerId, + editingConfig, + userProviders = [], + existingConfigs = [] +}: ProviderConfigDialogProps): React.ReactElement | null { + const [config, setConfig] = useState({}) + const [showFields, setShowFields] = useState>({}) + const [saving, setSaving] = useState(false) + const [deleting, setDeleting] = useState(false) + const [hasExistingConfig, setHasExistingConfig] = useState(false) + const [configId, setConfigId] = useState(null) + + // Get provider metadata - handle both built-in and custom providers + const isCustomProvider = providerId ? !isBuiltInProvider(providerId) : false + const provider = + providerId && isBuiltInProvider(providerId) ? PROVIDER_REGISTRY[providerId] : null + + // For custom providers, look up the preset type + const customProvider = isCustomProvider ? userProviders.find((p) => p.id === providerId) : null + const customPresetType = customProvider?.presetType || 'api' + + // Get fields based on provider type + const fields = isCustomProvider ? getFieldsForPreset(customPresetType) : provider?.fields || [] + const nameField = isCustomProvider + ? getNameFieldForPreset(customPresetType) + : provider?.nameField || '' + + // Reset state when dialog opens/closes or provider changes + useEffect(() => { + if (open && providerId) { + if (editingConfig) { + // Editing existing config + setHasExistingConfig(true) + setConfigId(editingConfig.id) + + // For password fields, clear the value but track that it exists + const displayConfig: ProviderConfig = {} + for (const field of fields) { + if (field.type === 'password' && editingConfig.config[field.key]) { + displayConfig[field.key] = '' // Don't show actual password + } else { + displayConfig[field.key] = editingConfig.config[field.key] || '' + } + } + setConfig(displayConfig) + } else { + // Creating new config + setHasExistingConfig(false) + setConfigId(null) + + // Initialize with prepopulated values from existing configs (except passwords and name field) + const initialConfig: ProviderConfig = {} + const latestConfig = existingConfigs.length > 0 ? existingConfigs[0] : null + + for (const field of fields) { + // Don't prepopulate password fields or the name field (should be unique) + if (field.type === 'password' || field.key === nameField) { + initialConfig[field.key] = '' + } else if (latestConfig && latestConfig.config[field.key]) { + // Prepopulate from latest existing config + initialConfig[field.key] = latestConfig.config[field.key] + } else { + initialConfig[field.key] = '' + } + } + setConfig(initialConfig) + } + setShowFields({}) + } + }, [open, providerId, editingConfig, existingConfigs]) + + function handleFieldChange(key: string, value: string): void { + setConfig((prev) => { + const newConfig = { ...prev, [key]: value } + + // Auto-extract api-version from URL fields + const field = fields.find((f) => f.key === key) + if (field?.type === 'url' && value) { + const extractedVersion = extractApiVersionFromUrl(value) + if (extractedVersion) { + // Find the apiVersion field and update it if it exists and is empty or different + const apiVersionField = fields.find((f) => f.key === 'apiVersion') + if (apiVersionField && (!prev.apiVersion || prev.apiVersion !== extractedVersion)) { + newConfig.apiVersion = extractedVersion + } + } + } + + return newConfig + }) + } + + function toggleShowField(key: string): void { + setShowFields((prev) => ({ ...prev, [key]: !prev[key] })) + } + + async function handleSave(): Promise { + if (!providerId) return + // For built-in providers, we need the provider metadata; for custom, we have fields + if (!isCustomProvider && !provider) return + + // Validation is handled by canSave, but double-check here + if (!canSave) return + + setSaving(true) + try { + // Build final config, merging with existing for empty password fields + // Trim all string values to remove accidental whitespace + const finalConfig: ProviderConfig = {} + for (const [key, value] of Object.entries(config)) { + finalConfig[key] = typeof value === 'string' ? value.trim() : value + } + + if (hasExistingConfig && editingConfig) { + // Keep existing password values if new ones are empty + for (const field of fields) { + if (field.type === 'password' && !finalConfig[field.key]) { + finalConfig[field.key] = editingConfig.config[field.key] + } + } + } + + // Get the config name from the nameField + const configName = nameField ? finalConfig[nameField] || 'Default' : 'Default' + + // Create the SavedProviderConfig object + const savedConfig: SavedProviderConfig = { + id: configId || crypto.randomUUID(), + name: configName, + config: finalConfig, + createdAt: editingConfig?.createdAt || new Date().toISOString() + } + + await window.api.models.saveProviderConfigById(providerId, savedConfig) + onOpenChange(false) + } catch (e) { + console.error('Failed to save provider config:', e) + } finally { + setSaving(false) + } + } + + async function handleDelete(): Promise { + if (!providerId || !configId) return + + setDeleting(true) + try { + await window.api.models.deleteProviderConfigById(providerId, configId) + onOpenChange(false) + } catch (e) { + console.error('Failed to delete provider config:', e) + } finally { + setDeleting(false) + } + } + + // Check if we can save - all required fields must have values + // For existing configs, password fields can be empty (keeps existing value) + const canSave = fields.every((field) => { + const value = config[field.key] + // For password fields with existing config, empty is OK (keeps existing) + if (field.type === 'password' && hasExistingConfig && !value) { + return true + } + // apiVersion is always optional + if (field.key === 'apiVersion') { + return true + } + // All other fields need a value + return !!value + }) + + // For built-in providers, we need provider metadata; for custom providers, we have fields + if (!providerId || (!isCustomProvider && !provider)) return null + + // Get provider name for display + const providerDisplayName = isCustomProvider + ? customProvider?.name || 'Custom Provider' + : provider!.name + + const dialogTitle = hasExistingConfig + ? `Edit ${providerDisplayName} Configuration` + : `Add ${providerDisplayName} Configuration` + + const dialogDescription = hasExistingConfig + ? `Update your ${providerDisplayName} settings.${fields.some((f) => f.type === 'password') ? ' Leave password fields blank to keep existing values.' : ''}` + : existingConfigs.length > 0 + ? `Add another model configuration. Fields are prepopulated from your existing config.` + : `Enter your ${providerDisplayName} credentials.` + + return ( + + + + {dialogTitle} + {dialogDescription} + + +
+ {fields.map((field) => ( + handleFieldChange(field.key, value)} + onBlur={ + field.type === 'url' + ? () => handleFieldChange(field.key, config[field.key] || '') + : undefined + } + onPaste={ + field.type === 'url' + ? (pastedValue) => { + // Extract API version from pasted URL immediately + const extractedVersion = extractApiVersionFromUrl(pastedValue) + if (extractedVersion) { + const apiVersionField = fields.find((f) => f.key === 'apiVersion') + if (apiVersionField) { + setConfig((prev) => ({ ...prev, apiVersion: extractedVersion })) + } + } + } + : undefined + } + showValue={showFields[field.key] || false} + onToggleShow={() => toggleShowField(field.key)} + hasExistingValue={hasExistingConfig && field.type === 'password'} + /> + ))} +
+ +
+ {hasExistingConfig ? ( + + ) : ( +
+ )} +
+ + +
+
+ +
+ ) +} + +// Individual field input component +interface FieldInputProps { + field: FieldConfig + value: string + onChange: (value: string) => void + onBlur?: () => void + onPaste?: (pastedValue: string) => void + showValue: boolean + onToggleShow: () => void + hasExistingValue: boolean +} + +function FieldInput({ + field, + value, + onChange, + onBlur, + onPaste, + showValue, + onToggleShow, + hasExistingValue +}: FieldInputProps): React.ReactElement { + const isPassword = field.type === 'password' + const isUrl = field.type === 'url' + const inputType = isPassword ? (showValue ? 'text' : 'password') : isUrl ? 'url' : 'text' + const hasRightButton = isPassword + + function handlePaste(e: React.ClipboardEvent): void { + if (onPaste) { + const pastedText = e.clipboardData.getData('text') + // Let the default paste happen, then call onPaste with the full value + setTimeout(() => { + onPaste(pastedText) + }, 0) + } + } + + return ( +
+ +
+ onChange(e.target.value)} + onBlur={onBlur} + onPaste={handlePaste} + placeholder={hasExistingValue ? '••••••••••••••••' : field.placeholder} + className={hasRightButton ? 'pr-10' : ''} + /> + {isPassword && ( + + )} +
+ {field.helpText &&

{field.helpText}

} +
+ ) +} diff --git a/src/renderer/src/components/chat/StreamingMarkdown.tsx b/src/renderer/src/components/chat/StreamingMarkdown.tsx index 1c6c065..1a2a74e 100644 --- a/src/renderer/src/components/chat/StreamingMarkdown.tsx +++ b/src/renderer/src/components/chat/StreamingMarkdown.tsx @@ -13,9 +13,7 @@ export const StreamingMarkdown = memo(function StreamingMarkdown({ }: StreamingMarkdownProps) { return (
- - {children} - + {children} {isStreaming && ( )} diff --git a/src/renderer/src/components/chat/ToolCallRenderer.tsx b/src/renderer/src/components/chat/ToolCallRenderer.tsx index c363b6b..1c8b665 100644 --- a/src/renderer/src/components/chat/ToolCallRenderer.tsx +++ b/src/renderer/src/components/chat/ToolCallRenderer.tsx @@ -1,8 +1,8 @@ -import { - FileText, - FolderOpen, - Search, - Edit, +import { + FileText, + FolderOpen, + Search, + Edit, Terminal, ListTodo, GitBranch, @@ -37,7 +37,7 @@ const TOOL_ICONS: Record> = grep: Search, execute: Terminal, write_todos: ListTodo, - task: GitBranch, + task: GitBranch } const TOOL_LABELS: Record = { @@ -49,7 +49,7 @@ const TOOL_LABELS: Record = { grep: 'Search Content', execute: 'Execute Command', write_todos: 'Update Tasks', - task: 'Subagent Task', + task: 'Subagent Task' } // Tools whose results are shown in the UI panels and don't need verbose display @@ -61,7 +61,7 @@ function getFileName(path: string): string { } // Render todos nicely -function TodosDisplay({ todos }: { todos: Todo[] }) { +function TodosDisplay({ todos }: { todos: Todo[] }): React.ReactElement { const statusConfig: Record = { pending: { icon: Circle, color: 'text-muted-foreground' }, in_progress: { icon: Clock, color: 'text-status-info' }, @@ -78,12 +78,12 @@ function TodosDisplay({ todos }: { todos: Todo[] }) { const Icon = config.icon const isDone = todo.status === 'completed' || todo.status === 'cancelled' return ( -
- - {todo.content} +
+ + {todo.content}
) })} @@ -92,7 +92,13 @@ function TodosDisplay({ todos }: { todos: Todo[] }) { } // Render file list nicely -function FileListDisplay({ files, isGlob }: { files: string[] | Array<{ path: string; is_dir?: boolean }>; isGlob?: boolean }) { +function FileListDisplay({ + files, + isGlob +}: { + files: string[] | Array<{ path: string; is_dir?: boolean }> + isGlob?: boolean +}): React.ReactElement { const items = files.slice(0, 15) // Limit display const hasMore = files.length > 15 @@ -113,28 +119,33 @@ function FileListDisplay({ files, isGlob }: { files: string[] | Array<{ path: st ) })} {hasMore && ( -
- ... and {files.length - 15} more -
+
... and {files.length - 15} more
)}
) } // Render grep results nicely -function GrepResultsDisplay({ matches }: { matches: Array<{ path: string; line?: number; text?: string }> }) { - const grouped = matches.reduce((acc, match) => { - if (!acc[match.path]) acc[match.path] = [] - acc[match.path].push(match) - return acc - }, {} as Record) +function GrepResultsDisplay({ + matches +}: { + matches: Array<{ path: string; line?: number; text?: string }> +}): React.ReactElement { + const grouped = matches.reduce( + (acc, match) => { + if (!acc[match.path]) acc[match.path] = [] + acc[match.path].push(match) + return acc + }, + {} as Record + ) const files = Object.keys(grouped).slice(0, 5) const hasMore = Object.keys(grouped).length > 5 return (
- {files.map(path => ( + {files.map((path) => (
@@ -163,7 +174,7 @@ function GrepResultsDisplay({ matches }: { matches: Array<{ path: string; line?: } // Render file content preview -function FileContentPreview({ content }: { content: string; path?: string }) { +function FileContentPreview({ content }: { content: string; path?: string }): React.ReactElement { const lines = content.split('\n') const preview = lines.slice(0, 10) const hasMore = lines.length > 10 @@ -173,7 +184,9 @@ function FileContentPreview({ content }: { content: string; path?: string }) {
         {preview.map((line, i) => (
           
- {i + 1} + + {i + 1} + {line || ' '}
))} @@ -188,7 +201,7 @@ function FileContentPreview({ content }: { content: string; path?: string }) { } // Render edit/write file summary -function FileEditSummary({ args }: { args: Record }) { +function FileEditSummary({ args }: { args: Record }): React.ReactElement | null { const path = (args.path || args.file_path) as string const content = args.content as string | undefined const oldStr = args.old_str as string | undefined @@ -199,10 +212,14 @@ function FileEditSummary({ args }: { args: Record }) { return (
- - {oldStr.split('\n').length} lines + + - {oldStr.split('\n').length} lines +
- + {newStr.split('\n').length} lines + + + {newStr.split('\n').length} lines +
) @@ -221,7 +238,13 @@ function FileEditSummary({ args }: { args: Record }) { } // Command display -function CommandDisplay({ command, output }: { command: string; output?: string }) { +function CommandDisplay({ + command, + output +}: { + command: string + output?: string +}): React.ReactElement { return (
@@ -239,7 +262,13 @@ function CommandDisplay({ command, output }: { command: string; output?: string } // Subagent task display -function TaskDisplay({ args, isExpanded }: { args: Record; isExpanded?: boolean }) { +function TaskDisplay({ + args, + isExpanded +}: { + args: Record + isExpanded?: boolean +}): React.ReactElement { const name = args.name as string | undefined const description = args.description as string | undefined @@ -252,10 +281,7 @@ function TaskDisplay({ args, isExpanded }: { args: Record; isEx
)} {description && ( -

+

{description}

)} @@ -263,7 +289,13 @@ function TaskDisplay({ args, isExpanded }: { args: Record; isEx ) } -export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onApprovalDecision }: ToolCallRendererProps) { +export function ToolCallRenderer({ + toolCall, + result, + isError, + needsApproval, + onApprovalDecision +}: ToolCallRendererProps): React.ReactElement | null { // Defensive: ensure args is always an object const args = toolCall?.args || {} @@ -278,18 +310,18 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA const label = TOOL_LABELS[toolCall.name] || toolCall.name const isPanelSynced = PANEL_SYNCED_TOOLS.has(toolCall.name) - const handleApprove = (e: React.MouseEvent) => { + const handleApprove = (e: React.MouseEvent): void => { e.stopPropagation() onApprovalDecision?.('approve') } - const handleReject = (e: React.MouseEvent) => { + const handleReject = (e: React.MouseEvent): void => { e.stopPropagation() onApprovalDecision?.('reject') } // Format the main argument for display - const getDisplayArg = () => { + const getDisplayArg = (): string | null => { if (!args) return null if (args.path) return args.path as string if (args.file_path) return args.file_path as string @@ -303,7 +335,7 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA const displayArg = getDisplayArg() // Render formatted content based on tool type - const renderFormattedContent = () => { + const renderFormattedContent = (): React.ReactElement | null => { if (!args) return null switch (toolCall.name) { @@ -336,7 +368,7 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA } // Render result based on tool type - const renderFormattedResult = () => { + const renderFormattedResult = (): React.ReactElement | null => { if (result === undefined) return null // Handle errors @@ -344,7 +376,9 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA return (
- {typeof result === 'string' ? result : JSON.stringify(result)} + + {typeof result === 'string' ? result : JSON.stringify(result)} +
) } @@ -366,13 +400,18 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA case 'ls': { if (Array.isArray(result)) { - const dirs = result.filter((f: { is_dir?: boolean } | string) => typeof f === 'object' && f.is_dir).length + const dirs = result.filter( + (f: { is_dir?: boolean } | string) => typeof f === 'object' && f.is_dir + ).length const files = result.length - dirs return (
- {files} file{files !== 1 ? 's' : ''}{dirs > 0 ? `, ${dirs} folder${dirs !== 1 ? 's' : ''}` : ''} + + {files} file{files !== 1 ? 's' : ''} + {dirs > 0 ? `, ${dirs} folder${dirs !== 1 ? 's' : ''}` : ''} +
@@ -387,7 +426,9 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
- Found {result.length} match{result.length !== 1 ? 'es' : ''} + + Found {result.length} match{result.length !== 1 ? 'es' : ''} +
@@ -403,7 +444,10 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA
- {result.length} match{result.length !== 1 ? 'es' : ''} in {fileCount} file{fileCount !== 1 ? 's' : ''} + + {result.length} match{result.length !== 1 ? 'es' : ''} in {fileCount} file + {fileCount !== 1 ? 's' : ''} +
@@ -500,7 +544,10 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA return (
- {result.slice(0, 100)}{result.length > 100 ? '...' : ''} + + {result.slice(0, 100)} + {result.length > 100 ? '...' : ''} +
) } @@ -519,10 +566,14 @@ export function ToolCallRenderer({ toolCall, result, isError, needsApproval, onA const hasFormattedDisplay = formattedContent || formattedResult return ( -
+
{/* Header */}
- + {/* Action buttons */}
- -
) -} \ No newline at end of file +} diff --git a/src/renderer/src/components/chat/WorkspacePicker.tsx b/src/renderer/src/components/chat/WorkspacePicker.tsx index e791d74..2ca5991 100644 --- a/src/renderer/src/components/chat/WorkspacePicker.tsx +++ b/src/renderer/src/components/chat/WorkspacePicker.tsx @@ -1,18 +1,16 @@ import { useState, useEffect } from 'react' import { Folder, Check, ChevronDown } from 'lucide-react' import { Button } from '@/components/ui/button' -import { - Popover, - PopoverContent, - PopoverTrigger -} from '@/components/ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { useAppStore } from '@/lib/store' import { cn } from '@/lib/utils' +import type { FileInfo } from '@/types' +// eslint-disable-next-line react-refresh/only-export-components export async function selectWorkspaceFolder( currentThreadId: string | null, setWorkspacePath: (path: string | null) => void, - setWorkspaceFiles: (files: any[]) => void, + setWorkspaceFiles: (files: FileInfo[]) => void, setLoading: (loading: boolean) => void, setOpen?: (open: boolean) => void ): Promise { @@ -60,7 +58,13 @@ export function WorkspacePicker(): React.JSX.Element { }, [currentThreadId, setWorkspacePath, setWorkspaceFiles]) async function handleSelectFolder(): Promise { - await selectWorkspaceFolder(currentThreadId, setWorkspacePath, setWorkspaceFiles, setLoading, setOpen) + await selectWorkspaceFolder( + currentThreadId, + setWorkspacePath, + setWorkspaceFiles, + setLoading, + setOpen + ) } const folderName = workspacePath?.split('/').pop() @@ -72,7 +76,7 @@ export function WorkspacePicker(): React.JSX.Element { variant="ghost" size="sm" className={cn( - 'h-7 px-2 text-xs gap-1.5', + 'h-7 px-2 text-xs gap-1.5 cursor-pointer', workspacePath ? 'text-foreground' : 'text-amber-500' )} disabled={!currentThreadId} @@ -114,7 +118,8 @@ export function WorkspacePicker(): React.JSX.Element { ) : (

- Select a folder for the agent to work in. The agent will read and write files directly to this location. + Select a folder for the agent to work in. The agent will read and write files + directly to this location.

- - {file.is_dir && isExpanded && children.map(child => renderNode(child, depth + 1))} + + {file.is_dir && isExpanded && children.map((child) => renderNode(child, depth + 1))}
) } @@ -264,7 +273,10 @@ export function FilesystemPanel() {
WORKSPACE
- + {workspacePath.split('/').pop()}
- +
{rootItems.length === 0 ? ( @@ -306,7 +318,7 @@ export function FilesystemPanel() {
) : ( - rootItems.map(file => renderNode(file)) + rootItems.map((file) => renderNode(file)) )}
diff --git a/src/renderer/src/components/settings/SettingsDialog.tsx b/src/renderer/src/components/settings/SettingsDialog.tsx index a2ae31c..e43932c 100644 --- a/src/renderer/src/components/settings/SettingsDialog.tsx +++ b/src/renderer/src/components/settings/SettingsDialog.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'react' -import { Eye, EyeOff, Check, AlertCircle, Loader2 } from 'lucide-react' +import { useState, useEffect, useCallback } from 'react' +import { Check, Loader2, Settings, Plus, Pencil } from 'lucide-react' import { Dialog, DialogContent, @@ -8,208 +8,189 @@ import { DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { Separator } from '@/components/ui/separator' +import { ProviderConfigDialog } from '@/components/chat/ProviderConfigDialog' +import { PROVIDER_REGISTRY } from '../../../../shared/providers' +import type { ProviderId, SavedProviderConfig } from '@/types' interface SettingsDialogProps { open: boolean onOpenChange: (open: boolean) => void } -interface ProviderConfig { - id: string - name: string - envVar: string - placeholder: string -} +// Get providers that require configuration (exclude ollama) +const CONFIGURABLE_PROVIDERS = Object.values(PROVIDER_REGISTRY).filter((p) => p.requiresConfig) -const PROVIDERS: ProviderConfig[] = [ - { - id: 'anthropic', - name: 'Anthropic', - envVar: 'ANTHROPIC_API_KEY', - placeholder: 'sk-ant-...' - }, - { - id: 'openai', - name: 'OpenAI', - envVar: 'OPENAI_API_KEY', - placeholder: 'sk-...' - }, - { - id: 'google', - name: 'Google AI', - envVar: 'GOOGLE_API_KEY', - placeholder: 'AIza...' - } -] +interface ProviderConfigState { + configs: SavedProviderConfig[] + activeConfigId: string | null +} -export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { - const [apiKeys, setApiKeys] = useState>({}) - const [savedKeys, setSavedKeys] = useState>({}) - const [showKeys, setShowKeys] = useState>({}) - const [saving, setSaving] = useState>({}) +export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps): React.ReactElement { + const [providerStates, setProviderStates] = useState>({}) const [loading, setLoading] = useState(true) + const [configDialogOpen, setConfigDialogOpen] = useState(false) + const [configProviderId, setConfigProviderId] = useState(null) + const [editingConfig, setEditingConfig] = useState(null) - // Load existing settings on mount - useEffect(() => { - if (open) { - loadApiKeys() - } - }, [open]) + const loadAllProviderConfigs = useCallback(async (): Promise => { + const states: Record = {} - async function loadApiKeys() { - setLoading(true) - const keys: Record = {} - const saved: Record = {} - - for (const provider of PROVIDERS) { + for (const provider of CONFIGURABLE_PROVIDERS) { try { - const key = await window.api.models.getApiKey(provider.id) - if (key) { - // Show masked version - keys[provider.id] = '••••••••••••••••' - saved[provider.id] = true - } else { - keys[provider.id] = '' - saved[provider.id] = false + const configs = await window.api.models.listProviderConfigs(provider.id) + const activeConfig = await window.api.models.getActiveProviderConfigById(provider.id) + states[provider.id] = { + configs, + activeConfigId: activeConfig?.id || null } - } catch (e) { - keys[provider.id] = '' - saved[provider.id] = false + } catch { + states[provider.id] = { configs: [], activeConfigId: null } } } - setApiKeys(keys) - setSavedKeys(saved) + setProviderStates(states) setLoading(false) - } + }, []) - async function saveApiKey(providerId: string) { - const key = apiKeys[providerId] - if (!key || key === '••••••••••••••••') return - - setSaving((prev) => ({ ...prev, [providerId]: true })) - - try { - await window.api.models.setApiKey(providerId, key) - setSavedKeys((prev) => ({ ...prev, [providerId]: true })) - setApiKeys((prev) => ({ ...prev, [providerId]: '••••••••••••••••' })) - setShowKeys((prev) => ({ ...prev, [providerId]: false })) - } catch (e) { - console.error('Failed to save API key:', e) - } finally { - setSaving((prev) => ({ ...prev, [providerId]: false })) + // Load existing settings on mount + useEffect(() => { + if (open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Loading state before async fetch is a valid pattern + setLoading(true) + void loadAllProviderConfigs() } + }, [open, loadAllProviderConfigs]) + + function handleAddConfiguration(providerId: ProviderId): void { + setConfigProviderId(providerId) + setEditingConfig(null) + setConfigDialogOpen(true) } - function handleKeyChange(providerId: string, value: string) { - // If user starts typing on a masked field, clear it - if (apiKeys[providerId] === '••••••••••••••••' && value.length > 16) { - value = value.slice(16) - } - setApiKeys((prev) => ({ ...prev, [providerId]: value })) - setSavedKeys((prev) => ({ ...prev, [providerId]: false })) + function handleEditConfiguration(providerId: ProviderId, config: SavedProviderConfig): void { + setConfigProviderId(providerId) + setEditingConfig(config) + setConfigDialogOpen(true) } - function toggleShowKey(providerId: string) { - setShowKeys((prev) => ({ ...prev, [providerId]: !prev[providerId] })) + function handleConfigDialogClose(isOpen: boolean): void { + setConfigDialogOpen(isOpen) + if (!isOpen) { + // Reload provider configs when dialog closes + loadAllProviderConfigs() + } } return ( - - - - Settings - - Configure API keys for model providers. Keys are stored securely on your device. - - - - - -
-
API KEYS
- - {loading ? ( -
- -
- ) : ( -
- {PROVIDERS.map((provider) => ( -
-
- - {savedKeys[provider.id] ? ( - - - Configured - - ) : apiKeys[provider.id] ? ( - - - Unsaved - - ) : ( - Not set - )} -
-
-
- handleKeyChange(provider.id, e.target.value)} - placeholder={provider.placeholder} - className="pr-10" - /> - -
-
+ + {/* Show existing configs */} + {hasConfigs && ( +
+ {state.configs.map((config) => ( +
+ + {config.name} + {state.activeConfigId === config.id && ( + (active) + )} + + +
+ ))} +
)} - -
-

- Environment variable: {provider.envVar} -

-
- ))} -
- )} -
- - - -
- -
- - + +
+ +
+
+ ) + })} +
+ )} +
+ + + +
+ +
+ + + + + ) } diff --git a/src/renderer/src/components/sidebar/ThreadSidebar.tsx b/src/renderer/src/components/sidebar/ThreadSidebar.tsx index 91356a2..178ff47 100644 --- a/src/renderer/src/components/sidebar/ThreadSidebar.tsx +++ b/src/renderer/src/components/sidebar/ThreadSidebar.tsx @@ -52,7 +52,12 @@ export function ThreadSidebar(): React.JSX.Element {