From 284861a0f5cd13140968cacbb014ba666b8b849c Mon Sep 17 00:00:00 2001 From: Haze <709547807@qq.com> Date: Fri, 6 Feb 2026 03:12:17 +0800 Subject: [PATCH] feat(chat): write API keys to OpenClaw and embed Control UI for chat Part 1: API Key Integration - Create electron/utils/openclaw-auth.ts to write keys to ~/.openclaw/agents/main/agent/auth-profiles.json - Update provider:save and provider:setApiKey IPC handlers to persist keys to OpenClaw auth-profiles alongside ClawX storage - Save API key to OpenClaw on successful validation in Setup wizard - Pass provider API keys as environment variables when starting the Gateway process (ANTHROPIC_API_KEY, OPENROUTER_API_KEY, etc.) Part 2: Embed OpenClaw Control UI for Chat - Replace custom Chat UI with embedding the Gateway's built-in Control UI at http://127.0.0.1:{port}/?token={token} - Add gateway:getControlUiUrl IPC handler to provide tokenized URL - Enable webviewTag in Electron BrowserWindow preferences - Override X-Frame-Options/CSP headers to allow webview embedding - Suppress noisy control-ui token_mismatch stderr messages - Add loading/error states for the embedded webview This fixes the "No API key found for provider" error and replaces the buggy custom chat implementation with OpenClaw's battle-tested Control UI. --- electron/gateway/manager.ts | 37 ++- electron/main/index.ts | 25 +- electron/main/ipc-handlers.ts | 34 +++ electron/preload/index.ts | 1 + electron/utils/openclaw-auth.ts | 163 ++++++++++ src/pages/Chat/index.tsx | 515 +++++++++----------------------- src/pages/Setup/index.tsx | 19 +- 7 files changed, 414 insertions(+), 380 deletions(-) create mode 100644 electron/utils/openclaw-auth.ts diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index e4a064106..953d4b726 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -15,6 +15,8 @@ import { isOpenClawInstalled } from '../utils/paths'; import { getSetting } from '../utils/store'; +import { getApiKey } from '../utils/secure-storage'; +import { getProviderEnvVar } from '../utils/openclaw-auth'; import { GatewayEventType, JsonRpcNotification, isNotification, isResponse } from './protocol'; /** @@ -370,6 +372,24 @@ export class GatewayManager extends EventEmitter { console.log(`Spawning Gateway: ${command} ${args.join(' ')}`); console.log(`Working directory: ${openclawDir}`); + // Load provider API keys from secure storage to pass as environment variables + const providerEnv: Record = {}; + const providerTypes = ['anthropic', 'openai', 'google', 'openrouter']; + for (const providerType of providerTypes) { + try { + const key = await getApiKey(providerType); + if (key) { + const envVar = getProviderEnvVar(providerType); + if (envVar) { + providerEnv[envVar] = key; + console.log(`Loaded API key for ${providerType} -> ${envVar}`); + } + } + } catch (err) { + console.warn(`Failed to load API key for ${providerType}:`, err); + } + } + return new Promise((resolve, reject) => { this.process = spawn(command, args, { cwd: openclawDir, @@ -378,6 +398,8 @@ export class GatewayManager extends EventEmitter { shell: process.platform === 'win32', // Use shell on Windows for pnpm env: { ...process.env, + // Provider API keys + ...providerEnv, // Skip channel auto-connect during startup for faster boot OPENCLAW_SKIP_CHANNELS: '1', CLAWDBOT_SKIP_CHANNELS: '1', @@ -407,9 +429,18 @@ export class GatewayManager extends EventEmitter { console.log('Gateway:', data.toString()); }); - // Log stderr + // Log stderr (filter out noisy control-ui token_mismatch messages) this.process.stderr?.on('data', (data) => { - console.error('Gateway error:', data.toString()); + const msg = data.toString(); + // Suppress the constant Control UI token_mismatch noise + // These come from the browser-based Control UI auto-polling with no token + if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) { + return; + } + if (msg.includes('closed before connect') && msg.includes('token mismatch')) { + return; + } + console.error('Gateway error:', msg); }); // Store PID @@ -516,7 +547,7 @@ export class GatewayManager extends EventEmitter { }, 10000); this.pendingRequests.set(connectId, { - resolve: (result) => { + resolve: (_result) => { clearTimeout(connectTimeout); handshakeComplete = true; console.log('WebSocket handshake complete, gateway connected'); diff --git a/electron/main/index.ts b/electron/main/index.ts index d80dd7534..6514038fc 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -2,7 +2,7 @@ * Electron Main Process Entry * Manages window creation, system tray, and IPC handlers */ -import { app, BrowserWindow, ipcMain, shell } from 'electron'; +import { app, BrowserWindow, ipcMain, session, shell } from 'electron'; import { join } from 'path'; import { GatewayManager } from '../gateway/manager'; import { registerIpcHandlers } from './ipc-handlers'; @@ -32,6 +32,7 @@ function createWindow(): BrowserWindow { nodeIntegration: false, contextIsolation: true, sandbox: false, + webviewTag: true, // Enable for embedding OpenClaw Control UI }, titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', trafficLightPosition: { x: 16, y: 16 }, @@ -74,6 +75,28 @@ async function initialize(): Promise { // Create system tray createTray(mainWindow); + // Override security headers for the OpenClaw Control UI webview + // The Control UI sets X-Frame-Options: DENY and CSP frame-ancestors 'none' + // which prevents embedding in an Electron webview + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + const headers = { ...details.responseHeaders }; + // Remove X-Frame-Options to allow embedding in webview + delete headers['X-Frame-Options']; + delete headers['x-frame-options']; + // Remove restrictive CSP frame-ancestors + if (headers['Content-Security-Policy']) { + headers['Content-Security-Policy'] = headers['Content-Security-Policy'].map( + (csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *") + ); + } + if (headers['content-security-policy']) { + headers['content-security-policy'] = headers['content-security-policy'].map( + (csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *") + ); + } + callback({ responseHeaders: headers }); + }); + // Register IPC handlers registerIpcHandlers(gatewayManager, mainWindow); diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 60273958b..e17640aa9 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -20,6 +20,8 @@ import { type ProviderConfig, } from '../utils/secure-storage'; import { getOpenClawStatus } from '../utils/paths'; +import { getSetting } from '../utils/store'; +import { saveProviderKeyToOpenClaw } from '../utils/openclaw-auth'; /** * Register all IPC handlers @@ -104,6 +106,20 @@ function registerGatewayHandlers( } }); + // Get the Control UI URL with token for embedding + ipcMain.handle('gateway:getControlUiUrl', async () => { + try { + const status = gatewayManager.getStatus(); + const token = await getSetting('gatewayToken'); + const port = status.port || 18789; + // Pass token as query param - Control UI will store it in localStorage + const url = `http://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`; + return { success: true, url, port, token }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + // Health check ipcMain.handle('gateway:health', async () => { try { @@ -203,6 +219,13 @@ function registerProviderHandlers(): void { // Store the API key if provided if (apiKey) { await storeApiKey(config.id, apiKey); + + // Also write to OpenClaw auth-profiles.json so the gateway can use it + try { + saveProviderKeyToOpenClaw(config.type, apiKey); + } catch (err) { + console.warn('Failed to save key to OpenClaw auth-profiles:', err); + } } return { success: true }; @@ -225,6 +248,17 @@ function registerProviderHandlers(): void { ipcMain.handle('provider:setApiKey', async (_, providerId: string, apiKey: string) => { try { await storeApiKey(providerId, apiKey); + + // Also write to OpenClaw auth-profiles.json + // Resolve provider type from stored config, or use providerId as type + const provider = await getProvider(providerId); + const providerType = provider?.type || providerId; + try { + saveProviderKeyToOpenClaw(providerType, apiKey); + } catch (err) { + console.warn('Failed to save key to OpenClaw auth-profiles:', err); + } + return { success: true }; } catch (error) { return { success: false, error: String(error) }; diff --git a/electron/preload/index.ts b/electron/preload/index.ts index b5832147c..4d1d92a43 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -22,6 +22,7 @@ const electronAPI = { 'gateway:restart', 'gateway:rpc', 'gateway:health', + 'gateway:getControlUiUrl', // OpenClaw 'openclaw:status', 'openclaw:isReady', diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts new file mode 100644 index 000000000..f05fdafdf --- /dev/null +++ b/electron/utils/openclaw-auth.ts @@ -0,0 +1,163 @@ +/** + * OpenClaw Auth Profiles Utility + * Writes API keys to ~/.openclaw/agents/main/agent/auth-profiles.json + * so the OpenClaw Gateway can load them for AI provider calls. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +const AUTH_STORE_VERSION = 1; +const AUTH_PROFILE_FILENAME = 'auth-profiles.json'; + +/** + * Auth profile entry for an API key + */ +interface AuthProfileEntry { + type: 'api_key'; + provider: string; + key: string; +} + +/** + * Auth profiles store format + */ +interface AuthProfilesStore { + version: number; + profiles: Record; + order?: Record; + lastGood?: Record; +} + +/** + * Provider type to environment variable name mapping + */ +const PROVIDER_ENV_VARS: Record = { + anthropic: 'ANTHROPIC_API_KEY', + openai: 'OPENAI_API_KEY', + google: 'GEMINI_API_KEY', + openrouter: 'OPENROUTER_API_KEY', + groq: 'GROQ_API_KEY', + deepgram: 'DEEPGRAM_API_KEY', + cerebras: 'CEREBRAS_API_KEY', + xai: 'XAI_API_KEY', + mistral: 'MISTRAL_API_KEY', +}; + +/** + * Get the path to the auth-profiles.json for a given agent + */ +function getAuthProfilesPath(agentId = 'main'): string { + return join(homedir(), '.openclaw', 'agents', agentId, 'agent', AUTH_PROFILE_FILENAME); +} + +/** + * Read existing auth profiles store, or create an empty one + */ +function readAuthProfiles(agentId = 'main'): AuthProfilesStore { + const filePath = getAuthProfilesPath(agentId); + + try { + if (existsSync(filePath)) { + const raw = readFileSync(filePath, 'utf-8'); + const data = JSON.parse(raw) as AuthProfilesStore; + // Validate basic structure + if (data.version && data.profiles && typeof data.profiles === 'object') { + return data; + } + } + } catch (error) { + console.warn('Failed to read auth-profiles.json, creating fresh store:', error); + } + + return { + version: AUTH_STORE_VERSION, + profiles: {}, + }; +} + +/** + * Write auth profiles store to disk + */ +function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): void { + const filePath = getAuthProfilesPath(agentId); + const dir = join(filePath, '..'); + + // Ensure directory exists + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeFileSync(filePath, JSON.stringify(store, null, 2), 'utf-8'); +} + +/** + * Save a provider API key to OpenClaw's auth-profiles.json + * This writes the key in the format OpenClaw expects so the gateway + * can use it for AI provider calls. + * + * @param provider - Provider type (e.g., 'anthropic', 'openrouter', 'openai', 'google') + * @param apiKey - The API key to store + * @param agentId - Agent ID (defaults to 'main') + */ +export function saveProviderKeyToOpenClaw( + provider: string, + apiKey: string, + agentId = 'main' +): void { + const store = readAuthProfiles(agentId); + + // Profile ID follows OpenClaw convention: :default + const profileId = `${provider}:default`; + + // Upsert the profile entry + store.profiles[profileId] = { + type: 'api_key', + provider, + key: apiKey, + }; + + // Update order to include this profile + if (!store.order) { + store.order = {}; + } + if (!store.order[provider]) { + store.order[provider] = []; + } + if (!store.order[provider].includes(profileId)) { + store.order[provider].push(profileId); + } + + // Set as last good + if (!store.lastGood) { + store.lastGood = {}; + } + store.lastGood[provider] = profileId; + + writeAuthProfiles(store, agentId); + console.log(`Saved API key for provider "${provider}" to OpenClaw auth-profiles (agent: ${agentId})`); +} + +/** + * Get the environment variable name for a provider type + */ +export function getProviderEnvVar(provider: string): string | undefined { + return PROVIDER_ENV_VARS[provider]; +} + +/** + * Build environment variables object with all stored API keys + * for passing to the Gateway process + */ +export function buildProviderEnvVars(providers: Array<{ type: string; apiKey: string }>): Record { + const env: Record = {}; + + for (const { type, apiKey } of providers) { + const envVar = PROVIDER_ENV_VARS[type]; + if (envVar && apiKey) { + env[envVar] = apiKey; + } + } + + return env; +} diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index 384b82259..eac8cb0d1 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -1,399 +1,164 @@ /** * Chat Page - * Conversation interface with AI + * Embeds OpenClaw's Control UI for chat functionality. + * The Control UI handles all chat protocol details (sessions, streaming, etc.) + * and is served by the Gateway at http://127.0.0.1:{port}/ */ import { useState, useEffect, useRef, useCallback } from 'react'; -import { - Send, - Trash2, - Bot, - User, - Copy, - Check, - RefreshCw, - AlertCircle, - Sparkles, - MessageSquare, -} from 'lucide-react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; +import { AlertCircle, RefreshCw, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Textarea } from '@/components/ui/textarea'; -import { Card, CardContent } from '@/components/ui/card'; -import { useChatStore } from '@/stores/chat'; import { useGatewayStore } from '@/stores/gateway'; -import { LoadingSpinner } from '@/components/common/LoadingSpinner'; -import { cn, formatRelativeTime } from '@/lib/utils'; -import { toast } from 'sonner'; -// Message component with markdown support -interface ChatMessageProps { - message: { - id: string; - role: 'user' | 'assistant' | 'system'; - content: string; - timestamp: string; - toolCalls?: Array<{ - id: string; - name: string; - arguments: Record; - result?: unknown; - status: 'pending' | 'running' | 'completed' | 'error'; - }>; - }; -} - -function ChatMessage({ message }: ChatMessageProps) { - const [copied, setCopied] = useState(false); - const isUser = message.role === 'user'; - - const copyContent = useCallback(() => { - navigator.clipboard.writeText(message.content); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }, [message.content]); - - return ( -
- {/* Avatar */} -
- {isUser ? ( - - ) : ( - - )} -
- - {/* Message Content */} -
- {isUser ? ( -

{message.content}

- ) : ( -
- - {children} - - ); - } - - return ( -
- {match && ( -
- - {match[1]} - - -
- )} -
-                        
-                          {children}
-                        
-                      
-
- ); - }, - // Custom link styling - a({ href, children }) { - return ( - - {children} - - ); - }, - }} - > - {message.content} -
-
- )} - - {/* Timestamp and actions */} -
-

- {formatRelativeTime(message.timestamp)} -

- - {!isUser && ( - - )} -
- - {/* Tool Calls */} - {message.toolCalls && message.toolCalls.length > 0 && ( -
-

Tools Used:

- {message.toolCalls.map((tool) => ( - - -
-

{tool.name}

- - {tool.status} - -
-
-
- ))} -
- )} -
-
- ); -} - -// Typing indicator component -function TypingIndicator() { - return ( -
-
- -
-
-
- - - -
-
-
- ); -} - -// Welcome screen component -function WelcomeScreen() { - return ( -
-
- -
-

Welcome to ClawX Chat

-

- I'm your AI assistant, ready to help with tasks, answer questions, and more. - Start by typing a message below. -

- -
- {[ - { icon: MessageSquare, title: 'Ask Questions', desc: 'Get answers on any topic' }, - { icon: Sparkles, title: 'Creative Tasks', desc: 'Writing, brainstorming, ideas' }, - ].map((item, i) => ( - - - -

{item.title}

-

{item.desc}

-
-
- ))} -
-
- ); -} +// Custom CSS to inject into the Control UI to match ClawX theme +const CUSTOM_CSS = ` + /* Hide the Control UI header/nav that we don't need */ + .gateway-header, [data-testid="gateway-header"] { + display: none !important; + } + /* Remove top padding that the header would occupy */ + body, #root { + background: transparent !important; + } + /* Ensure the chat area fills the frame */ + .chat-container, [data-testid="chat-container"] { + height: 100vh !important; + max-height: 100vh !important; + } +`; export function Chat() { - const { messages, loading, sending, error, fetchHistory, sendMessage, clearHistory } = useChatStore(); const gatewayStatus = useGatewayStore((state) => state.status); - const [input, setInput] = useState(''); - const messagesEndRef = useRef(null); - const textareaRef = useRef(null); + const [controlUiUrl, setControlUiUrl] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const webviewRef = useRef(null); const isGatewayRunning = gatewayStatus.state === 'running'; - // Fetch history on mount + // Fetch Control UI URL when gateway is running useEffect(() => { - if (isGatewayRunning) { - fetchHistory(); + if (!isGatewayRunning) { + setControlUiUrl(null); + setLoading(false); + return; } - }, [fetchHistory, isGatewayRunning]); - - // Auto-scroll to bottom on new messages - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages, sending]); - - // Auto-resize textarea - useEffect(() => { - if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; - textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`; - } - }, [input]); - - // Handle send message - const handleSend = useCallback(async () => { - if (!input.trim() || sending || !isGatewayRunning) return; - const content = input.trim(); - setInput(''); - await sendMessage(content); - }, [input, sending, isGatewayRunning, sendMessage]); + setLoading(true); + setError(null); + + window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') + .then((result: unknown) => { + const r = result as { success: boolean; url?: string; error?: string }; + if (r.success && r.url) { + setControlUiUrl(r.url); + } else { + setError(r.error || 'Failed to get Control UI URL'); + } + }) + .catch((err: unknown) => { + setError(String(err)); + }) + .finally(() => { + setLoading(false); + }); + }, [isGatewayRunning]); - // Handle key press - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(); + // Inject custom CSS when webview loads + const handleWebviewReady = useCallback(() => { + const webview = webviewRef.current as unknown as HTMLElement & { + addEventListener: (event: string, cb: (e: unknown) => void) => void; + insertCSS: (css: string) => Promise; + reload: () => void; + }; + if (!webview) return; + + webview.addEventListener('dom-ready', () => { + // Inject custom CSS to match ClawX theme + webview.insertCSS(CUSTOM_CSS).catch((err: unknown) => { + console.warn('Failed to inject CSS:', err); + }); + setLoading(false); + }); + + webview.addEventListener('did-fail-load', (event: unknown) => { + const e = event as { errorCode: number; errorDescription: string }; + if (e.errorCode !== -3) { // -3 is ERR_ABORTED, ignore it + setError(`Failed to load: ${e.errorDescription}`); + setLoading(false); + } + }); + }, []); + + // Set up webview event listeners + useEffect(() => { + if (controlUiUrl && webviewRef.current) { + handleWebviewReady(); } - }, [handleSend]); + }, [controlUiUrl, handleWebviewReady]); - return ( -
- {/* Gateway Warning */} - {!isGatewayRunning && ( -
- - - Gateway is not running. Chat functionality is unavailable. - -
- )} - - {/* Messages Area */} -
- {loading ? ( -
- -
- ) : messages.length === 0 ? ( - - ) : ( - <> - {messages.map((message) => ( - - ))} - {sending && } -
- - )} -
- - {/* Error Display */} - {error && ( -
-

- - {error} -

-
- )} - - {/* Input Area */} -
-
- - -
-