diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c9f30d0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check formatting + run: bun run format:check + + - name: Lint + run: bun run lint + + - name: Typecheck + run: bun run typecheck + + - name: Test + run: bun run test diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..84bc0d9 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +tmp/ +packages/create-bot/templates/ diff --git a/apps/electron/README.md b/apps/electron/README.md new file mode 100644 index 0000000..77c3beb --- /dev/null +++ b/apps/electron/README.md @@ -0,0 +1,104 @@ +# Botarium Electron App + +Desktop application that bundles the Slack emulator and optionally pre-compiled bots. + +## Bot Configuration + +Bots are configured in `bots.json`: + +```json +{ + "bots": [ + "../../bot1", + "../../bot2", + { "source": "../../custom-bot", "name": "renamed", "entry": "src/main.ts" } + ] +} +``` + +### Entry formats + +**Simple** - just the path to bot directory: + +```json +"../../my-bot" +``` + +The bot name is read from the bot's `config.yaml` (`simulator.id` field). + +**Advanced** - object with overrides: + +```json +{ + "source": "../../my-bot", + "name": "custom-name", // optional - overrides simulator.id + "entry": "src/custom.ts" // optional - defaults to src/app.ts +} +``` + +## Build Process + +### Scripts + +| Script | Description | +| ----------------- | --------------------------------------- | +| `bun run dev` | Development mode with Vite hot reload | +| `bun run build` | Build everything (preload + bots + UI) | +| `bun run package` | Build and package with electron-builder | + +### How bot compilation works + +1. `scripts/compile-bots.ts` reads `bots.json` +2. For each entry, resolves the bot name from `config.yaml` (or uses override) +3. Compiles each bot to a standalone binary in `dist/bots/{name}` +4. Generates `dist/bots/manifest.json` listing compiled bots + +``` +dist/bots/ +├── manifest.json # Generated manifest +├── my-bot # Compiled binary +└── simple # Compiled binary +``` + +### Manifest format + +```json +{ + "bots": [{ "name": "bot1" }, { "name": "bot2" }] +} +``` + +## Runtime Modes + +### Bundled bots (recommended for distribution) + +When `dist/bots/manifest.json` exists, the app launches pre-compiled bot binaries. This is the mode used for packaged releases. + +``` +bun run build # Compiles bots and generates manifest +bun run package # Creates distributable app +``` + +### Discovery mode (development without bundled bots) + +When no manifest exists, the app runs in discovery mode - it starts only the emulator and waits for external bots to connect. Useful for development when running bots separately. + +``` +# Terminal 1: Run Electron (no bots) +bun run dev + +# Terminal 2: Run bot separately +cd ../my-bot && bun run dev +``` + +## Packaging + +The packaged app includes: + +- Electron runtime +- Preload script (`dist/preload.cjs`) +- UI (`dist/` from @botarium/ui) +- Slack emulator binary (`dist/slack-emulator`) +- Compiled bots and manifest (`dist/bots/`) + +Configured in `package.json` under the `build` key (electron-builder config). diff --git a/apps/electron/bots.json b/apps/electron/bots.json new file mode 100644 index 0000000..c603a69 --- /dev/null +++ b/apps/electron/bots.json @@ -0,0 +1,3 @@ +{ + "bots": [] +} diff --git a/apps/electron/bots.yaml b/apps/electron/bots.yaml deleted file mode 100644 index 989a4f8..0000000 --- a/apps/electron/bots.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Bots to bundle with the Electron app -# List bots here to compile and embed them in the packaged application -# -# Each bot needs: -# - name: Unique identifier for the bot (used in dist/bots/{name}) -# - source: Path to the bot's source directory (relative or absolute) -# - entry: (optional) Entry point file, defaults to src/index.ts -# -# Example: -# bots: -# - name: my-bot -# source: /path/to/my-bot -# # entry: src/index.ts # default - -bots: [] diff --git a/apps/electron/electron.js b/apps/electron/electron.js index 1b149d8..e70929e 100644 --- a/apps/electron/electron.js +++ b/apps/electron/electron.js @@ -16,6 +16,7 @@ import { import { omit } from 'es-toolkit' import path from 'path' import fs from 'fs' +import net from 'net' import { spawn } from 'child_process' import { fileURLToPath } from 'url' import { @@ -24,6 +25,11 @@ import { emulatorProcLogger, botProcLogger, } from './electron-logger.js' +import { + getModelTiers, + clearModelCache, + validateApiKey, +} from './model-fetcher.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -45,12 +51,11 @@ app.commandLine.appendSwitch('disable-features', 'AutofillServerCommunication') let mainWindow = null let emulatorProcess = null -let botProcesses = new Map() // Map +let botProcesses = new Map() // Map let logsPanelChecked = false // Paths - adjust based on whether running from root (dev) or dist folder (bundled) const useDevServer = process.env.VITE_DEV === '1' -const useBundledBots = process.env.USE_BUNDLED_BOTS === '1' // In dev mode, web app is at ../ui/dist, in packaged mode it's at dist/ const webAppDir = isRunningFromDist @@ -184,6 +189,23 @@ function decryptSensitiveFields(settings) { return decrypted } +/** + * Find an available port by letting the OS assign one + * Returns a Promise that resolves to an available port number + */ +function findAvailablePort() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.unref() + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + const port = address.port + server.close(() => resolve(port)) + }) + }) +} + // Get emulator configuration for dev vs production function getEmulatorConfig() { if (useDevServer) { @@ -219,84 +241,51 @@ function getEmulatorConfig() { } /** - * Read bot configuration from bots.yaml - * Returns array of { name, source, entry } objects + * Read bot manifest from dist/bots/manifest.json (generated by compile-bots.ts) + * Returns array of { name, entry } objects */ -function readBotsConfig() { - const yamlPath = app.isPackaged - ? path.join(process.resourcesPath, 'bots.yaml') - : path.join(__dirname, 'bots.yaml') +function readBotsManifest() { + const manifestPath = app.isPackaged + ? path.join(process.resourcesPath, 'bots', 'manifest.json') + : path.join(__dirname, 'dist', 'bots', 'manifest.json') - if (!fs.existsSync(yamlPath)) { - electronLogger.info('No bots.yaml found - running in discovery mode') + if (!fs.existsSync(manifestPath)) { + electronLogger.info('No bots manifest found - running in discovery mode') return [] } try { - const yaml = fs.readFileSync(yamlPath, 'utf-8') - // Simple YAML parsing for our specific format - const bots = [] - let currentBot = null - - for (const line of yaml.split('\n')) { - const trimmed = line.trim() - if (trimmed.startsWith('#') || trimmed === '') continue - if (trimmed === 'bots:') continue - - if (trimmed.startsWith('- name:')) { - if (currentBot) bots.push(currentBot) - currentBot = { name: trimmed.replace('- name:', '').trim() } - } else if (trimmed.startsWith('source:') && currentBot) { - currentBot.source = trimmed.replace('source:', '').trim() - } else if (trimmed.startsWith('entry:') && currentBot) { - currentBot.entry = trimmed.replace('entry:', '').trim() - } - } - if (currentBot) bots.push(currentBot) - - return bots.filter((b) => b.name && b.source) + const content = fs.readFileSync(manifestPath, 'utf-8') + const manifest = JSON.parse(content) + return manifest.bots ?? [] } catch (err) { - electronLogger.error({ err }, 'Failed to read bots.yaml') + electronLogger.error({ err }, 'Failed to read bots manifest') return [] } } /** * Get bot configurations for starting - * In dev mode: returns configs to run from source - * In production/bundled mode: returns configs for compiled binaries + * Reads from manifest.json generated by compile-bots.ts + * Returns configs for compiled binaries */ function getBotConfigs() { - const botsConfig = readBotsConfig() + const botsManifest = readBotsManifest() const isPackaged = app.isPackaged - if (botsConfig.length === 0) { - return [] - } + electronLogger.info({ botsManifest, isPackaged }, 'getBotConfigs called') - if (useDevServer && !useBundledBots) { - // Dev mode: run from source - return botsConfig - .map((bot) => { - const sourcePath = path.resolve(__dirname, bot.source) - const entry = bot.entry || 'src/index.ts' - return { - type: 'bun', - bunPath: 'bun', - script: path.join(sourcePath, entry), - cwd: sourcePath, - name: bot.name, - } - }) - .filter((config) => fs.existsSync(config.script)) + if (botsManifest.length === 0) { + electronLogger.info('No bots in manifest') + return [] } - // Bundled/production mode: use compiled binaries + // Use compiled binaries from manifest const botsDir = isPackaged ? path.join(process.resourcesPath, 'bots') : path.join(__dirname, 'dist', 'bots') - return botsConfig + return botsManifest .map((bot) => { const binaryPath = path.join(botsDir, bot.name) return { @@ -310,13 +299,24 @@ function getBotConfigs() { // Settings management function loadSettings() { + electronLogger.debug({ settingsPath }, 'Loading settings') try { if (fs.existsSync(settingsPath)) { const data = fs.readFileSync(settingsPath, 'utf-8') const settings = JSON.parse(data) + electronLogger.debug( + { keys: Object.keys(settings).filter((k) => !k.startsWith('_')) }, + 'Settings loaded from file' + ) const decrypted = decryptSensitiveFields(settings) + // Log which fields were decrypted (without values) + const decryptedFields = Object.keys(settings).filter( + (k) => k.endsWith('_encrypted') && settings[k] === true + ) + electronLogger.debug({ decryptedFields }, 'Decrypted sensitive fields') return decrypted } + electronLogger.debug('No settings file found') } catch (err) { electronLogger.error({ err }, 'Failed to load settings') } @@ -324,52 +324,133 @@ function loadSettings() { } function saveSettings(settings) { + electronLogger.debug( + { + settingsPath, + keys: Object.keys(settings).filter((k) => !k.startsWith('_')), + }, + 'Saving settings' + ) try { fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) const encrypted = encryptSensitiveFields(settings) + // Log which fields were encrypted (without values) + const encryptedFields = Object.keys(encrypted).filter( + (k) => k.endsWith('_encrypted') && encrypted[k] === true + ) + electronLogger.debug({ encryptedFields }, 'Encrypted sensitive fields') fs.writeFileSync(settingsPath, JSON.stringify(encrypted, null, 2)) + electronLogger.info('Settings saved successfully') } catch (err) { electronLogger.error({ err }, 'Failed to save settings') throw err } } +// Default models per provider (used when model_default isn't explicitly set) +// These should match the first model in each tier from the UI's MODEL_TIERS +const DEFAULT_MODELS = { + openai: 'gpt-4o', + anthropic: 'claude-sonnet-4-5', + google: 'gemini-2.0-flash', + openrouter: 'anthropic/claude-sonnet-4', // Valid OpenRouter model ID +} + // Convert settings to environment variables function settingsToEnv(settings) { // Use app's userData folder for writable storage (not the read-only app bundle) const dataDir = path.join(app.getPath('userData'), 'data') + // Track all vars injected by the emulator (from UI settings, not user's .env file) + // This allows bots to distinguish between UI-derived env vars and actual .env overrides + const injectedVars = [] + const env = { SLACK_API_URL: `http://localhost:${EMULATOR_PORT}/api`, DATA_DIR: dataDir, } + // Don't merge bot-specific settings into global env + // Bot-specific settings should only apply to their specific bot + const flatSettings = { ...settings } + delete flatSettings.app_settings + + // Fields that should never be in global env - they're bot-specific + const NON_GLOBAL_FIELDS = new Set(['bot_name', 'bot_personality']) + // Convert all settings to env vars using convention: snake_case -> UPPER_SNAKE_CASE - for (const [key, value] of Object.entries(settings)) { + for (const [key, value] of Object.entries(flatSettings)) { if (key.startsWith('_')) continue // Skip internal fields like _schema + if (NON_GLOBAL_FIELDS.has(key)) continue // Skip bot-specific fields if (value === undefined || value === null || value === '') continue if (typeof value !== 'string' && typeof value !== 'number') continue // Convert snake_case to UPPER_SNAKE_CASE const envKey = key.toUpperCase() env[envKey] = String(value) + injectedVars.push(envKey) } // Special handling: set provider-specific API key env var - // Bots expect OPENAI_API_KEY, ANTHROPIC_API_KEY, or GOOGLE_API_KEY - const provider = settings.ai_provider - const apiKey = settings[`${provider}_api_key`] + // Bots expect OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY, or OPENROUTER_API_KEY + const provider = flatSettings.ai_provider + const apiKey = flatSettings[`${provider}_api_key`] if (apiKey) { const keyMap = { openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', google: 'GOOGLE_API_KEY', + openrouter: 'OPENROUTER_API_KEY', } if (keyMap[provider]) { env[keyMap[provider]] = apiKey + injectedVars.push(keyMap[provider]) + } + } + + // Check if a model ID is compatible with the selected provider + // OpenRouter uses "org/model" format, others use simple names + const isModelCompatibleWithProvider = (modelId, prov) => { + if (!modelId || !prov) return false + const hasSlash = modelId.includes('/') + // OpenRouter models contain "/", other providers don't + return prov === 'openrouter' ? hasSlash : !hasSlash + } + + // Reset model to provider default if not set or incompatible with current provider + // This handles the case where user switches providers (e.g., OpenRouter → OpenAI) + // and the old model ID format is incompatible with the new provider + if (provider && DEFAULT_MODELS[provider]) { + if ( + !env.MODEL_DEFAULT || + !isModelCompatibleWithProvider(env.MODEL_DEFAULT, provider) + ) { + env.MODEL_DEFAULT = DEFAULT_MODELS[provider] + // MODEL_DEFAULT might already be in injectedVars if it came from settings + if (!injectedVars.includes('MODEL_DEFAULT')) { + injectedVars.push('MODEL_DEFAULT') + } + } + // Apply same logic to MODEL_FAST and MODEL_THINKING for consistency + if ( + env.MODEL_FAST && + !isModelCompatibleWithProvider(env.MODEL_FAST, provider) + ) { + delete env.MODEL_FAST // Let the bot use its default + } + if ( + env.MODEL_THINKING && + !isModelCompatibleWithProvider(env.MODEL_THINKING, provider) + ) { + delete env.MODEL_THINKING // Let the bot use its default } } + // Pass the list of injected vars to the bot so it can exclude them from envOverrides + if (injectedVars.length > 0) { + env._EMULATOR_INJECTED_VARS = injectedVars.join(',') + } + return env } @@ -536,8 +617,9 @@ function spawnProcess(config, env, label, forwardLogs = false) { } // Start a single bot process -function startBotProcess(botConfig, settingsEnv, botName) { - const proc = spawnProcess(botConfig, settingsEnv, `Bot:${botName}`, true) +function startBotProcess(botConfig, settingsEnv, botName, botPort) { + const botEnv = { ...settingsEnv, PORT: String(botPort) } + const proc = spawnProcess(botConfig, botEnv, `Bot:${botName}`, true) proc.on('error', (err) => { botProcLogger.error({ err, bot: botName }, 'Failed to start bot') @@ -563,7 +645,7 @@ function startBotProcess(botConfig, settingsEnv, botName) { } }) - botProcesses.set(botName, proc) + botProcesses.set(botName, { process: proc }) return proc } @@ -622,6 +704,23 @@ async function startBackend(settings) { } emulatorProcLogger.info('Ready') + // Push global settings to emulator so external bots can receive them on registration + // Include app_settings so emulator can return bot-specific settings on registration + try { + const settingsPayload = { + ...settingsEnv, + _app_settings: settings.app_settings || {}, + } + await fetch(`${EMULATOR_URL}/api/simulator/settings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settingsPayload), + }) + electronLogger.debug('Pushed settings to emulator') + } catch (err) { + electronLogger.warn({ err }, 'Failed to push settings to emulator') + } + // 2. Start bots from bots.yaml configuration const botConfigs = getBotConfigs() const startedBots = botConfigs.map((b) => b.name) @@ -629,11 +728,13 @@ async function startBackend(settings) { if (botConfigs.length > 0) { botProcLogger.info(`Starting ${botConfigs.length} bot(s) from bots.yaml`) for (const config of botConfigs) { + // Find an available port for this bot + const botPort = await findAvailablePort() botProcLogger.info( - { bot: config.name, type: config.type }, + { bot: config.name, type: config.type, port: botPort }, `Starting bot: ${config.name}` ) - startBotProcess(config, settingsEnv, config.name) + startBotProcess(config, settingsEnv, config.name, botPort) } } else { botProcLogger.info( @@ -698,7 +799,7 @@ async function stopBackend() { const promises = [] // Stop all bot processes - for (const [botName, proc] of botProcesses) { + for (const [botName, { process: proc }] of botProcesses) { promises.push(stopProcess(proc, `Bot:${botName}`)) } botProcesses.clear() @@ -756,14 +857,45 @@ async function waitForBotConnection(retries = 20, delay = 500) { // IPC Handlers function setupIpcHandlers() { ipcMain.handle('settings:load', () => { - return loadSettings() + electronLogger.debug('IPC: settings:load called') + const settings = loadSettings() + electronLogger.debug( + { hasSettings: !!settings, hasApiKey: !!settings?.openai_api_key }, + 'IPC: settings:load returning' + ) + return settings }) ipcMain.handle('settings:save', async (_event, settings) => { + electronLogger.debug( + { hasApiKey: !!settings?.openai_api_key }, + 'IPC: settings:save called' + ) + + // Check if API keys changed and clear model cache + const oldSettings = loadSettings() || {} + const apiKeyFields = [ + 'openai_api_key', + 'anthropic_api_key', + 'google_api_key', + 'openrouter_api_key', + ] + for (const field of apiKeyFields) { + if (oldSettings[field] !== settings[field]) { + const provider = field.replace('_api_key', '') + clearModelCache(provider) + electronLogger.debug( + { provider }, + 'Cleared model cache due to API key change' + ) + } + } + saveSettings(settings) // Restart backend with new settings await stopBackend() await startBackend(settings) + electronLogger.debug('IPC: settings:save completed') }) ipcMain.handle('backend:restart', async () => { @@ -787,6 +919,98 @@ function setupIpcHandlers() { ipcMain.on('logs-panel:state-changed', (_event, visible) => { updateLogsPanelMenuState(visible) }) + + // Model tiers - fetch dynamic models from provider APIs + // Accepts optional apiKeys to override saved settings (useful for validation before saving) + ipcMain.handle('models:getTiers', async (_event, overrideApiKeys) => { + const settings = loadSettings() || {} + const apiKeys = { + openai_api_key: settings.openai_api_key, + anthropic_api_key: settings.anthropic_api_key, + google_api_key: settings.google_api_key, + openrouter_api_key: settings.openrouter_api_key, + ...overrideApiKeys, + } + return getModelTiers(apiKeys) + }) + + // Clear model cache (when API keys change) + ipcMain.handle('models:clearCache', async (_event, provider) => { + clearModelCache(provider) + }) + + // Validate API key + ipcMain.handle('models:validateKey', async (_event, provider, apiKey) => { + return validateApiKey(provider, apiKey) + }) + + // Fetch bot config (proxied to avoid renderer CSP issues) + // Bots register their config port with the emulator, so we query the emulator to get it + // Includes retry logic since bot config server may not be immediately available + ipcMain.handle('bot:fetchConfig', async (_event, botId) => { + let configPort = null + + try { + const botsResponse = await fetch(`${EMULATOR_URL}/api/simulator/bots`) + if (botsResponse.ok) { + const botsData = await botsResponse.json() + const bot = botsData.bots?.find((b) => b.id === botId) + if (bot?.configPort) { + configPort = bot.configPort + electronLogger.debug( + { botId, configPort }, + 'Found config port from emulator' + ) + } + } + } catch (error) { + electronLogger.debug( + { error: error.message, botId }, + 'Failed to query emulator for bot config port' + ) + } + + if (!configPort) { + electronLogger.debug( + { botId }, + 'Bot config port not found - bot may not have registered yet' + ) + return null + } + + const MAX_RETRIES = 10 + const RETRY_DELAY = 300 + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const response = await fetch(`http://127.0.0.1:${configPort}/config`) + if (response.ok) { + const data = await response.json() + electronLogger.debug( + { botId, configPort, attempt }, + 'Bot config fetched successfully' + ) + return data + } + electronLogger.debug( + { status: response.status, attempt, botId }, + 'Config server returned error' + ) + } catch (error) { + electronLogger.debug( + { error: error.message, attempt, botId }, + 'Config server not ready' + ) + } + + if (attempt < MAX_RETRIES) { + await new Promise((r) => setTimeout(r, RETRY_DELAY)) + } + } + + electronLogger.debug({ botId }, 'Bot config not available after retries') + return null + }) } function createWindow() { @@ -988,6 +1212,19 @@ app.on('activate', () => { } }) -app.on('before-quit', async () => { +// Track if we're currently cleaning up to avoid double-cleanup +let isQuitting = false + +app.on('before-quit', async (event) => { + if (isQuitting) return // Already cleaning up + + // Prevent immediate quit + event.preventDefault() + isQuitting = true + + // Stop backend processes await stopBackend() + + // Now actually quit + app.quit() }) diff --git a/apps/electron/model-fetcher.js b/apps/electron/model-fetcher.js new file mode 100644 index 0000000..0fd1bec --- /dev/null +++ b/apps/electron/model-fetcher.js @@ -0,0 +1,650 @@ +/** + * Dynamic model fetching for AI providers + * Fetches available models from provider APIs and categorizes them into tiers + * with in-memory caching and fallback to hardcoded defaults + */ + +/* global fetch */ + +import { electronLogger } from './electron-logger.js' + +// Cache duration: 30 minutes +const CACHE_DURATION_MS = 30 * 60 * 1000 + +// In-memory cache: { provider: { models, timestamp } } +const modelCache = new Map() + +// Hardcoded fallback model tiers (kept in sync with simulator-settings.ts) +const FALLBACK_MODEL_TIERS = { + openai: { + fast: ['gpt-5-mini', 'gpt-4o-mini'], + default: ['gpt-5.2', 'gpt-4o', 'gpt-4o-mini'], + thinking: ['o3', 'o3-mini', 'o1'], + }, + anthropic: { + fast: ['claude-haiku-4-5'], + default: ['claude-sonnet-4-5'], + thinking: ['claude-opus-4-5'], + }, + google: { + fast: ['gemini-2.0-flash', 'gemini-2.0-flash-lite'], + default: ['gemini-3-flash-preview', 'gemini-2.5-pro'], + thinking: ['gemini-2.5-pro', 'gemini-2.0-flash-thinking-exp'], + }, + openrouter: { + fast: [ + 'openai/gpt-4o-mini', + 'anthropic/claude-3-5-haiku', + 'google/gemini-2.0-flash-001', + ], + default: [ + 'openai/gpt-4o', + 'anthropic/claude-sonnet-4', + 'google/gemini-2.5-pro', + ], + thinking: [ + 'openai/o3-mini', + 'anthropic/claude-opus-4', + 'google/gemini-2.5-pro', + ], + }, +} + +/** + * Categorize OpenAI models into tiers + * @param {string[]} modelIds - List of model IDs from API + * @returns {{ fast: string[], default: string[], thinking: string[] }} + */ +function categorizeOpenAIModels(modelIds) { + const tiers = { fast: [], default: [], thinking: [] } + + for (const id of modelIds) { + const lower = id.toLowerCase() + + // Thinking models: o1, o3 series + if (lower.startsWith('o3') || lower.startsWith('o1')) { + tiers.thinking.push(id) + } + // Fast models: mini + else if (lower.includes('mini')) { + tiers.fast.push(id) + } + // Default models: gpt-5.2, gpt-4o etc (non-mini, non-turbo) + else if (lower.startsWith('gpt-5.2') || lower.startsWith('gpt-4o')) { + tiers.default.push(id) + } + } + + return tiers +} + +/** + * Categorize Anthropic models into tiers + * @param {string[]} modelIds - List of model IDs from API + * @returns {{ fast: string[], default: string[], thinking: string[] }} + */ +function categorizeAnthropicModels(modelIds) { + const tiers = { fast: [], default: [], thinking: [] } + + for (const id of modelIds) { + const lower = id.toLowerCase() + + // Thinking models: opus + if (lower.includes('opus')) { + tiers.thinking.push(id) + } + // Fast models: haiku + else if (lower.includes('haiku')) { + tiers.fast.push(id) + } + // Default models: sonnet + else if (lower.includes('sonnet')) { + tiers.default.push(id) + } + } + + return tiers +} + +/** + * Categorize Google models into tiers + * @param {string[]} modelIds - List of model IDs from API + * @returns {{ fast: string[], default: string[], thinking: string[] }} + */ +function categorizeGoogleModels(modelIds) { + const tiers = { fast: [], default: [], thinking: [] } + + for (const id of modelIds) { + const lower = id.toLowerCase() + + // Thinking models: thinking, or pro (also in thinking for complex reasoning) + if (lower.includes('thinking')) { + tiers.thinking.push(id) + } + // Fast models: flash + else if (lower.includes('flash')) { + tiers.fast.push(id) + } + // Default models: pro + else if (lower.includes('pro')) { + tiers.default.push(id) + // Pro models can also be used for thinking tasks + tiers.thinking.push(id) + } + } + + return tiers +} + +/** + * Sort OpenRouter models by preference (newer/better models first) + * @param {string[]} models - List of model IDs + * @returns {string[]} - Sorted models with preferred ones first + */ +function sortOpenRouterModelsByPreference(models) { + // Preferred models in order (best first) + const preferredOrder = [ + // Anthropic - newest first + 'anthropic/claude-sonnet-4', + 'anthropic/claude-opus-4', + 'anthropic/claude-3.5-sonnet', + 'anthropic/claude-3-5-haiku', + 'anthropic/claude-3-haiku', + // OpenAI - newest first + 'openai/gpt-5', + 'openai/gpt-4o', + 'openai/gpt-4o-mini', + 'openai/o3', + 'openai/o3-mini', + 'openai/o1', + // Google - newest first + 'google/gemini-2.5-pro', + 'google/gemini-2.0-flash', + 'google/gemini-2.0-flash-001', + ] + + return models.sort((a, b) => { + const aIndex = preferredOrder.findIndex((p) => a.startsWith(p)) + const bIndex = preferredOrder.findIndex((p) => b.startsWith(p)) + + // Both in preferred list - sort by preference order + if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex + // Only a in preferred list - a comes first + if (aIndex !== -1) return -1 + // Only b in preferred list - b comes first + if (bIndex !== -1) return 1 + // Neither in preferred list - alphabetical + return a.localeCompare(b) + }) +} + +/** + * Categorize OpenRouter models into tiers + * Since OpenRouter aggregates models from multiple providers, categorize by naming patterns + * @param {string[]} modelIds - List of model IDs from API + * @returns {{ fast: string[], default: string[], thinking: string[] }} + */ +function categorizeOpenRouterModels(modelIds) { + const tiers = { fast: [], default: [], thinking: [] } + + for (const id of modelIds) { + const lower = id.toLowerCase() + + // Thinking tier: opus, o1, o3, thinking, deep models + // Check before fast tier to correctly classify o1-mini, o3-mini as thinking + if ( + lower.includes('opus') || + lower.includes('/o1') || + lower.includes('/o3') || + lower.includes('thinking') || + lower.includes('deep') + ) { + tiers.thinking.push(id) + } + // Fast tier: mini, flash, haiku, instant, lite, small, tiny models + else if ( + lower.includes('mini') || + lower.includes('flash') || + lower.includes('haiku') || + lower.includes('instant') || + lower.includes('lite') || + lower.includes('small') || + lower.includes('tiny') + ) { + tiers.fast.push(id) + } + // Default tier: general-purpose models (sonnet, gpt-4o, pro, llama, mistral, etc.) + else if ( + lower.includes('sonnet') || + lower.includes('gpt-4o') || + lower.includes('gpt-5') || + lower.includes('pro') || + lower.includes('claude-3') || + lower.includes('gemini') || + lower.includes('llama') || + lower.includes('mistral') || + lower.includes('mixtral') + ) { + tiers.default.push(id) + } + } + + // Sort each tier by preference (newer models first) + tiers.fast = sortOpenRouterModelsByPreference(tiers.fast) + tiers.default = sortOpenRouterModelsByPreference(tiers.default) + tiers.thinking = sortOpenRouterModelsByPreference(tiers.thinking) + + return tiers +} + +/** + * Fetch models from OpenAI API + * @param {string} apiKey - OpenAI API key + * @returns {Promise<{ fast: string[], default: string[], thinking: string[] } | null>} + */ +async function fetchOpenAIModels(apiKey) { + try { + const response = await fetch('https://api.openai.com/v1/models', { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + if (!response.ok) { + electronLogger.warn( + { status: response.status }, + 'OpenAI models API returned error' + ) + return null + } + + const data = await response.json() + const modelIds = data.data + .map((m) => m.id) + .filter( + (id) => + // Filter to only chat models (gpt, o1, o3) + id.startsWith('gpt-') || id.startsWith('o1') || id.startsWith('o3') + ) + .sort() + + return categorizeOpenAIModels(modelIds) + } catch (error) { + electronLogger.warn( + { error: error.message }, + 'Failed to fetch OpenAI models' + ) + return null + } +} + +/** + * Fetch models from Anthropic API + * @param {string} apiKey - Anthropic API key + * @returns {Promise<{ fast: string[], default: string[], thinking: string[] } | null>} + */ +async function fetchAnthropicModels(apiKey) { + try { + electronLogger.debug('Fetching Anthropic models...') + const response = await fetch('https://api.anthropic.com/v1/models', { + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + }) + + if (!response.ok) { + const text = await response.text() + electronLogger.warn( + { status: response.status, body: text.slice(0, 200) }, + 'Anthropic models API returned error' + ) + return null + } + + const data = await response.json() + electronLogger.debug( + { modelCount: data.data?.length }, + 'Anthropic API response received' + ) + const modelIds = data.data + .map((m) => m.id) + .filter((id) => + // Filter to Claude models + id.includes('claude') + ) + .sort() + + electronLogger.debug({ modelIds }, 'Anthropic models filtered') + return categorizeAnthropicModels(modelIds) + } catch (error) { + electronLogger.warn( + { error: error.message }, + 'Failed to fetch Anthropic models' + ) + return null + } +} + +/** + * Fetch models from Google AI API + * @param {string} apiKey - Google API key + * @returns {Promise<{ fast: string[], default: string[], thinking: string[] } | null>} + */ +async function fetchGoogleModels(apiKey) { + try { + electronLogger.debug('Fetching Google models...') + const response = await fetch( + 'https://generativelanguage.googleapis.com/v1beta/models', + { + headers: { + 'x-goog-api-key': apiKey, + }, + } + ) + + if (!response.ok) { + const text = await response.text() + electronLogger.warn( + { status: response.status, body: text.slice(0, 200) }, + 'Google models API returned error' + ) + return null + } + + const data = await response.json() + electronLogger.debug( + { modelCount: data.models?.length }, + 'Google API response received' + ) + const modelIds = data.models + .map((m) => m.name.replace('models/', '')) + .filter((id) => + // Filter to Gemini models + id.includes('gemini') + ) + .sort() + + electronLogger.debug({ modelIds }, 'Google models filtered') + return categorizeGoogleModels(modelIds) + } catch (error) { + electronLogger.warn( + { error: error.message }, + 'Failed to fetch Google models' + ) + return null + } +} + +/** + * Fetch models from OpenRouter API + * @param {string} apiKey - OpenRouter API key + * @returns {Promise<{ fast: string[], default: string[], thinking: string[] } | null>} + */ +async function fetchOpenRouterModels(apiKey) { + try { + electronLogger.debug('Fetching OpenRouter models...') + const response = await fetch('https://openrouter.ai/api/v1/models', { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + if (!response.ok) { + const text = await response.text() + electronLogger.warn( + { status: response.status, body: text.slice(0, 200) }, + 'OpenRouter models API returned error' + ) + return null + } + + const data = await response.json() + electronLogger.debug( + { modelCount: data.data?.length }, + 'OpenRouter API response received' + ) + + // Filter to well-known provider models to avoid overwhelming users + const allowedPrefixes = [ + 'openai/', + 'anthropic/', + 'google/', + 'meta-llama/', + 'mistralai/', + ] + + const modelIds = data.data + .map((m) => m.id) + .filter((id) => allowedPrefixes.some((prefix) => id.startsWith(prefix))) + .sort() + + electronLogger.debug( + { modelCount: modelIds.length }, + 'OpenRouter models filtered' + ) + return categorizeOpenRouterModels(modelIds) + } catch (error) { + electronLogger.warn( + { error: error.message }, + 'Failed to fetch OpenRouter models' + ) + return null + } +} + +/** + * Get model tiers for a provider, using cache or fetching from API + * @param {string} provider - Provider name (openai, anthropic, google) + * @param {string} apiKey - API key for the provider + * @returns {Promise<{ fast: string[], default: string[], thinking: string[] }>} + */ +async function getModelTiersForProvider(provider, apiKey) { + // Check cache first + const cached = modelCache.get(provider) + if (cached && Date.now() - cached.timestamp < CACHE_DURATION_MS) { + electronLogger.debug({ provider }, 'Using cached model tiers') + return cached.tiers + } + + // No API key - return fallback + if (!apiKey) { + const fallback = FALLBACK_MODEL_TIERS[provider] || { + fast: [], + default: [], + thinking: [], + } + electronLogger.info( + { + provider, + fast: fallback.fast.length, + default: fallback.default.length, + thinking: fallback.thinking.length, + }, + 'No API key, using fallback model tiers' + ) + return fallback + } + + electronLogger.debug( + { provider, hasKey: !!apiKey }, + 'Fetching models from API' + ) + + // Fetch from API + let tiers = null + switch (provider) { + case 'openai': + tiers = await fetchOpenAIModels(apiKey) + break + case 'anthropic': + tiers = await fetchAnthropicModels(apiKey) + break + case 'google': + tiers = await fetchGoogleModels(apiKey) + break + case 'openrouter': + tiers = await fetchOpenRouterModels(apiKey) + break + } + + // Use fallback if fetch failed or returned empty tiers + if ( + !tiers || + (tiers.fast.length === 0 && + tiers.default.length === 0 && + tiers.thinking.length === 0) + ) { + electronLogger.debug({ provider }, 'Fetch failed or empty, using fallback') + tiers = FALLBACK_MODEL_TIERS[provider] || { + fast: [], + default: [], + thinking: [], + } + } else { + // Cache successful result + modelCache.set(provider, { tiers, timestamp: Date.now() }) + electronLogger.info( + { + provider, + fast: tiers.fast.length, + default: tiers.default.length, + thinking: tiers.thinking.length, + }, + 'Fetched and cached model tiers' + ) + } + + return tiers +} + +/** + * Get all model tiers for all providers + * @param {Record} apiKeys - Map of provider to API key + * @returns {Promise>} + */ +export async function getModelTiers(apiKeys = {}) { + const providers = ['openai', 'anthropic', 'google', 'openrouter'] + const result = {} + + // Fetch all providers in parallel + const promises = providers.map(async (provider) => { + const apiKey = apiKeys[`${provider}_api_key`] || apiKeys[provider] + result[provider] = await getModelTiersForProvider(provider, apiKey) + }) + + await Promise.all(promises) + return result +} + +/** + * Clear the model cache for a specific provider or all providers + * @param {string} [provider] - Optional provider to clear, or all if not specified + */ +export function clearModelCache(provider) { + if (provider) { + modelCache.delete(provider) + electronLogger.debug({ provider }, 'Cleared model cache for provider') + } else { + modelCache.clear() + electronLogger.debug('Cleared all model caches') + } +} + +/** + * Get the hardcoded fallback model tiers + * @returns {Record} + */ +export function getFallbackModelTiers() { + return FALLBACK_MODEL_TIERS +} + +/** + * Validate an API key by making a lightweight API call + * @param {string} provider - Provider name (openai, anthropic, google) + * @param {string} apiKey - API key to validate + * @returns {Promise<{ valid: boolean, error?: string }>} + */ +export async function validateApiKey(provider, apiKey) { + if (!apiKey || apiKey.trim() === '') { + return { valid: false, error: 'API key is empty' } + } + + try { + switch (provider) { + case 'openai': { + const response = await fetch('https://api.openai.com/v1/models', { + method: 'GET', + headers: { Authorization: `Bearer ${apiKey}` }, + }) + if (response.ok) { + return { valid: true } + } + const data = await response.json().catch(() => ({})) + return { + valid: false, + error: data.error?.message || `HTTP ${response.status}`, + } + } + + case 'anthropic': { + const response = await fetch('https://api.anthropic.com/v1/models', { + method: 'GET', + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + }) + if (response.ok) { + return { valid: true } + } + const data = await response.json().catch(() => ({})) + return { + valid: false, + error: data.error?.message || `HTTP ${response.status}`, + } + } + + case 'google': { + const response = await fetch( + 'https://generativelanguage.googleapis.com/v1beta/models', + { + headers: { + 'x-goog-api-key': apiKey, + }, + } + ) + if (response.ok) { + return { valid: true } + } + const data = await response.json().catch(() => ({})) + return { + valid: false, + error: data.error?.message || `HTTP ${response.status}`, + } + } + + case 'openrouter': { + // Use /api/v1/auth/key endpoint which requires valid authentication + // The /models endpoint is public and doesn't validate the key + const response = await fetch('https://openrouter.ai/api/v1/auth/key', { + method: 'GET', + headers: { Authorization: `Bearer ${apiKey}` }, + }) + if (response.ok) { + return { valid: true } + } + const data = await response.json().catch(() => ({})) + return { + valid: false, + error: data.error?.message || data.error || `HTTP ${response.status}`, + } + } + + default: + return { valid: false, error: `Unknown provider: ${provider}` } + } + } catch (error) { + return { valid: false, error: error.message } + } +} diff --git a/apps/electron/package.json b/apps/electron/package.json index 41dc353..0963fab 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -6,13 +6,10 @@ "private": true, "main": "electron.js", "scripts": { - "dev:electron": "bun run build && VITE_DEV=1 concurrently -k \"bun run --filter @botarium/ui dev:server\" \"wait-on http://localhost:5173 && electron .\"", - "electron": "electron .", - "build": "bun build src/preload.ts --outfile=dist/preload.cjs --target=node --format=cjs --external=electron", - "build:bots": "bun run scripts/compile-bots.ts", - "build:full": "bun run build && bun run --filter @botarium/ui build", - "package": "bun run build:full && electron-builder", - "package:with-bots": "bun run build:bots && bun run package", + "dev": "bun run build:preload && VITE_DEV=1 concurrently -k \"bun run --filter @botarium/ui dev:server\" \"wait-on http://localhost:5173 && electron .\"", + "build:preload": "bun build src/preload.ts --outfile=dist/preload.cjs --target=node --format=cjs --external=electron", + "build": "bun run build:preload && bun run scripts/compile-bots.ts && bun run --filter @botarium/ui build", + "package": "bun run build && electron-builder", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -55,10 +52,6 @@ "filter": [ "**/*" ] - }, - { - "from": "bots.yaml", - "to": "bots.yaml" } ], "mac": { diff --git a/apps/electron/scripts/compile-bots.ts b/apps/electron/scripts/compile-bots.ts index 7edbaee..0508ffb 100644 --- a/apps/electron/scripts/compile-bots.ts +++ b/apps/electron/scripts/compile-bots.ts @@ -1,5 +1,5 @@ /** - * Compile bots from bots.yaml for bundling with the Electron app. + * Compile bots from bots.json for bundling with the Electron app. * Each bot is compiled to a standalone binary in dist/bots/{name} */ @@ -7,75 +7,136 @@ import { $ } from 'bun' import path from 'path' import fs from 'fs' -interface BotConfig { +// Bot entry - either a simple path string or an object with overrides +type BotEntry = string | { source: string; name?: string; entry?: string } + +// Resolved bot config ready for compilation +interface ResolvedBot { name: string source: string - entry?: string + entry: string } -interface BotsYaml { - bots?: BotConfig[] +interface BotsConfig { + bots?: BotEntry[] } const ROOT_DIR = path.join(import.meta.dir, '..') const OUTPUT_DIR = path.join(ROOT_DIR, 'dist', 'bots') -const YAML_PATH = path.join(ROOT_DIR, 'bots.yaml') - -// Simple YAML parser for our specific format -function parseBotsYaml(content: string): BotsYaml { - const bots: BotConfig[] = [] - let currentBot: Partial | null = null +const CONFIG_PATH = path.join(ROOT_DIR, 'bots.json') - for (const line of content.split('\n')) { - const trimmed = line.trim() - if (trimmed.startsWith('#') || trimmed === '') continue - if (trimmed === 'bots:') continue +// Parse config.yaml from bot's source directory to get simulator.id +function getBotNameFromConfig(sourcePath: string): string | null { + const configPath = path.join(sourcePath, 'config.yaml') + if (!fs.existsSync(configPath)) { + return null + } + const content = fs.readFileSync(configPath, 'utf-8') + let config: { simulator?: { id?: string } } + try { + config = Bun.YAML.parse(content) as { simulator?: { id?: string } } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`Failed to parse ${configPath}: ${message}`) + return null + } + return config.simulator?.id ?? null +} - if (trimmed.startsWith('- name:')) { - if (currentBot && currentBot.name && currentBot.source) { - bots.push(currentBot as BotConfig) - } - currentBot = { name: trimmed.replace('- name:', '').trim() } - } else if (trimmed.startsWith('source:') && currentBot) { - currentBot.source = trimmed.replace('source:', '').trim() - } else if (trimmed.startsWith('entry:') && currentBot) { - currentBot.entry = trimmed.replace('entry:', '').trim() - } +// Resolve a bot entry to a full config with name +function resolveBotEntry(entry: BotEntry): ResolvedBot | null { + const source = typeof entry === 'string' ? entry : entry.source + const sourcePath = path.resolve(ROOT_DIR, source) + + // Get name: use override if provided, otherwise read from config.yaml + let name: string | undefined + if (typeof entry === 'object' && entry.name) { + name = entry.name + } else { + name = getBotNameFromConfig(sourcePath) ?? undefined } - if (currentBot && currentBot.name && currentBot.source) { - bots.push(currentBot as BotConfig) + if (!name) { + console.error(`Error: Cannot determine bot name for ${source}`) + console.error( + ` No 'name' override provided and no simulator.id found in config.yaml` + ) + return null } - return { bots } + return { + name, + source, + entry: + (typeof entry === 'object' ? entry.entry : undefined) ?? 'src/app.ts', + } } async function main() { - if (!fs.existsSync(YAML_PATH)) { - console.log('No bots.yaml found - nothing to compile') + if (!fs.existsSync(CONFIG_PATH)) { + console.log('No bots.json found - nothing to compile') process.exit(0) } - const yamlContent = fs.readFileSync(YAML_PATH, 'utf-8') - const config = parseBotsYaml(yamlContent) - const bots = config.bots ?? [] + const content = fs.readFileSync(CONFIG_PATH, 'utf-8') + let config: BotsConfig + try { + config = JSON.parse(content) as BotsConfig + } catch (error) { + console.error( + `Failed to parse ${CONFIG_PATH}: ${error instanceof Error ? error.message : error}` + ) + process.exit(1) + } + const botEntries = config.bots ?? [] - if (bots.length === 0) { - console.log('No bots configured in bots.yaml') + if (botEntries.length === 0) { + console.log('No bots configured in bots.json - clearing dist/bots/') + if (fs.existsSync(OUTPUT_DIR)) { + fs.rmSync(OUTPUT_DIR, { recursive: true }) + } + fs.mkdirSync(OUTPUT_DIR, { recursive: true }) + fs.writeFileSync( + path.join(OUTPUT_DIR, 'manifest.json'), + JSON.stringify({ bots: [] }, null, 2) + ) process.exit(0) } + // Resolve all bot entries to full configs + const bots: ResolvedBot[] = [] + for (const entry of botEntries) { + const resolved = resolveBotEntry(entry) + if (resolved) { + const existing = bots.find((b) => b.name === resolved.name) + if (existing) { + console.error(`Error: Duplicate bot name "${resolved.name}"`) + console.error(` First entry: ${existing.source}`) + console.error(` Conflicting entry: ${resolved.source}`) + console.error( + `Duplicate names would cause compiled outputs to be overwritten. Please fix before compiling.` + ) + process.exit(1) + } + bots.push(resolved) + } + } + + if (bots.length === 0) { + console.log('No valid bots found after resolving entries') + process.exit(1) + } + console.log(`Found ${bots.length} bot(s) to compile`) // Ensure output directory exists fs.mkdirSync(OUTPUT_DIR, { recursive: true }) // Compile each bot - let successCount = 0 + const compiledBots: { name: string; entry: string }[] = [] for (const bot of bots) { - const entry = bot.entry ?? 'src/index.ts' const sourcePath = path.resolve(ROOT_DIR, bot.source) - const entryPath = path.join(sourcePath, entry) + const entryPath = path.join(sourcePath, bot.entry) const outfile = path.join(OUTPUT_DIR, bot.name) // Verify source exists @@ -85,25 +146,37 @@ async function main() { continue } + // Remove any pre-existing binary to avoid stale code in manifest + if (fs.existsSync(outfile)) { + fs.rmSync(outfile) + } + console.log(`\nCompiling ${bot.name}...`) console.log(` Source: ${sourcePath}`) - console.log(` Entry: ${entry}`) + console.log(` Entry: ${bot.entry}`) console.log(` Output: ${outfile}`) try { await $`bun build ${entryPath} --compile --outfile=${outfile}`.quiet() console.log(` Done`) - successCount++ + compiledBots.push({ name: bot.name, entry: bot.entry }) } catch (error) { console.error(` Failed to compile ${bot.name}:`, error) } } + const manifestPath = path.join(OUTPUT_DIR, 'manifest.json') + fs.writeFileSync( + manifestPath, + JSON.stringify({ bots: compiledBots }, null, 2) + ) + console.log(`\nGenerated manifest: ${manifestPath}`) + console.log( - `\nCompiled ${successCount}/${bots.length} bot(s) to ${OUTPUT_DIR}` + `\nCompiled ${compiledBots.length}/${bots.length} bot(s) to ${OUTPUT_DIR}` ) - if (successCount === 0 && bots.length > 0) { + if (compiledBots.length === 0 && bots.length > 0) { process.exit(1) } } diff --git a/apps/electron/src/preload.ts b/apps/electron/src/preload.ts index a380b83..a09d239 100644 --- a/apps/electron/src/preload.ts +++ b/apps/electron/src/preload.ts @@ -43,4 +43,23 @@ contextBridge.exposeInMainWorld('electronAPI', { notifyLogsPanelState: (visible: boolean): void => { ipcRenderer.send('logs-panel:state-changed', visible) }, + + // Fetch bot config (proxied through main process to avoid CSP issues) + fetchBotConfig: (botId: string): Promise => + ipcRenderer.invoke('bot:fetchConfig', botId), + + // Dynamic model tiers - fetch from provider APIs + // Optional apiKeys parameter overrides saved settings (useful for validation before saving) + getModelTiers: ( + apiKeys?: Record + ): Promise< + Record + > => ipcRenderer.invoke('models:getTiers', apiKeys), + clearModelCache: (provider?: string): Promise => + ipcRenderer.invoke('models:clearCache', provider), + validateApiKey: ( + provider: string, + apiKey: string + ): Promise<{ valid: boolean; error?: string }> => + ipcRenderer.invoke('models:validateKey', provider, apiKey), }) diff --git a/apps/ui/src/components/AppSettings.svelte b/apps/ui/src/components/AppSettings.svelte index 5d5684e..2016f27 100644 --- a/apps/ui/src/components/AppSettings.svelte +++ b/apps/ui/src/components/AppSettings.svelte @@ -4,6 +4,10 @@ import { Button } from '$lib/components/ui/button' import IconButton from './IconButton.svelte' import DynamicSettings from './DynamicSettings.svelte' + import { + SIMULATOR_SETTINGS_SCHEMA, + BOT_OVERRIDABLE_SETTINGS, + } from '../lib/simulator-settings' interface Props { appId: string @@ -16,7 +20,7 @@ } let { - appId: _appId, + appId, appName, globalSettings, appSettings, @@ -31,10 +35,30 @@ let error = $state('') let dialogEl: HTMLDialogElement | undefined = $state() + // Filter global settings to only include inheritable fields (simulator settings + bot-overridable) + // Bot-specific fields like bot_name shouldn't be inherited from global settings + const inheritableGlobalSettings = $derived.by(() => { + const result: Record = {} + for (const [key, value] of Object.entries(globalSettings)) { + const isSimulatorSetting = key in SIMULATOR_SETTINGS_SCHEMA.settings + const isBotOverridable = ( + BOT_OVERRIDABLE_SETTINGS as readonly string[] + ).includes(key) + if (isSimulatorSetting || isBotOverridable) { + result[key] = value + } + } + return result + }) + // Initialize form data with merged settings only when modal opens $effect.pre(() => { if (open) { - formData = untrack(() => ({ ...globalSettings, ...appSettings })) + // Only inherit simulator settings and bot-overridable settings from global + // Bot-specific fields should come from appSettings or the bot's defaults + // Use JSON parse/stringify to ensure plain objects (no reactive proxies) + const merged = { ...inheritableGlobalSettings, ...appSettings } + formData = untrack(() => JSON.parse(JSON.stringify(merged))) } }) @@ -53,8 +77,50 @@ error = '' saving = true try { - const snapshot = $state.snapshot(formData) - await onSave(snapshot) + // Use JSON parse/stringify to create a plain object copy + // This avoids $state.snapshot issues with reactive proxies + const snapshot = JSON.parse(JSON.stringify(formData)) + + const currentProvider = snapshot.ai_provider + + // Filter out bot-overridable settings that match global values + // Only save settings that are explicitly different from inherited + const filteredSettings: Record = {} + for (const [key, value] of Object.entries(snapshot)) { + const isBotOverridable = ( + BOT_OVERRIDABLE_SETTINGS as readonly string[] + ).includes(key) + const globalValue = inheritableGlobalSettings[key] + const isModelField = key.startsWith('model_') + + if (isBotOverridable) { + // For model fields, check if the value matches the current provider format + if (isModelField && value) { + const modelStr = value as string + const isOpenRouter = currentProvider === 'openrouter' + const modelHasSlash = modelStr.includes('/') + // OpenRouter models have format "provider/model", others don't + const isValidForProvider = isOpenRouter + ? modelHasSlash + : !modelHasSlash + if (!isValidForProvider) { + // Clear invalid model values (from a different provider) + // Save empty string to explicitly clear any previous override + filteredSettings[key] = '' + continue + } + } + // Only include if different from global + if (value !== globalValue && value !== undefined && value !== '') { + filteredSettings[key] = value + } + } else { + // Non-overridable settings always save (bot_name, bot_personality, etc.) + filteredSettings[key] = value + } + } + + await onSave(filteredSettings) } catch (e) { error = e instanceof Error ? e.message : 'Failed to save settings' } finally { @@ -77,9 +143,9 @@ bind:this={dialogEl} onclick={handleDialogClick} onclose={handleDialogClose} - class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 m-0 p-0 border-none rounded-xl bg-(--main-bg) text-(--text-primary) max-w-[480px] w-[calc(100%-2rem)] max-h-[85vh] overflow-hidden shadow-[0_8px_32px_rgba(0,0,0,0.4)] backdrop:bg-black/60" + class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 m-0 p-0 border-none rounded-xl bg-(--main-bg) text-(--text-primary) max-w-[480px] w-[calc(100%-2rem)] h-[65vh] overflow-hidden shadow-[0_8px_32px_rgba(0,0,0,0.4)] backdrop:bg-black/60" > -
+
-
+
diff --git a/apps/ui/src/components/CommandAutocomplete.svelte b/apps/ui/src/components/CommandAutocomplete.svelte index ce97a41..fb43c63 100644 --- a/apps/ui/src/components/CommandAutocomplete.svelte +++ b/apps/ui/src/components/CommandAutocomplete.svelte @@ -30,7 +30,7 @@ > Commands matching "{filter || '/'}"
- {#each filteredCommands as cmd, i} + {#each filteredCommands as cmd, i (cmd.command)} + {/if} + {/if} +{/snippet} + {#snippet field(key: string, schema: SettingSchema)}
{#if schema.type === 'select' && key === 'ai_provider'} + {@const isDisabledByEnv = isEnvOverridden(key)} + + {#if isDisabledByEnv} +

+ Set via environment variable +

+ {/if} { - if (v) handleProviderChange(v) + if (v && !isDisabledByEnv) handleProviderChange(v) }} - class="w-full" + class="w-full {isDisabledByEnv ? 'opacity-60 pointer-events-none' : ''}" > - - {#each schema.options ?? [] as option} - {option.label} + + {#each schema.options ?? [] as option (option.value)} + {option.label} {/each} {:else if schema.type === 'secret'} + {@const validationStatus = getValidationStatus(key)} + {@const isApiKeyField = key.endsWith('_api_key')} + {@const isThisFieldEnvOverridden = isEnvOverridden(key)}
+ {#if validationStatus === 'invalid' && apiKeyValidation[key]?.error} +

{apiKeyValidation[key].error}

+ {/if} {:else if schema.type === 'string'} {schema.label} + {@render inheritedIndicator(key)}