diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 44fc7955..0e202352 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -190,28 +190,6 @@ ipcMain.handle('skills:add', async (_, source: string) => { --- -## 三、实现优先级 - -### Phase 1: 基础框架 (MVP) - -1. **Layout 组件** - Header + Tabs 导航 -2. **Home 页面** - 二维码显示 + 连接状态 -3. **Gateway 连接** - 复用 @multica/store - -### Phase 2: 管理功能 - -4. **Tools 页面** - 列表展示 + 开关切换 -5. **Skills 页面** - 列表展示 + 基础操作 -6. **Settings** - Gateway URL + Theme - -### Phase 3: 完善体验 - -7. **Agent 页面** - 状态监控 + Provider 切换 -8. **二维码刷新机制** -9. **错误处理 + Toast 提示** - ---- - ## 四、Hub 集成技术方案 ### 架构概述 @@ -496,9 +474,17 @@ ChatInput → useMessagesStore.sendMessage() ### 复用层级 -| 层级 | 组件/模块 | 复用情况 | -| ---------- | ---------------------------------------- | -------- | -| UI 层 | `MessageList`, `ChatInput` | ✅ 完全复用 | -| Store 层 | `useMessagesStore` | ✅ 完全复用 | -| Agent 层 | `AsyncAgent.write()`, `subscribe()` | ✅ 完全复用 | -| 传输层 | IPC vs WebSocket | ❌ 各自实现 | +| 层级 | 组件/模块 | 复用情况 | +| -------- | ----------------------------------- | ----------- | +| UI 层 | `MessageList`, `ChatInput` | ✅ 完全复用 | +| Store 层 | `useMessagesStore` | ✅ 完全复用 | +| Agent 层 | `AsyncAgent.write()`, `subscribe()` | ✅ 完全复用 | +| 传输层 | IPC vs WebSocket | ❌ 各自实现 | + +--- + +## 九、TODO + +- [ ] **优化 Memory Tool 逻辑**: 当前 memory tool 和 memory.md 没有统一,需要整合 +- [ ] **优化 Agent Profile 加载逻辑**: 改进 Profile 的加载机制 +- [ ] **Agent 自我迭代 Profile**: 添加让 Agent 在对话过程中自己修改 Profile 内文件的能力 diff --git a/apps/desktop/electron/electron-env.d.ts b/apps/desktop/electron/electron-env.d.ts index 880abc6a..7777fdf2 100644 --- a/apps/desktop/electron/electron-env.d.ts +++ b/apps/desktop/electron/electron-env.d.ts @@ -101,6 +101,27 @@ interface LocalChatEvent { } } +interface ProviderStatus { + id: string + name: string + authMethod: 'api-key' | 'oauth' + available: boolean + configured: boolean + current: boolean + defaultModel: string + models: string[] + loginUrl?: string + loginCommand?: string + loginInstructions?: string +} + +interface CurrentProviderInfo { + provider: string + model: string | undefined + providerName: string | undefined + available: boolean +} + interface ElectronAPI { hub: { init: () => Promise @@ -145,6 +166,16 @@ interface ElectronAPI { updateStyle: (style: string) => Promise updateUser: (content: string) => Promise } + provider: { + list: () => Promise + listAvailable: () => Promise + current: () => Promise + set: (providerId: string, modelId?: string) => Promise<{ ok: boolean; provider?: string; model?: string; error?: string }> + getMeta: (providerId: string) => Promise + isAvailable: (providerId: string) => Promise + saveApiKey: (providerId: string, apiKey: string) => Promise<{ ok: boolean; error?: string }> + importOAuth: (providerId: string) => Promise<{ ok: boolean; expiresAt?: number; error?: string }> + } localChat: { subscribe: (agentId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }> unsubscribe: (agentId: string) => Promise<{ ok: boolean }> diff --git a/apps/desktop/electron/ipc/index.ts b/apps/desktop/electron/ipc/index.ts index d0971eb0..fc11179c 100644 --- a/apps/desktop/electron/ipc/index.ts +++ b/apps/desktop/electron/ipc/index.ts @@ -5,11 +5,13 @@ export { registerAgentIpcHandlers, cleanupAgent } from './agent.js' export { registerSkillsIpcHandlers } from './skills.js' export { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation } from './hub.js' export { registerProfileIpcHandlers } from './profile.js' +export { registerProviderIpcHandlers } from './provider.js' import { registerAgentIpcHandlers, cleanupAgent } from './agent.js' import { registerSkillsIpcHandlers } from './skills.js' import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js' import { registerProfileIpcHandlers } from './profile.js' +import { registerProviderIpcHandlers } from './provider.js' /** * Register all IPC handlers. @@ -20,6 +22,7 @@ export function registerAllIpcHandlers(): void { registerAgentIpcHandlers() registerSkillsIpcHandlers() registerProfileIpcHandlers() + registerProviderIpcHandlers() } /** diff --git a/apps/desktop/electron/ipc/provider.ts b/apps/desktop/electron/ipc/provider.ts new file mode 100644 index 00000000..3f31dc9c --- /dev/null +++ b/apps/desktop/electron/ipc/provider.ts @@ -0,0 +1,312 @@ +/** + * Provider IPC handlers for Electron main process. + * + * Manages LLM provider listing, status checking, and switching. + * Mirrors the CLI `/provider` command functionality. + */ +import { ipcMain } from 'electron' +import { getCurrentHub } from './hub.js' +import { + getProviderList, + getAvailableProviders, + getCurrentProvider, + getProviderMeta, + isProviderAvailable, + getLoginInstructions, + type ProviderInfo, +} from '../../../../src/agent/providers/index.js' +import { + readClaudeCliCredentials, + readCodexCliCredentials, +} from '../../../../src/agent/providers/oauth/cli-credentials.js' +import { credentialManager } from '../../../../src/agent/credentials.js' + +/** + * Provider info returned to renderer (matches ProviderInfo from registry). + */ +export interface ProviderStatus { + id: string + name: string + authMethod: 'api-key' | 'oauth' + available: boolean + configured: boolean + current: boolean + defaultModel: string + models: string[] + loginUrl?: string + loginCommand?: string + loginInstructions?: string +} + +/** + * Current provider/model info returned to renderer. + */ +export interface CurrentProviderInfo { + provider: string + model: string | undefined + providerName: string | undefined + available: boolean +} + +/** + * Get the default agent from Hub. + */ +function getDefaultAgent() { + const hub = getCurrentHub() + if (!hub) return null + + const agentIds = hub.listAgents() + if (agentIds.length === 0) return null + + return hub.getAgent(agentIds[0]) ?? null +} + +/** + * Register all Provider-related IPC handlers. + */ +export function registerProviderIpcHandlers(): void { + /** + * List all providers with their status. + * This is the main listing function, similar to CLI `/provider` command. + */ + ipcMain.handle('provider:list', async (): Promise => { + const providers = getProviderList() + + return providers.map((p: ProviderInfo) => ({ + id: p.id, + name: p.name, + authMethod: p.authMethod, + available: p.available, + configured: p.configured, + current: p.current, + defaultModel: p.defaultModel, + models: p.models, + loginUrl: p.loginUrl, + loginCommand: p.loginCommand, + loginInstructions: getLoginInstructions(p.id), + })) + }) + + /** + * List only available (configured) providers. + */ + ipcMain.handle('provider:listAvailable', async (): Promise => { + const providers = getAvailableProviders() + + return providers.map((p: ProviderInfo) => ({ + id: p.id, + name: p.name, + authMethod: p.authMethod, + available: p.available, + configured: p.configured, + current: p.current, + defaultModel: p.defaultModel, + models: p.models, + loginUrl: p.loginUrl, + loginCommand: p.loginCommand, + loginInstructions: getLoginInstructions(p.id), + })) + }) + + /** + * Get current provider and model from the active agent. + */ + ipcMain.handle('provider:current', async (): Promise => { + const agent = getDefaultAgent() + + if (agent) { + // Get from actual agent instance + const info = agent.getProviderInfo() + const meta = getProviderMeta(info.provider) + + return { + provider: info.provider, + model: info.model, + providerName: meta?.name, + available: isProviderAvailable(info.provider), + } + } + + // Fallback to credentials default + const defaultProvider = getCurrentProvider() + const meta = getProviderMeta(defaultProvider) + + return { + provider: defaultProvider, + model: meta?.defaultModel, + providerName: meta?.name, + available: isProviderAvailable(defaultProvider), + } + }) + + /** + * Switch the agent to a different provider and/or model. + */ + ipcMain.handle( + 'provider:set', + async (_event, providerId: string, modelId?: string): Promise<{ ok: boolean; provider?: string; model?: string; error?: string }> => { + const agent = getDefaultAgent() + + if (!agent) { + return { ok: false, error: 'No agent available' } + } + + // Validate provider exists + const meta = getProviderMeta(providerId) + if (!meta) { + return { ok: false, error: `Unknown provider: ${providerId}` } + } + + // Check if provider is available + if (!isProviderAvailable(providerId)) { + const instructions = getLoginInstructions(providerId) + return { + ok: false, + error: `Provider "${providerId}" is not configured.\n${instructions}`, + } + } + + // Validate model if specified + if (modelId && !meta.models.includes(modelId)) { + return { + ok: false, + error: `Model "${modelId}" is not available for provider "${providerId}". Available: ${meta.models.join(', ')}`, + } + } + + try { + const result = agent.setProvider(providerId, modelId) + console.log(`[IPC] Provider switched to: ${result.provider}, model: ${result.model}`) + + return { + ok: true, + provider: result.provider, + model: result.model, + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`[IPC] Failed to switch provider: ${message}`) + return { ok: false, error: message } + } + } + ) + + /** + * Get metadata for a specific provider. + */ + ipcMain.handle('provider:getMeta', async (_event, providerId: string) => { + const meta = getProviderMeta(providerId) + if (!meta) { + return { error: `Unknown provider: ${providerId}` } + } + + return { + id: meta.id, + name: meta.name, + authMethod: meta.authMethod, + defaultModel: meta.defaultModel, + models: meta.models, + loginUrl: meta.loginUrl, + loginCommand: meta.loginCommand, + available: isProviderAvailable(providerId), + loginInstructions: getLoginInstructions(providerId), + } + }) + + /** + * Check if a specific provider is available (has valid credentials). + */ + ipcMain.handle('provider:isAvailable', async (_event, providerId: string): Promise => { + return isProviderAvailable(providerId) + }) + + /** + * Save API key for a provider to credentials.json5. + * After saving, the provider should become available. + */ + ipcMain.handle( + 'provider:saveApiKey', + async (_event, providerId: string, apiKey: string): Promise<{ ok: boolean; error?: string }> => { + try { + // Validate provider exists and uses API key auth + const meta = getProviderMeta(providerId) + if (!meta) { + return { ok: false, error: `Unknown provider: ${providerId}` } + } + if (meta.authMethod !== 'api-key') { + return { ok: false, error: `Provider "${providerId}" uses ${meta.authMethod} authentication, not API key` } + } + + // Save the API key + credentialManager.setLlmProviderApiKey(providerId, apiKey) + console.log(`[IPC] API key saved for provider: ${providerId}`) + + return { ok: true } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`[IPC] Failed to save API key: ${message}`) + return { ok: false, error: message } + } + } + ) + + /** + * Import OAuth credentials from CLI tools (claude-code, codex). + * Reads from CLI credential storage and saves to credentials.json5. + */ + ipcMain.handle( + 'provider:importOAuth', + async (_event, providerId: string): Promise<{ ok: boolean; expiresAt?: number; error?: string }> => { + try { + const meta = getProviderMeta(providerId) + if (!meta) { + return { ok: false, error: `Unknown provider: ${providerId}` } + } + if (meta.authMethod !== 'oauth') { + return { ok: false, error: `Provider "${providerId}" does not use OAuth authentication` } + } + + // Read credentials from CLI tool + if (providerId === 'claude-code') { + const creds = readClaudeCliCredentials() + if (!creds) { + return { ok: false, error: 'No Claude Code credentials found. Run "claude login" first.' } + } + if (creds.expires <= Date.now()) { + return { ok: false, error: 'Claude Code credentials have expired. Run "claude login" again.' } + } + + // Save to credentials.json5 + const token = creds.type === 'oauth' ? creds.access : creds.token + const refreshToken = creds.type === 'oauth' ? creds.refresh : undefined + credentialManager.setLlmProviderOAuthToken(providerId, token, refreshToken, creds.expires) + console.log(`[IPC] OAuth credentials imported for: ${providerId}`) + + return { ok: true, expiresAt: creds.expires } + } + + if (providerId === 'openai-codex') { + const creds = readCodexCliCredentials() + if (!creds) { + return { ok: false, error: 'No Codex credentials found. Run "codex login" first.' } + } + if (creds.expires <= Date.now()) { + return { ok: false, error: 'Codex credentials have expired. Run "codex login" again.' } + } + + // Save to credentials.json5 + credentialManager.setLlmProviderOAuthToken(providerId, creds.access, creds.refresh, creds.expires) + console.log(`[IPC] OAuth credentials imported for: ${providerId}`) + + return { ok: true, expiresAt: creds.expires } + } + + return { ok: false, error: `OAuth import not supported for provider: ${providerId}` } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.error(`[IPC] Failed to import OAuth credentials: ${message}`) + return { ok: false, error: message } + } + } + ) +} diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index f554ef4b..16093ac0 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -44,6 +44,27 @@ export interface ProfileData { userContent: string | undefined } +export interface ProviderStatus { + id: string + name: string + authMethod: 'api-key' | 'oauth' + available: boolean + configured: boolean + current: boolean + defaultModel: string + models: string[] + loginUrl?: string + loginCommand?: string + loginInstructions?: string +} + +export interface CurrentProviderInfo { + provider: string + model: string | undefined + providerName: string | undefined + available: boolean +} + // Local chat event types (for direct IPC communication without Gateway) export interface LocalChatEvent { agentId: string @@ -134,6 +155,29 @@ const electronAPI = { updateUser: (content: string) => ipcRenderer.invoke('profile:updateUser', content), }, + // Provider management + provider: { + /** List all providers with their status */ + list: (): Promise => ipcRenderer.invoke('provider:list'), + /** List only available (configured) providers */ + listAvailable: (): Promise => ipcRenderer.invoke('provider:listAvailable'), + /** Get current provider and model from the active agent */ + current: (): Promise => ipcRenderer.invoke('provider:current'), + /** Switch the agent to a different provider and/or model */ + set: (providerId: string, modelId?: string): Promise<{ ok: boolean; provider?: string; model?: string; error?: string }> => + ipcRenderer.invoke('provider:set', providerId, modelId), + /** Get metadata for a specific provider */ + getMeta: (providerId: string) => ipcRenderer.invoke('provider:getMeta', providerId), + /** Check if a specific provider is available */ + isAvailable: (providerId: string): Promise => ipcRenderer.invoke('provider:isAvailable', providerId), + /** Save API key for a provider */ + saveApiKey: (providerId: string, apiKey: string): Promise<{ ok: boolean; error?: string }> => + ipcRenderer.invoke('provider:saveApiKey', providerId, apiKey), + /** Import OAuth credentials from CLI tools (claude-code, codex) */ + importOAuth: (providerId: string): Promise<{ ok: boolean; expiresAt?: number; error?: string }> => + ipcRenderer.invoke('provider:importOAuth', providerId), + }, + // Local chat (direct IPC, no Gateway required) localChat: { /** Subscribe to agent events for local direct chat */ diff --git a/apps/desktop/src/components/api-key-dialog.tsx b/apps/desktop/src/components/api-key-dialog.tsx new file mode 100644 index 00000000..672cc9a8 --- /dev/null +++ b/apps/desktop/src/components/api-key-dialog.tsx @@ -0,0 +1,121 @@ +import { useState } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} from '@multica/ui/components/ui/dialog' +import { Button } from '@multica/ui/components/ui/button' +import { Input } from '@multica/ui/components/ui/input' +import { Label } from '@multica/ui/components/ui/label' +import { HugeiconsIcon } from '@hugeicons/react' +import { Loading03Icon, Key01Icon } from '@hugeicons/core-free-icons' + +interface ApiKeyDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + providerId: string + providerName: string + onSuccess?: () => void +} + +export function ApiKeyDialog({ + open, + onOpenChange, + providerId, + providerName, + onSuccess, +}: ApiKeyDialogProps) { + const [apiKey, setApiKey] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const handleSave = async () => { + if (!apiKey.trim()) { + setError('API key is required') + return + } + + setSaving(true) + setError(null) + + try { + const result = await window.electronAPI.provider.saveApiKey(providerId, apiKey.trim()) + if (result.ok) { + setApiKey('') + onOpenChange(false) + onSuccess?.() + } else { + setError(result.error ?? 'Failed to save API key') + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setError(message) + } finally { + setSaving(false) + } + } + + const handleClose = (isOpen: boolean) => { + if (!isOpen) { + setApiKey('') + setError(null) + } + onOpenChange(isOpen) + } + + return ( + + + + + + Configure {providerName} + + + Enter your API key to enable {providerName}. The key will be saved securely in your credentials file. + + + +
+
+ + setApiKey(e.target.value)} + placeholder="sk-..." + onKeyDown={(e) => { + if (e.key === 'Enter' && !saving) { + handleSave() + } + }} + /> + {error && ( +

{error}

+ )} +
+ +

+ Your API key is stored locally in ~/.super-multica/credentials.json5 +

+
+ + + + + +
+
+ ) +} + +export default ApiKeyDialog diff --git a/apps/desktop/src/components/oauth-dialog.tsx b/apps/desktop/src/components/oauth-dialog.tsx new file mode 100644 index 00000000..e6573cf0 --- /dev/null +++ b/apps/desktop/src/components/oauth-dialog.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} from '@multica/ui/components/ui/dialog' +import { Button } from '@multica/ui/components/ui/button' +import { HugeiconsIcon } from '@hugeicons/react' +import { Loading03Icon, CommandLineIcon, RefreshIcon, Tick02Icon } from '@hugeicons/core-free-icons' + +interface OAuthDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + providerId: string + providerName: string + loginCommand?: string + onSuccess?: () => void +} + +export function OAuthDialog({ + open, + onOpenChange, + providerId, + providerName, + loginCommand, + onSuccess, +}: OAuthDialogProps) { + const [importing, setImporting] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(false) + const [expiresAt, setExpiresAt] = useState(null) + + const handleImport = async () => { + setImporting(true) + setError(null) + setSuccess(false) + + try { + const result = await window.electronAPI.provider.importOAuth(providerId) + if (result.ok) { + setSuccess(true) + setExpiresAt(result.expiresAt ?? null) + // Auto-close after a short delay + setTimeout(() => { + onOpenChange(false) + onSuccess?.() + }, 1500) + } else { + setError(result.error ?? 'Failed to import credentials') + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setError(message) + } finally { + setImporting(false) + } + } + + const handleClose = (isOpen: boolean) => { + if (!isOpen) { + setError(null) + setSuccess(false) + setExpiresAt(null) + } + onOpenChange(isOpen) + } + + const formatExpiry = (timestamp: number) => { + const remaining = timestamp - Date.now() + if (remaining <= 0) return 'expired' + const hours = Math.floor(remaining / (60 * 60 * 1000)) + const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000)) + if (hours > 0) return `${hours}h ${minutes}m` + return `${minutes}m` + } + + return ( + + + + + + Configure {providerName} + + + {providerName} uses OAuth authentication. Please log in via the command line first. + + + +
+ {/* Login instructions */} +
+

+ 1. Open your terminal and run: +

+
+ {loginCommand ?? `${providerId} login`} +
+

+ 2. Complete the login process in your browser +

+

+ 3. Click "Refresh" below to import your credentials +

+
+ + {/* Status messages */} + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ + + Credentials imported successfully! + {expiresAt && ` (expires in ${formatExpiry(expiresAt)})`} + +
+ )} +
+ + + + + +
+
+ ) +} + +export default OAuthDialog diff --git a/apps/desktop/src/hooks/use-provider.ts b/apps/desktop/src/hooks/use-provider.ts new file mode 100644 index 00000000..99cae540 --- /dev/null +++ b/apps/desktop/src/hooks/use-provider.ts @@ -0,0 +1,101 @@ +/** + * Hook for managing LLM providers in the Desktop App. + * + * Provides functionality similar to CLI `/provider` command: + * - List all providers with status + * - Get current provider/model + * - Switch provider/model + */ +import { useState, useEffect, useCallback } from 'react' + +// Types are defined in electron-env.d.ts and available globally + +interface UseProviderReturn { + /** All providers with their status */ + providers: ProviderStatus[] + /** Only available (configured) providers */ + availableProviders: ProviderStatus[] + /** Current provider and model info */ + current: CurrentProviderInfo | null + /** Loading state */ + loading: boolean + /** Error message if any */ + error: string | null + /** Refresh provider list and current status */ + refresh: () => Promise + /** Switch to a different provider (and optionally model) */ + setProvider: (providerId: string, modelId?: string) => Promise<{ ok: boolean; error?: string }> + /** Get metadata for a specific provider */ + getProviderMeta: (providerId: string) => ProviderStatus | undefined +} + +export function useProvider(): UseProviderReturn { + const [providers, setProviders] = useState([]) + const [current, setCurrent] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const refresh = useCallback(async () => { + setLoading(true) + setError(null) + + try { + const [providerList, currentInfo] = await Promise.all([ + window.electronAPI.provider.list(), + window.electronAPI.provider.current(), + ]) + + setProviders(providerList) + setCurrent(currentInfo) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setError(message) + console.error('[useProvider] Failed to load providers:', message) + } finally { + setLoading(false) + } + }, []) + + // Load providers on mount + useEffect(() => { + refresh() + }, [refresh]) + + const setProvider = useCallback(async (providerId: string, modelId?: string) => { + setError(null) + + try { + const result = await window.electronAPI.provider.set(providerId, modelId) + + if (result.ok) { + // Refresh to update current status + await refresh() + return { ok: true } + } else { + setError(result.error ?? 'Unknown error') + return { ok: false, error: result.error } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setError(message) + return { ok: false, error: message } + } + }, [refresh]) + + const getProviderMeta = useCallback((providerId: string) => { + return providers.find((p) => p.id === providerId) + }, [providers]) + + const availableProviders = providers.filter((p) => p.available) + + return { + providers, + availableProviders, + current, + loading, + error, + refresh, + setProvider, + getProviderMeta, + } +} diff --git a/apps/desktop/src/pages/home.tsx b/apps/desktop/src/pages/home.tsx index ba2767f0..56396e53 100644 --- a/apps/desktop/src/pages/home.tsx +++ b/apps/desktop/src/pages/home.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { Button } from '@multica/ui/components/ui/button' import { HugeiconsIcon } from '@hugeicons/react' @@ -8,17 +8,52 @@ import { Loading03Icon, AlertCircleIcon, Edit02Icon, + ArrowDown01Icon, + Tick02Icon, + Alert02Icon, } from '@hugeicons/core-free-icons' import { ConnectionQRCode } from '../components/qr-code' import { DeviceList } from '../components/device-list' import { AgentSettingsDialog } from '../components/agent-settings-dialog' +import { ApiKeyDialog } from '../components/api-key-dialog' +import { OAuthDialog } from '../components/oauth-dialog' import { useHub } from '../hooks/use-hub' +import { useProvider } from '../hooks/use-provider' export default function HomePage() { const navigate = useNavigate() const { hubInfo, agents, loading, error } = useHub() + const { providers, current, setProvider, refresh, loading: providerLoading } = useProvider() const [settingsOpen, setSettingsOpen] = useState(false) const [agentName, setAgentName] = useState() + const [providerDropdownOpen, setProviderDropdownOpen] = useState(false) + const [switching, setSwitching] = useState(false) + const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false) + const [oauthDialogOpen, setOauthDialogOpen] = useState(false) + const [selectedProvider, setSelectedProvider] = useState<{ + id: string + name: string + authMethod: 'api-key' | 'oauth' + loginCommand?: string + } | null>(null) + const dropdownRef = useRef(null) + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setProviderDropdownOpen(false) + } + } + + if (providerDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [providerDropdownOpen]) // Load agent profile info useEffect(() => { @@ -151,6 +186,92 @@ export default function HomePage() {

{agentName || 'Unnamed Agent'}

+ {/* Provider Selector */} +
+

+ LLM Provider +

+ + + {/* Provider Dropdown - Compact Grid */} + {providerDropdownOpen && ( +
+
+ {providers.map((p) => ( + + ))} +
+
+ )} +
+ {/* Stats Grid */}
@@ -167,20 +288,6 @@ export default function HomePage() {

{connectionState}

-
-

- Active Agents -

-

{hubInfo?.agentCount ?? 0}

-
-
-

- Primary Agent -

-

- {primaryAgent?.id ?? 'None'} -

-
@@ -194,6 +301,43 @@ export default function HomePage() { {/* Agent Settings Dialog */} + {/* API Key Dialog */} + {selectedProvider && selectedProvider.authMethod === 'api-key' && ( + { + // Refresh provider list and switch to the newly configured provider + await refresh() + const result = await setProvider(selectedProvider.id) + if (!result.ok) { + console.error('Failed to switch provider:', result.error) + } + }} + /> + )} + + {/* OAuth Dialog */} + {selectedProvider && selectedProvider.authMethod === 'oauth' && ( + { + // Refresh provider list and switch to the newly configured provider + await refresh() + const result = await setProvider(selectedProvider.id) + if (!result.ok) { + console.error('Failed to switch provider:', result.error) + } + }} + /> + )} + {/* Bottom: Actions */}
diff --git a/src/agent/async-agent.ts b/src/agent/async-agent.ts index 68475555..4fdfb616 100644 --- a/src/agent/async-agent.ts +++ b/src/agent/async-agent.ts @@ -221,4 +221,19 @@ export class AsyncAgent { getMessages(): AgentMessage[] { return this.agent.getMessages(); } + + /** + * Get current provider and model information. + */ + getProviderInfo(): { provider: string; model: string | undefined } { + return this.agent.getProviderInfo(); + } + + /** + * Switch to a different provider and/or model. + * This updates the agent's model without recreating the session. + */ + setProvider(providerId: string, modelId?: string): { provider: string; model: string | undefined } { + return this.agent.setProvider(providerId, modelId); + } } diff --git a/src/agent/credentials.ts b/src/agent/credentials.ts index 5f4c7555..223798e6 100644 --- a/src/agent/credentials.ts +++ b/src/agent/credentials.ts @@ -1,11 +1,17 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; import { homedir } from "node:os"; import JSON5 from "json5"; import { DATA_DIR } from "../shared/paths.js"; type ProviderConfig = { + // API Key authentication apiKey?: string | undefined; + // OAuth authentication + oauthToken?: string | undefined; + oauthRefreshToken?: string | undefined; + oauthExpiresAt?: number | undefined; + // Common baseUrl?: string | undefined; model?: string | undefined; }; @@ -223,6 +229,132 @@ export class CredentialManager { this.skillsConfig = null; this.resolvedSkillsEnv = null; } + + /** + * Set the API key for a provider and save to credentials.json5. + * Creates the file if it doesn't exist. + */ + setLlmProviderApiKey(provider: string, apiKey: string): void { + const path = getCredentialsPath(); + + // Load existing config or create new one + let config: CredentialsConfig = { version: 1 }; + if (existsSync(path)) { + try { + const raw = readFileSync(path, "utf8"); + config = JSON5.parse(raw) as CredentialsConfig; + } catch { + // If parse fails, start fresh + config = { version: 1 }; + } + } + + // Ensure structure exists + if (!config.llm) { + config.llm = {}; + } + if (!config.llm.providers) { + config.llm.providers = {}; + } + + // Set or update the provider config + const existing = config.llm.providers[provider] ?? {}; + config.llm.providers[provider] = { + ...existing, + apiKey, + }; + + // Write back to file + mkdirSync(dirname(path), { recursive: true }); + const content = JSON.stringify(config, null, 2); + writeFileSync(path, content, "utf8"); + + // Reset cache so next read picks up the change + this.reset(); + } + + /** + * Set OAuth token for a provider and save to credentials.json5. + * Used for OAuth providers like claude-code and openai-codex. + */ + setLlmProviderOAuthToken( + provider: string, + token: string, + refreshToken?: string, + expiresAt?: number, + ): void { + const path = getCredentialsPath(); + + // Load existing config or create new one + let config: CredentialsConfig = { version: 1 }; + if (existsSync(path)) { + try { + const raw = readFileSync(path, "utf8"); + config = JSON5.parse(raw) as CredentialsConfig; + } catch { + config = { version: 1 }; + } + } + + // Ensure structure exists + if (!config.llm) { + config.llm = {}; + } + if (!config.llm.providers) { + config.llm.providers = {}; + } + + // Set or update the provider config + const existing = config.llm.providers[provider] ?? {}; + config.llm.providers[provider] = { + ...existing, + oauthToken: token, + oauthRefreshToken: refreshToken, + oauthExpiresAt: expiresAt, + }; + + // Write back to file + mkdirSync(dirname(path), { recursive: true }); + const content = JSON.stringify(config, null, 2); + writeFileSync(path, content, "utf8"); + + // Reset cache + this.reset(); + } + + /** + * Set the default LLM provider and save to credentials.json5. + */ + setDefaultLlmProvider(provider: string): void { + const path = getCredentialsPath(); + + // Load existing config or create new one + let config: CredentialsConfig = { version: 1 }; + if (existsSync(path)) { + try { + const raw = readFileSync(path, "utf8"); + config = JSON5.parse(raw) as CredentialsConfig; + } catch { + config = { version: 1 }; + } + } + + // Ensure structure exists + if (!config.llm) { + config.llm = {}; + } + + // Set default provider + config.llm.provider = provider; + + // Write back to file + mkdirSync(dirname(path), { recursive: true }); + const content = JSON.stringify(config, null, 2); + writeFileSync(path, content, "utf8"); + + // Reset cache + this.reset(); + } } export const credentialManager = new CredentialManager(); diff --git a/src/agent/runner.ts b/src/agent/runner.ts index f66ef159..98f233d9 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -9,6 +9,8 @@ import { resolveApiKeyForProvider, resolveBaseUrl, resolveModelId, + PROVIDER_ALIAS, + getDefaultModel, } from "./providers/index.js"; import { SessionManager } from "./session/session-manager.js"; import { ProfileManager } from "./profile/index.js"; @@ -82,7 +84,7 @@ export class Agent { private initialized = false; // Auth profile rotation state - private readonly resolvedProvider: string; + private resolvedProvider: string; private currentApiKey: string | undefined; private currentProfileId: string | undefined; private profileCandidates: string[]; @@ -598,6 +600,72 @@ export class Agent { this.profile?.updateStyle(style); } + /** + * Get current provider and model information. + */ + getProviderInfo(): { provider: string; model: string | undefined } { + return { + provider: this.resolvedProvider, + model: this.agent.state.model?.id, + }; + } + + /** + * Switch to a different provider and/or model. + * This updates the agent's model without recreating the session. + */ + setProvider(providerId: string, modelId?: string): { provider: string; model: string | undefined } { + // Resolve the actual provider (handle aliases like claude-code -> anthropic) + const actualProvider = PROVIDER_ALIAS[providerId] ?? providerId; + + // Resolve the model + const targetModel = modelId ?? getDefaultModel(providerId) ?? getDefaultModel(actualProvider); + const model = resolveModel({ provider: providerId, model: targetModel }); + + if (!model) { + throw new Error(`Failed to resolve model for provider: ${providerId}, model: ${targetModel}`); + } + + // Resolve API key for the new provider + // For OAuth providers (claude-code, openai-codex), we need to use the original providerId + // because OAuth credentials are resolved by the original provider name, not the alias + const resolved = resolveApiKeyForProvider(providerId); + if (resolved) { + this.currentApiKey = resolved.apiKey; + this.currentProfileId = resolved.profileId; + } else { + // Fallback: try with actual provider (for API key based providers) + this.currentApiKey = resolveApiKey(actualProvider); + this.currentProfileId = actualProvider; + } + + if (!this.currentApiKey) { + throw new Error(`No API key configured for provider: ${providerId}`); + } + + // Update the agent's model and API key + const baseUrl = resolveBaseUrl(actualProvider); + const modelWithBaseUrl = baseUrl ? { ...model, baseUrl } : model; + this.agent.setModel(modelWithBaseUrl); + + // Update internal state + this.resolvedProvider = providerId; + + // Update session metadata + this.session.saveMeta({ + provider: actualProvider, + model: model.id, + thinkingLevel: this.agent.state.thinkingLevel, + reasoningMode: this.reasoningMode, + contextWindowTokens: this.contextWindowGuard.tokens, + }); + + return { + provider: providerId, + model: model.id, + }; + } + /** * Build the full system prompt using the structured builder. * Combines profile content, tools, skills, and runtime info.