diff --git a/package.json b/package.json index 974af11..15ca3c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openbrowserclaw", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "description": "Browser-native personal AI assistant. Zero infrastructure — the browser is the server.", @@ -23,6 +23,7 @@ "vite-plugin-pwa": "^0.21.0" }, "dependencies": { + "@huggingface/transformers": "next", "lucide-react": "^0.575.0", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/src/components/chat/ChatPage.tsx b/src/components/chat/ChatPage.tsx index dac032f..7c5ca8d 100644 --- a/src/components/chat/ChatPage.tsx +++ b/src/components/chat/ChatPage.tsx @@ -3,7 +3,7 @@ // --------------------------------------------------------------------------- import { useEffect, useRef } from 'react'; -import { X, MessageSquare, Globe, FileText, MapPin } from 'lucide-react'; +import { X, MessageSquare, Globe, FileText, MessageCircle, HelpCircle } from 'lucide-react'; import { useOrchestratorStore } from '../../stores/orchestrator-store.js'; import { MessageList } from './MessageList.js'; import { ChatInput } from './ChatInput.js'; @@ -29,7 +29,8 @@ const LineGraphIcon = ({ className }: { className?: string }) => ( ); -const PROMPT_STARTERS = [ +// Prompt starters for Claude (with tool access) +const CLAUDE_PROMPT_STARTERS = [ { icon: Globe, title: 'Latest news', @@ -41,48 +42,82 @@ const PROMPT_STARTERS = [ prompt: 'Show me a graph with the Ethereum price over the last 6 months.', }, { - icon: MapPin, - title: 'Map viewer', - prompt: 'Generate an interactive map viewer with the top locations to visit in Seattle.', + icon: FileText, + title: 'Create a file', + prompt: 'Create a simple HTML file with a hello world page.', + }, +]; + +// Prompt starters for local model (chat only, no tools) +const LOCAL_PROMPT_STARTERS = [ + { + icon: MessageCircle, + title: 'Have a conversation', + prompt: 'Tell me about yourself and what you can help with.', + }, + { + icon: HelpCircle, + title: 'Ask a question', + prompt: 'What are the main differences between JavaScript and TypeScript?', + }, + { + icon: FileText, + title: 'Get help with code', + prompt: 'Explain how async/await works in JavaScript with a simple example.', }, ]; export function ChatPage() { const messages = useOrchestratorStore((s) => s.messages); const isTyping = useOrchestratorStore((s) => s.isTyping); + const isStreaming = useOrchestratorStore((s) => s.isStreaming); const toolActivity = useOrchestratorStore((s) => s.toolActivity); const activityLog = useOrchestratorStore((s) => s.activityLog); const orchState = useOrchestratorStore((s) => s.state); const tokenUsage = useOrchestratorStore((s) => s.tokenUsage); const error = useOrchestratorStore((s) => s.error); + const providerType = useOrchestratorStore((s) => s.providerType); const sendMessage = useOrchestratorStore((s) => s.sendMessage); const loadHistory = useOrchestratorStore((s) => s.loadHistory); const bottomRef = useRef(null); - // Scroll to bottom on new messages + // Choose prompt starters based on provider type + const promptStarters = providerType === 'onnx' ? LOCAL_PROMPT_STARTERS : CLAUDE_PROMPT_STARTERS; + const starterTitle = providerType === 'onnx' + ? 'Chat with the local AI' + : 'Start a conversation'; + const starterDesc = providerType === 'onnx' + ? 'This is a lightweight local model. It can chat and answer questions, but cannot execute commands or access files.' + : 'Try one of these to get started'; + + // Scroll to bottom on new messages or streaming content useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages, isTyping]); + }, [messages, isStreaming]); // Load history on mount useEffect(() => { loadHistory(); }, [loadHistory]); + // Determine if we should show typing indicator + // Show for: Claude thinking, or local model loading (before streaming starts) + const showTyping = isTyping && !isStreaming; + return (
{/* Messages area */}
- {messages.length === 0 && !isTyping && ( + {messages.length === 0 && !isTyping && !isStreaming && (
-

Start a conversation

-

Try one of these to get started

+

{starterTitle}

+

{starterDesc}

- {PROMPT_STARTERS.map(({ icon: Icon, title, prompt }) => ( + {promptStarters.map(({ icon: Icon, title, prompt }) => (
+ {providerType === 'onnx' && ( +

+ 💡 Switch to Claude mode in Settings for file operations, web access, and command execution. +

+ )}
@@ -105,7 +145,9 @@ export function ChatPage() { - {isTyping && } + {/* Show typing indicator during thinking/loading phase */} + {showTyping && } + {toolActivity && ( )} @@ -147,4 +189,4 @@ export function ChatPage() {
); -} +} \ No newline at end of file diff --git a/src/components/chat/MessageList.tsx b/src/components/chat/MessageList.tsx index e4cd1ee..7dbd37b 100644 --- a/src/components/chat/MessageList.tsx +++ b/src/components/chat/MessageList.tsx @@ -1,20 +1,35 @@ // --------------------------------------------------------------------------- -// OpenBrowserClaw — Message list +// OpenBrowserClaw — Message list with streaming support // --------------------------------------------------------------------------- import type { StoredMessage } from '../../types.js'; import { MessageBubble } from './MessageBubble.js'; +import { StreamingMessage } from './StreamingMessage.js'; +import { useOrchestratorStore } from '../../stores/orchestrator-store.js'; interface Props { messages: StoredMessage[]; } export function MessageList({ messages }: Props) { + const streamingContent = useOrchestratorStore((s) => s.streamingContent); + const isStreaming = useOrchestratorStore((s) => s.isStreaming); + const orchState = useOrchestratorStore((s) => s.state); + const providerType = useOrchestratorStore((s) => s.providerType); + + // Filter out any streaming placeholder messages from the array + const realMessages = messages.filter(m => !String(m.id).startsWith('streaming-')); + + // Show streaming only for local model when in responding state + const showStreaming = isStreaming && providerType === 'onnx' && orchState === 'responding'; + return ( <> - {messages.map((msg) => ( + {realMessages.map((msg) => ( ))} + {/* Show streaming message for local model */} + {showStreaming && } ); -} +} \ No newline at end of file diff --git a/src/components/chat/StreamingMessage.tsx b/src/components/chat/StreamingMessage.tsx new file mode 100644 index 0000000..2a171e2 --- /dev/null +++ b/src/components/chat/StreamingMessage.tsx @@ -0,0 +1,224 @@ +// --------------------------------------------------------------------------- +// OpenBrowserClaw — Streaming message with typewriter effect and stop button +// --------------------------------------------------------------------------- + +import { useEffect, useRef, useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; +import { Square } from 'lucide-react'; +import { getOrchestrator } from '../../stores/orchestrator-store.js'; +import { useOrchestratorStore } from '../../stores/orchestrator-store.js'; +import { CodeBlock } from './CodeBlock.js'; + +// Allow SVG elements and common attributes through sanitization +const sanitizeSchema = { + ...defaultSchema, + tagNames: [ + ...(defaultSchema.tagNames ?? []), + 'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', + 'ellipse', 'g', 'defs', 'use', 'text', 'tspan', + 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'mask', + ], + attributes: { + ...defaultSchema.attributes, + svg: ['xmlns', 'viewBox', 'width', 'height', 'fill', 'stroke', 'class', 'style', 'role', 'aria-*'], + path: ['d', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'opacity', 'transform', 'class'], + circle: ['cx', 'cy', 'r', 'fill', 'stroke', 'stroke-width', 'class'], + rect: ['x', 'y', 'width', 'height', 'rx', 'ry', 'fill', 'stroke', 'stroke-width', 'class'], + line: ['x1', 'y1', 'x2', 'y2', 'stroke', 'stroke-width', 'class'], + polyline: ['points', 'fill', 'stroke', 'stroke-width', 'class'], + polygon: ['points', 'fill', 'stroke', 'stroke-width', 'class'], + ellipse: ['cx', 'cy', 'rx', 'ry', 'fill', 'stroke', 'class'], + g: ['transform', 'fill', 'stroke', 'class', 'opacity'], + text: ['x', 'y', 'dx', 'dy', 'text-anchor', 'font-size', 'font-family', 'fill', 'class', 'transform'], + tspan: ['x', 'y', 'dx', 'dy', 'fill', 'class'], + linearGradient: ['id', 'x1', 'y1', 'x2', 'y2', 'gradientUnits', 'gradientTransform'], + radialGradient: ['id', 'cx', 'cy', 'r', 'fx', 'fy', 'gradientUnits'], + stop: ['offset', 'stop-color', 'stop-opacity'], + clipPath: ['id'], + mask: ['id'], + defs: [], + use: ['href', 'x', 'y', 'width', 'height'], + }, +}; + +interface StreamingMessageProps { + content: string; +} + +export function StreamingMessage({ content }: StreamingMessageProps) { + const [displayedContent, setDisplayedContent] = useState(''); + const contentRef = useRef(content); + const animationRef = useRef(null); + const isCompleteRef = useRef(false); + const abortStreaming = useOrchestratorStore((s) => s.abortStreaming); + + // Smooth typewriter effect + useEffect(() => { + contentRef.current = content; + + if (isCompleteRef.current) { + setDisplayedContent(content); + return; + } + + let currentIndex = displayedContent.length; + + // Cancel previous animation + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + + // Animate to catch up with received content + const animate = () => { + const targetIndex = contentRef.current.length; + + if (currentIndex < targetIndex) { + // Add multiple characters per frame for smoothness + const remaining = targetIndex - currentIndex; + const charsToAdd = Math.max(1, Math.min(remaining, Math.ceil(remaining / 10))); + currentIndex += charsToAdd; + setDisplayedContent(contentRef.current.slice(0, currentIndex)); + animationRef.current = requestAnimationFrame(animate); + } else { + setDisplayedContent(contentRef.current); + } + }; + + animationRef.current = requestAnimationFrame(animate); + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + }; + }, [content, displayedContent.length]); + + // Mark complete when streaming ends (detected by content not changing) + useEffect(() => { + const timeout = setTimeout(() => { + if (content === contentRef.current && content.length > 0) { + isCompleteRef.current = true; + setDisplayedContent(content); + } + }, 500); + + return () => clearTimeout(timeout); + }, [content]); + + const assistantName = getOrchestrator().getAssistantName(); + + function formatTime(ts: number): string { + return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + + const handleStop = () => { + abortStreaming(); + }; + + return ( +
+
+ {assistantName} + + ● writing + {/* Stop button */} + +
+
+
+ ; + } + return ( + + {children} + + ); + }, + pre({ children }) { + return <>{children}; + }, + blockquote({ children }) { + return ( +
+ {children} +
+ ); + }, + table({ children }) { + return ( +
+ {children}
+
+ ); + }, + p({ children }) { + return

{children}

; + }, + ul({ children }) { + return
    {children}
; + }, + ol({ children }) { + return
    {children}
; + }, + li({ children }) { + return
  • {children}
  • ; + }, + a({ href, children }) { + return ( + + {children} + + ); + }, + h1({ children }) { + return

    {children}

    ; + }, + h2({ children }) { + return

    {children}

    ; + }, + h3({ children }) { + return

    {children}

    ; + }, + hr() { + return
    ; + }, + img({ src, alt }) { + return ( + {alt + ); + }, + }} + > + {displayedContent} +
    + {/* Cursor indicator at end */} + {!isCompleteRef.current && ( + + )} +
    +
    +
    + ); +} \ No newline at end of file diff --git a/src/components/chat/TypingIndicator.tsx b/src/components/chat/TypingIndicator.tsx index 3f052eb..98154e9 100644 --- a/src/components/chat/TypingIndicator.tsx +++ b/src/components/chat/TypingIndicator.tsx @@ -2,7 +2,15 @@ // OpenBrowserClaw — Typing indicator // --------------------------------------------------------------------------- +import { useOrchestratorStore } from '../../stores/orchestrator-store.js'; + export function TypingIndicator() { + const providerType = useOrchestratorStore((s) => s.providerType); + const providerLoading = useOrchestratorStore((s) => s.providerLoading); + + // For local model, show "Loading model" during initialization + const isLocalLoading = providerType === 'onnx' && providerLoading; + return (
    @@ -11,8 +19,10 @@ export function TypingIndicator() {
    - Thinking... + + {isLocalLoading ? 'Loading model...' : 'Thinking...'} +
    ); -} +} \ No newline at end of file diff --git a/src/components/settings/ProviderSettings.tsx b/src/components/settings/ProviderSettings.tsx new file mode 100644 index 0000000..d11acb8 --- /dev/null +++ b/src/components/settings/ProviderSettings.tsx @@ -0,0 +1,234 @@ +// --------------------------------------------------------------------------- +// OpenBrowserClaw — Provider Settings Component +// --------------------------------------------------------------------------- + +import { useEffect, useState } from 'react'; +import { Cloud, Cpu, Eye, EyeOff, Check, AlertCircle, Loader2 } from 'lucide-react'; +import type { ProviderType, ProviderInfo } from '../../providers'; +import { getAvailableProviders } from '../../providers'; +import { getConfig, setConfig } from '../../db'; +import { CONFIG_KEYS } from '../../config'; +import { decryptValue } from '../../crypto'; + +interface ProviderSettingsProps { + /** Current provider type */ + providerType: ProviderType; + /** Callback when provider type changes */ + onProviderChange: (type: ProviderType) => void; + /** Callback when API key changes */ + onApiKeyChange?: (key: string) => void; +} + +export function ProviderSettings({ + providerType, + onProviderChange, + onApiKeyChange, +}: ProviderSettingsProps) { + const [providers, setProviders] = useState([]); + const [apiKey, setApiKey] = useState(''); + const [apiKeyMasked, setApiKeyMasked] = useState(true); + const [apiKeySaved, setApiKeySaved] = useState(false); + const [loading, setLoading] = useState(true); + const [webGPUStatus, setWebGPUStatus] = useState<{ available: boolean; reason?: string } | null>(null); + + // Load providers and API key + useEffect(() => { + async function load() { + try { + // Get available providers + const availableProviders = await getAvailableProviders(); + setProviders(availableProviders); + + // Check WebGPU status from ONNX provider + const onnxInfo = availableProviders.find(p => p.name.includes('ONNX')); + if (onnxInfo?.webGPUStatus) { + setWebGPUStatus(onnxInfo.webGPUStatus); + } + + // Load API key + const encKey = await getConfig(CONFIG_KEYS.ANTHROPIC_API_KEY); + if (encKey) { + try { + const dec = await decryptValue(encKey); + setApiKey(dec); + } catch { + setApiKey(''); + } + } + } catch (e) { + console.error('Failed to load provider settings:', e); + } finally { + setLoading(false); + } + } + load(); + }, []); + + async function handleSaveApiKey() { + await setConfig(CONFIG_KEYS.ANTHROPIC_API_KEY, apiKey.trim()); + onApiKeyChange?.(apiKey.trim()); + setApiKeySaved(true); + setTimeout(() => setApiKeySaved(false), 2000); + } + + function handleProviderChange(type: ProviderType) { + onProviderChange(type); + } + + if (loading) { + return ( +
    + +
    + ); + } + + return ( +
    + {/* ---- Provider Selection ---- */} +
    +
    +

    + LLM Provider +

    + +
    + {/* Claude (Cloud) Option */} + + + {/* ONNX (Local) Option */} + +
    +
    +
    + + {/* ---- API Key (only for Claude) ---- */} + {providerType === 'claude' && ( +
    +
    +

    + Anthropic API Key +

    +
    + setApiKey(e.target.value)} + /> + +
    +
    + + {apiKeySaved && ( + + Saved + + )} +
    +

    + Your API key is encrypted and stored locally. It never leaves your browser. +

    +
    +
    + )} + + {/* ---- Local Model Info (for ONNX) ---- */} + {providerType === 'onnx' && ( +
    +
    +

    + Local Model +

    +
    +

    Model: Qwen3.5-0.8B-ONNX

    +

    Size: ~500MB (downloaded once)

    +

    Context: 8192 tokens

    +
    + {webGPUStatus?.available && ( +
    + WebGPU available +
    + )} +

    + The model will be downloaded on first use and cached in your browser. + All processing happens locally on your device. +

    +
    +
    + )} +
    + ); +} + +export default ProviderSettings; diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index ee5bce0..c6cd52e 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -5,16 +5,18 @@ import { useEffect, useState } from 'react'; import { Palette, KeyRound, Eye, EyeOff, Bot, MessageSquare, - Smartphone, HardDrive, Lock, Check, + Smartphone, HardDrive, Lock, Check, AlertCircle, Loader2, Cpu, Cloud, + Download, Trash2, Info, } from 'lucide-react'; import { getConfig, setConfig } from '../../db.js'; -import { CONFIG_KEYS } from '../../config.js'; +import { CONFIG_KEYS, LOCAL_MODELS, type LocalModelId, DEFAULT_LOCAL_MODEL_ID } from '../../config.js'; import { getStorageEstimate, requestPersistentStorage } from '../../storage.js'; import { decryptValue } from '../../crypto.js'; import { getOrchestrator } from '../../stores/orchestrator-store.js'; import { useThemeStore, type ThemeChoice } from '../../stores/theme-store.js'; +import type { ProviderType, ModelLoadProgress } from '../../providers'; -const MODELS = [ +const CLAUDE_MODELS = [ { value: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }, { value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' }, @@ -31,34 +33,70 @@ function formatBytes(bytes: number): string { export function SettingsPage() { const orch = getOrchestrator(); - // API Key + // Provider state + const [providerType, setProviderType] = useState('claude'); + const [webGPUStatus, setWebGPUStatus] = useState<{ available: boolean; reason?: string } | null>(null); + + // Local model state + const [localModelId, setLocalModelId] = useState(DEFAULT_LOCAL_MODEL_ID); + const [modelLoading, setModelLoading] = useState(false); + const [modelLoadProgress, setModelLoadProgress] = useState(0); + const [modelLoadStatus, setModelLoadStatus] = useState(''); + const [modelReady, setModelReady] = useState(false); + + // Claude API key state const [apiKey, setApiKey] = useState(''); const [apiKeyMasked, setApiKeyMasked] = useState(true); const [apiKeySaved, setApiKeySaved] = useState(false); - // Model - const [model, setModel] = useState(orch.getModel()); + // Claude model selection + const [claudeModel, setClaudeModel] = useState(orch.getModel()); - // Assistant name + // Assistant configuration const [assistantName, setAssistantName] = useState(orch.getAssistantName()); - // Telegram + // Telegram configuration const [telegramToken, setTelegramToken] = useState(''); const [telegramChatIds, setTelegramChatIds] = useState(''); const [telegramSaved, setTelegramSaved] = useState(false); - // Storage + // Storage state const [storageUsage, setStorageUsage] = useState(0); const [storageQuota, setStorageQuota] = useState(0); const [isPersistent, setIsPersistent] = useState(false); - // Theme + // Theme state const { theme, setTheme } = useThemeStore(); - // Load current values + // Load saved configuration on mount useEffect(() => { - async function load() { - // API key + async function loadConfiguration() { + // Load provider type + const savedProvider = await getConfig(CONFIG_KEYS.LLM_PROVIDER_TYPE); + if (savedProvider === 'claude' || savedProvider === 'onnx') { + setProviderType(savedProvider); + } + + // Load local model selection + const savedModelId = await getConfig(CONFIG_KEYS.LOCAL_MODEL_ID); + if (savedModelId && savedModelId in LOCAL_MODELS) { + setLocalModelId(savedModelId as LocalModelId); + } + + // Check WebGPU support + try { + const gpu = await orch.checkWebGPU(); + setWebGPUStatus(gpu); + } catch (e) { + setWebGPUStatus({ available: false, reason: 'WebGPU check failed' }); + } + + // Check if local model is already loaded + if (orch.isLocalProviderReady()) { + setModelReady(true); + } + + // Load Claude API key const encKey = await getConfig(CONFIG_KEYS.ANTHROPIC_API_KEY); if (encKey) { try { @@ -69,9 +107,10 @@ export function SettingsPage() { } } - // Telegram + // Load Telegram configuration const token = await getConfig(CONFIG_KEYS.TELEGRAM_BOT_TOKEN); if (token) setTelegramToken(token); + const chatIds = await getConfig(CONFIG_KEYS.TELEGRAM_CHAT_IDS); if (chatIds) { try { @@ -81,32 +120,103 @@ export function SettingsPage() { } } - // Storage + // Load storage estimate const est = await getStorageEstimate(); setStorageUsage(est.usage); setStorageQuota(est.quota); + if (navigator.storage?.persisted) { setIsPersistent(await navigator.storage.persisted()); } } - load(); + loadConfiguration(); }, []); + // Subscribe to provider loading events from orchestrator + useEffect(() => { + const handleProviderLoading = (data: { loading: boolean; progress: number; status: string }) => { + setModelLoading(data.loading); + setModelLoadProgress(data.progress); + setModelLoadStatus(data.status); + if (!data.loading && data.progress === 100) { + setModelReady(true); + } + }; + + orch.events.on('provider-loading', handleProviderLoading); + + return () => { + orch.events.off('provider-loading', handleProviderLoading); + }; + }, [orch]); + + // Handle provider type change + async function handleProviderChange(type: ProviderType) { + setProviderType(type); + await orch.setProviderType(type); + await setConfig(CONFIG_KEYS.LLM_PROVIDER_TYPE, type); + + // Reset model ready state when switching providers + if (type === 'claude') { + setModelReady(false); + } else { + setModelReady(orch.isLocalProviderReady()); + } + } + + // Handle local model selection change + async function handleLocalModelChange(newModelId: LocalModelId) { + setLocalModelId(newModelId); + await setConfig(CONFIG_KEYS.LOCAL_MODEL_ID, newModelId); + + // Reset model status when changing model + setModelReady(false); + setModelLoadProgress(0); + } + + // Initialize and download the selected local model + async function handleInitializeLocalModel() { + try { + setModelLoading(true); + await orch.initializeLocalProvider((prog: ModelLoadProgress) => { + setModelLoadProgress(prog.progress); + setModelLoadStatus(prog.status); + }); + setModelReady(true); + } catch (e) { + console.error('Failed to initialize local model:', e); + } finally { + setModelLoading(false); + } + } + + // Unload the local model to free memory + async function handleUnloadLocalModel() { + await orch.shutdown(); + setModelReady(false); + setModelLoadProgress(0); + setModelLoadStatus(''); + } + + // Save Claude API key async function handleSaveApiKey() { await orch.setApiKey(apiKey.trim()); setApiKeySaved(true); setTimeout(() => setApiKeySaved(false), 2000); } - async function handleModelChange(value: string) { - setModel(value); + // Change Claude model + async function handleClaudeModelChange(value: string) { + setClaudeModel(value); await orch.setModel(value); } - async function handleNameSave() { + // Save assistant name + async function handleAssistantNameSave() { await orch.setAssistantName(assistantName.trim()); } + // Save Telegram configuration async function handleTelegramSave() { const ids = telegramChatIds .split(',') @@ -117,21 +227,25 @@ export function SettingsPage() { setTimeout(() => setTelegramSaved(false), 2000); } - async function handleRequestPersistent() { + // Request persistent storage permission + async function handleRequestPersistentStorage() { const granted = await requestPersistentStorage(); setIsPersistent(granted); } const storagePercent = storageQuota > 0 ? (storageUsage / storageQuota) * 100 : 0; + const currentLocalModel = LOCAL_MODELS[localModelId]; return (

    Settings

    - {/* ---- Theme ---- */} + {/* Theme Settings */}
    -

    Appearance

    +

    + Appearance +

    Theme setApiKey(e.target.value)} - /> - -
    -
    - - {apiKeySaved && ( - Saved - )} + handleProviderChange('onnx')} + disabled={!webGPUStatus?.available} + className="radio radio-primary radio-sm mt-1" + /> +
    +
    + + Local Model (ONNX) +
    +

    + Runs locally in browser. No API key needed. Private and offline-capable. +

    + {webGPUStatus && !webGPUStatus.available && ( +

    + + {webGPUStatus.reason || 'WebGPU not available'} +

    + )} +
    +
    -

    - Your API key is encrypted and stored locally. It never leaves your browser. -

    - {/* ---- Model ---- */} -
    -
    -

    Model

    - + {/* Claude API Key Configuration */} + {providerType === 'claude' && ( +
    +
    +

    + Anthropic API Key +

    +
    + setApiKey(e.target.value)} + /> + +
    +
    + + {apiKeySaved && ( + + Saved + + )} +
    +

    + Your API key is encrypted with AES-GCM and stored locally in IndexedDB. + It never leaves your browser. +

    +
    -
    + )} + + {/* Local Model Configuration */} + {providerType === 'onnx' && ( +
    +
    +

    + Local Model Configuration +

    + + {/* Model Selection Dropdown */} +
    + Select Model + +

    + {currentLocalModel.description} +

    +
    + + {/* Model Specifications */} +
    +
    + Model ID: + + {currentLocalModel.id} + +
    +
    + Context Window: + {currentLocalModel.contextLength.toLocaleString()} tokens +
    +
    + Download Size: + {currentLocalModel.size} +
    +
    + Capabilities: + Chat only (no tools) +
    +
    + + {/* Download Progress - Fixed integer percentage */} + {modelLoading && ( +
    +
    + {modelLoadStatus} + {Math.round(modelLoadProgress)}% +
    + +

    + Downloading model files from Hugging Face... First load may take several minutes depending on your connection. +

    +
    + )} + + {/* Status Indicators */} +
    + {webGPUStatus?.available && ( +
    + + {modelReady ? 'WebGPU Active' : 'WebGPU Ready'} +
    + )} + {!webGPUStatus?.available && ( +
    + CPU Fallback (Slow) +
    + )} + {modelReady && ( +
    + Model Loaded +
    + )} +
    - {/* ---- Assistant Name ---- */} + {/* Action Buttons */} +
    + {!modelReady && !modelLoading && webGPUStatus?.available && ( + + )} + {!modelReady && !modelLoading && !webGPUStatus?.available && ( + + )} + {modelReady && ( + + )} + {modelLoading && ( + + )} +
    + + {/* Privacy Notice */} +
    + +
    +

    Privacy & Offline Capability

    +

    + This model runs entirely on your device using WebGPU/WebGL acceleration. + Model files are cached in browser storage and reused across sessions. + No conversation data is sent to external servers. +

    +
    +
    +
    +
    + )} + + {/* Claude Model Selection */} + {providerType === 'claude' && ( +
    +
    +

    + Claude Model +

    + +

    + Select the Claude model for API calls. Opus is most capable, Haiku is fastest. +

    +
    +
    + )} + + {/* Assistant Name Configuration */}
    -

    Assistant Name

    +

    + Assistant Name +

    setAssistantName(e.target.value)} - onBlur={handleNameSave} + onBlur={handleAssistantNameSave} />

    - The name used for the assistant. Mention @{assistantName} to trigger a response. + The name used to identify the assistant. Mention @{assistantName} in any message to trigger a response.

    - {/* ---- Telegram ---- */} + {/* Telegram Bot Configuration */}
    -

    Telegram Bot

    +

    + Telegram Bot +

    Bot Token setTelegramToken(e.target.value)} /> +

    Get this from @BotFather on Telegram

    Allowed Chat IDs setTelegramChatIds(e.target.value)} /> -

    Comma-separated chat IDs

    +

    Comma-separated list of allowed chat or channel IDs

    - {/* ---- Storage ---- */} + {/* Storage Management */}
    -

    Storage

    +

    + Storage +

    {formatBytes(storageUsage)} used @@ -282,18 +639,21 @@ export function SettingsPage() { {!isPersistent && ( )} {isPersistent && (
    - Persistent storage active + Persistent storage granted
    )} +

    + Persistent storage prevents the browser from clearing your data when under storage pressure. +

    ); -} +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 9d79920..9972385 100644 --- a/src/config.ts +++ b/src/config.ts @@ -50,7 +50,7 @@ export const FETCH_MAX_RESPONSE = 20_000; export const DB_NAME = 'openbrowserclaw'; /** IndexedDB version */ -export const DB_VERSION = 1; +export const DB_VERSION = 2; /** OPFS root directory name */ export const OPFS_ROOT = 'openbrowserclaw'; @@ -69,4 +69,32 @@ export const CONFIG_KEYS = { PASSPHRASE_SALT: 'passphrase_salt', PASSPHRASE_VERIFY: 'passphrase_verify', ASSISTANT_NAME: 'assistant_name', + LLM_PROVIDER_TYPE: 'llm_provider_type', + LOCAL_MODEL_ID: 'local_model_id', } as const; + +/** Available local ONNX models */ +export const LOCAL_MODELS = { + GEMMA_3_1B: { + id: 'onnx-community/gemma-3-1b-it-ONNX', + name: 'Gemma 3 1B IT', + size: '~1GB', + contextLength: 8192, + description: 'Better quality, slower inference', + }, + QWEN_3_5_0_8B: { + id: 'onnx-community/Qwen3.5-0.8B-ONNX', + name: 'Qwen 3.5 0.8B', + size: '~800MB', + contextLength: 32768, + description: 'Fast with good quality and large context window', + }, +} as const; + +export type LocalModelId = keyof typeof LOCAL_MODELS; + +/** Default local model */ +export const DEFAULT_LOCAL_MODEL_ID: LocalModelId = 'GEMMA_3_1B'; + +/** ONNX Cache directory */ +export const ONNX_CACHE_DIR = 'onnx-local-cache'; \ No newline at end of file diff --git a/src/hooks/useLLM.ts b/src/hooks/useLLM.ts new file mode 100644 index 0000000..d002545 --- /dev/null +++ b/src/hooks/useLLM.ts @@ -0,0 +1,321 @@ +// --------------------------------------------------------------------------- +// OpenBrowserClaw — LLM React Hook +// --------------------------------------------------------------------------- + +import { useState, useCallback, useEffect, useRef } from 'react'; +import type { + LLMProvider, + ProviderType, + ProviderInfo, + ModelInfo, + ChatOptions, + ModelLoadProgress, +} from '../providers'; +import { createProvider, getAvailableProviders } from '../providers'; +import type { ConversationMessage, TokenUsage } from '../types'; + +interface UseLLMState { + /** Current provider instance */ + provider: LLMProvider | null; + /** Provider type */ + providerType: ProviderType; + /** Is provider ready for inference */ + isReady: boolean; + /** Is model loading */ + isLoading: boolean; + /** Loading progress (0-100) */ + loadingProgress: number; + /** Loading status message */ + loadingStatus: string; + /** Available providers info */ + availableProviders: ProviderInfo[]; + /** Is currently generating */ + isGenerating: boolean; + /** Last error */ + error: string | null; +} + +interface UseLLMActions { + /** Switch provider type */ + setProviderType: (type: ProviderType) => Promise; + /** Set API key for cloud providers */ + setApiKey: (key: string) => void; + /** Initialize the provider */ + initialize: () => Promise; + /** Send a message */ + sendMessage: ( + messages: ConversationMessage[], + options?: ChatOptions + ) => Promise; + /** Send a message with streaming */ + sendMessageStream: ( + messages: ConversationMessage[], + onToken: (token: string) => void, + options?: ChatOptions + ) => Promise; + /** Get available models */ + getModels: () => Promise; + /** Abort current operation */ + abort: () => void; + /** Clear error */ + clearError: () => void; +} + +export type UseLLMReturn = UseLLMState & UseLLMActions; + +/** + * React hook for LLM interactions + */ +export function useLLM(initialType: ProviderType = 'claude'): UseLLMReturn { + const [state, setState] = useState({ + provider: null, + providerType: initialType, + isReady: false, + isLoading: false, + loadingProgress: 0, + loadingStatus: '', + availableProviders: [], + isGenerating: false, + error: null, + }); + + const abortControllerRef = useRef(null); + + // Initialize provider on mount + useEffect(() => { + async function init() { + try { + // Get available providers + const providers = await getAvailableProviders(); + + setState(s => ({ + ...s, + availableProviders: providers, + })); + + // Create initial provider + const provider = createProvider({ type: initialType }); + + setState(s => ({ + ...s, + provider, + isReady: provider.isReady(), + })); + } catch (e) { + setState(s => ({ + ...s, + error: e instanceof Error ? e.message : 'Failed to initialize', + })); + } + } + + init(); + }, [initialType]); + + /** + * Switch provider type + */ + const setProviderType = useCallback(async (type: ProviderType) => { + setState(s => ({ + ...s, + isLoading: true, + loadingProgress: 0, + loadingStatus: 'Initializing...', + error: null, + })); + + try { + const provider = createProvider({ type }); + + // Initialize if needed + if (provider.initialize) { + await provider.initialize((prog: ModelLoadProgress) => { + setState(s => ({ + ...s, + loadingProgress: prog.progress, + loadingStatus: prog.status, + })); + }); + } + + setState(s => ({ + ...s, + provider, + providerType: type, + isReady: provider.isReady(), + isLoading: false, + loadingProgress: 100, + loadingStatus: 'Ready', + })); + } catch (e) { + setState(s => ({ + ...s, + isLoading: false, + error: e instanceof Error ? e.message : 'Failed to switch provider', + })); + } + }, []); + + /** + * Set API key + */ + const setApiKey = useCallback((key: string) => { + if (state.provider?.setApiKey) { + state.provider.setApiKey(key); + setState(s => ({ + ...s, + isReady: state.provider?.isReady() ?? false, + })); + } + }, [state.provider]); + + /** + * Initialize provider + */ + const initialize = useCallback(async () => { + if (!state.provider?.initialize) return; + + setState(s => ({ + ...s, + isLoading: true, + loadingProgress: 0, + loadingStatus: 'Loading model...', + })); + + try { + await state.provider.initialize((prog: ModelLoadProgress) => { + setState(s => ({ + ...s, + loadingProgress: prog.progress, + loadingStatus: prog.status, + })); + }); + + setState(s => ({ + ...s, + isReady: true, + isLoading: false, + loadingProgress: 100, + loadingStatus: 'Ready', + })); + } catch (e) { + setState(s => ({ + ...s, + isLoading: false, + error: e instanceof Error ? e.message : 'Initialization failed', + })); + } + }, [state.provider]); + + /** + * Send a message + */ + const sendMessage = useCallback(async ( + messages: ConversationMessage[], + options?: ChatOptions + ): Promise => { + if (!state.provider) { + throw new Error('Provider not initialized'); + } + + abortControllerRef.current = new AbortController(); + + setState(s => ({ ...s, isGenerating: true, error: null })); + + try { + const result = await state.provider.chat(messages, { + ...options, + signal: abortControllerRef.current.signal, + }); + + return result; + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Generation failed'; + setState(s => ({ ...s, error: errorMsg })); + throw e; + } finally { + setState(s => ({ ...s, isGenerating: false })); + abortControllerRef.current = null; + } + }, [state.provider]); + + /** + * Send a message with streaming + */ + const sendMessageStream = useCallback(async ( + messages: ConversationMessage[], + onToken: (token: string) => void, + options?: ChatOptions + ): Promise => { + if (!state.provider) { + throw new Error('Provider not initialized'); + } + + abortControllerRef.current = new AbortController(); + + setState(s => ({ ...s, isGenerating: true, error: null })); + + try { + let fullContent = ''; + + for await (const chunk of state.provider.chatStream(messages, { + ...options, + signal: abortControllerRef.current.signal, + onToken, + })) { + fullContent += chunk; + onToken(chunk); + } + + return { + role: 'assistant', + content: fullContent, + }; + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Generation failed'; + setState(s => ({ ...s, error: errorMsg })); + throw e; + } finally { + setState(s => ({ ...s, isGenerating: false })); + abortControllerRef.current = null; + } + }, [state.provider]); + + /** + * Get available models + */ + const getModels = useCallback(async (): Promise => { + if (!state.provider) return []; + return state.provider.getModels(); + }, [state.provider]); + + /** + * Abort current operation + */ + const abort = useCallback(() => { + abortControllerRef.current?.abort(); + state.provider?.abort(); + setState(s => ({ ...s, isGenerating: false })); + }, [state.provider]); + + /** + * Clear error + */ + const clearError = useCallback(() => { + setState(s => ({ ...s, error: null })); + }, []); + + return { + ...state, + setProviderType, + setApiKey, + initialize, + sendMessage, + sendMessageStream, + getModels, + abort, + clearError, + }; +} + +export default useLLM; diff --git a/src/orchestrator.ts b/src/orchestrator.ts index 0ee06a0..83ed22b 100644 --- a/src/orchestrator.ts +++ b/src/orchestrator.ts @@ -5,11 +5,10 @@ // The orchestrator is the main thread coordinator. It manages: // - State machine (idle → thinking → responding) // - Message queue and routing -// - Agent worker lifecycle +// - Agent worker lifecycle (for Claude) +// - Local LLM provider (for ONNX models with streaming support) // - Channel coordination // - Task scheduling -// -// This mirrors NanoClaw's src/index.ts but adapted for browser primitives. import type { InboundMessage, @@ -27,7 +26,11 @@ import { DEFAULT_GROUP_ID, DEFAULT_MAX_TOKENS, DEFAULT_MODEL, + FETCH_MAX_RESPONSE, buildTriggerPattern, + LOCAL_MODELS, + type LocalModelId, + DEFAULT_LOCAL_MODEL_ID, } from './config.js'; import { openDatabase, @@ -39,13 +42,15 @@ import { saveTask, clearGroupMessages, } from './db.js'; -import { readGroupFile } from './storage.js'; +import { readGroupFile, writeGroupFile, listGroupFiles } from './storage.js'; import { encryptValue, decryptValue } from './crypto.js'; import { BrowserChatChannel } from './channels/browser-chat.js'; import { TelegramChannel } from './channels/telegram.js'; import { Router } from './router.js'; import { TaskScheduler } from './task-scheduler.js'; import { ulid } from './ulid.js'; +import type { ProviderType, ModelLoadProgress } from './providers'; +import { ONNXProvider } from './providers'; // --------------------------------------------------------------------------- // Event emitter for UI updates @@ -62,6 +67,8 @@ type EventMap = { 'session-reset': { groupId: string }; 'context-compacted': { groupId: string; summary: string }; 'token-usage': import('./types.js').TokenUsage; + 'provider-loading': { loading: boolean; progress: number; status: string }; + 'streaming-aborted': { groupId: string }; }; type EventCallback = (data: T) => void; @@ -106,23 +113,35 @@ export class Orchestrator { private messageQueue: InboundMessage[] = []; private processing = false; private pendingScheduledTasks = new Set(); + + // Provider support + private providerType: ProviderType = 'claude'; + private localProvider: ONNXProvider | null = null; + private providerLoading = false; + + // Abort control for streaming + private _abortStreaming: boolean = false; /** * Initialize the orchestrator. Must be called before anything else. */ async init(): Promise { - // Open database await openDatabase(); - // Load config + // Load configuration this.assistantName = (await getConfig(CONFIG_KEYS.ASSISTANT_NAME)) || ASSISTANT_NAME; this.triggerPattern = buildTriggerPattern(this.assistantName); + + const savedProvider = await getConfig(CONFIG_KEYS.LLM_PROVIDER_TYPE); + if (savedProvider === 'claude' || savedProvider === 'onnx') { + this.providerType = savedProvider; + } + const storedKey = await getConfig(CONFIG_KEYS.ANTHROPIC_API_KEY); if (storedKey) { try { this.apiKey = await decryptValue(storedKey); } catch { - // Stored as plaintext from before encryption — clear it this.apiKey = ''; await setConfig(CONFIG_KEYS.ANTHROPIC_API_KEY, ''); } @@ -133,13 +152,9 @@ export class Orchestrator { 10, ); - // Set up router this.router = new Router(this.browserChat, this.telegram); - - // Set up channels this.browserChat.onMessage((msg) => this.enqueue(msg)); - // Configure Telegram if token exists const telegramToken = await getConfig(CONFIG_KEYS.TELEGRAM_BOT_TOKEN); if (telegramToken) { const chatIdsRaw = await getConfig(CONFIG_KEYS.TELEGRAM_CHAT_IDS); @@ -149,7 +164,6 @@ export class Orchestrator { this.telegram.start(); } - // Set up agent worker this.agentWorker = new Worker( new URL('./agent-worker.ts', import.meta.url), { type: 'module' }, @@ -161,36 +175,155 @@ export class Orchestrator { console.error('Agent worker error:', err); }; - // Set up task scheduler this.scheduler = new TaskScheduler((groupId, prompt) => this.invokeAgent(groupId, prompt), ); this.scheduler.start(); - // Wire up browser chat display callback - this.browserChat.onDisplay((groupId, text, isFromMe) => { - // Display handled via events.emit('message', ...) - }); - this.events.emit('ready', undefined); } /** - * Get the current state. + * Get current orchestrator state */ getState(): OrchestratorState { return this.state; } /** - * Check if the API key is configured. + * Get current provider type + */ + getProviderType(): ProviderType { + return this.providerType; + } + + /** + * Switch between Claude and local provider + */ + async setProviderType(type: ProviderType): Promise { + this.providerType = type; + await setConfig(CONFIG_KEYS.LLM_PROVIDER_TYPE, type); + + // Cleanup local provider when switching to Claude + if (this.localProvider && type === 'claude') { + await this.localProvider.dispose?.(); + this.localProvider = null; + } + } + + /** + * Check if orchestrator is properly configured for the current provider */ isConfigured(): boolean { + if (this.providerType === 'onnx') { + return true; // Local provider needs no API key + } return this.apiKey.length > 0; } /** - * Update the API key. + * Check if currently using local provider + */ + isLocalProvider(): boolean { + return this.providerType === 'onnx'; + } + + /** + * Check WebGPU availability for local models + */ + async checkWebGPU(): Promise<{ available: boolean; reason?: string }> { + return ONNXProvider.checkWebGPU(); + } + + /** + * Initialize local model provider with selected model + */ + async initializeLocalProvider( + onProgress?: (prog: ModelLoadProgress) => void + ): Promise { + if (this.providerType !== 'onnx') { + throw new Error('Not using local provider'); + } + + // Get saved model ID or use default + const savedModelId = await getConfig(CONFIG_KEYS.LOCAL_MODEL_ID); + const modelId = (savedModelId && savedModelId in LOCAL_MODELS) + ? savedModelId as LocalModelId + : DEFAULT_LOCAL_MODEL_ID; + + // Dispose existing provider if model changed + if (this.localProvider && this.localProvider.modelId !== modelId) { + await this.localProvider.dispose?.(); + this.localProvider = null; + } + + // Skip if already ready + if (this.localProvider && this.localProvider.isReady()) { + return; + } + + this.providerLoading = true; + this.events.emit('provider-loading', { + loading: true, + progress: 0, + status: 'Initializing...' + }); + + try { + this.localProvider = new ONNXProvider(modelId); + await this.localProvider.initialize((prog: ModelLoadProgress) => { + // Check if aborted during initialization + if (this._abortStreaming) { + throw new Error('Initialization aborted'); + } + this.events.emit('provider-loading', { + loading: true, + progress: prog.progress, + status: prog.status + }); + onProgress?.(prog); + }); + + this.events.emit('provider-loading', { + loading: false, + progress: 100, + status: 'Ready' + }); + } catch (error) { + this.providerLoading = false; + this.events.emit('provider-loading', { + loading: false, + progress: 0, + status: 'Failed to load model' + }); + throw error; + } finally { + this.providerLoading = false; + } + } + + /** + * Check if local model is loaded and ready + */ + isLocalProviderReady(): boolean { + return this.localProvider?.isReady() ?? false; + } + + /** + * Abort ongoing streaming or model loading + */ + abortStreaming(): void { + this._abortStreaming = true; + this.localProvider?.abort(); + + // Reset after short delay + setTimeout(() => { + this._abortStreaming = false; + }, 100); + } + + /** + * Set Claude API key */ async setApiKey(key: string): Promise { this.apiKey = key; @@ -199,14 +332,14 @@ export class Orchestrator { } /** - * Get current model. + * Get current Claude model */ getModel(): string { return this.model; } /** - * Update the model. + * Set Claude model */ async setModel(model: string): Promise { this.model = model; @@ -214,14 +347,14 @@ export class Orchestrator { } /** - * Get assistant name. + * Get assistant name */ getAssistantName(): string { return this.assistantName; } /** - * Update assistant name and trigger pattern. + * Set assistant name */ async setAssistantName(name: string): Promise { this.assistantName = name; @@ -230,7 +363,7 @@ export class Orchestrator { } /** - * Configure Telegram. + * Configure Telegram bot integration */ async configureTelegram(token: string, chatIds: string[]): Promise { await setConfig(CONFIG_KEYS.TELEGRAM_BOT_TOKEN, token); @@ -241,26 +374,32 @@ export class Orchestrator { } /** - * Submit a message from the browser chat UI. + * Submit a message from the browser UI */ submitMessage(text: string, groupId?: string): void { this.browserChat.submit(text, groupId); } /** - * Start a completely new session — clears message history for the group. + * Start a new session (clear conversation history) */ async newSession(groupId: string = DEFAULT_GROUP_ID): Promise { - // Clear messages from DB await clearGroupMessages(groupId); this.events.emit('session-reset', { groupId }); } /** - * Compact (summarize) the current context to reduce token usage. - * Asks Claude to produce a summary, then replaces the history with it. + * Compact conversation context to reduce token usage */ async compactContext(groupId: string = DEFAULT_GROUP_ID): Promise { + if (this.providerType === 'onnx') { + this.events.emit('error', { + groupId, + error: 'Context compaction is not supported with local models yet.', + }); + return; + } + if (!this.apiKey) { this.events.emit('error', { groupId, @@ -280,13 +419,10 @@ export class Orchestrator { this.setState('thinking'); this.events.emit('typing', { groupId, typing: true }); - // Load group memory let memory = ''; try { memory = await readGroupFile(groupId, 'CLAUDE.md'); - } catch { - // No memory file yet - } + } catch {} const messages = await buildConversationMessages(groupId, CONTEXT_WINDOW_SIZE); const systemPrompt = buildSystemPrompt(this.assistantName, memory); @@ -305,16 +441,21 @@ export class Orchestrator { } /** - * Shut down everything. + * Shutdown and cleanup all resources */ - shutdown(): void { + async shutdown(): Promise { this.scheduler.stop(); this.telegram.stop(); this.agentWorker.terminate(); + + if (this.localProvider) { + await this.localProvider.dispose?.(); + this.localProvider = null; + } } // ----------------------------------------------------------------------- - // Private + // Private methods // ----------------------------------------------------------------------- private setState(state: OrchestratorState): void { @@ -323,18 +464,15 @@ export class Orchestrator { } private async enqueue(msg: InboundMessage): Promise { - // Save to DB const stored: StoredMessage = { ...msg, isFromMe: false, isTrigger: false, }; - // Check trigger const isBrowserMain = msg.groupId === DEFAULT_GROUP_ID; const hasTrigger = this.triggerPattern.test(msg.content.trim()); - // Browser main group always triggers; other groups need the trigger pattern if (isBrowserMain || hasTrigger) { stored.isTrigger = true; this.messageQueue.push(msg); @@ -343,15 +481,14 @@ export class Orchestrator { await saveMessage(stored); this.events.emit('message', stored); - // Process queue this.processQueue(); } private async processQueue(): Promise { if (this.processing) return; if (this.messageQueue.length === 0) return; - if (!this.apiKey) { - // Can't process without API key + + if (this.providerType === 'claude' && !this.apiKey) { const msg = this.messageQueue.shift()!; this.events.emit('error', { groupId: msg.groupId, @@ -369,7 +506,6 @@ export class Orchestrator { console.error('Failed to invoke agent:', err); } finally { this.processing = false; - // Process next in queue if (this.messageQueue.length > 0) { this.processQueue(); } @@ -377,12 +513,11 @@ export class Orchestrator { } private async invokeAgent(groupId: string, triggerContent: string): Promise { + // Show thinking immediately for local models (before model loading) this.setState('thinking'); this.router.setTyping(groupId, true); this.events.emit('typing', { groupId, typing: true }); - // If this is a scheduled task, save the prompt as a user message so - // it appears in conversation context and in the chat UI. if (triggerContent.startsWith('[SCHEDULED TASK]')) { this.pendingScheduledTasks.add(groupId); const stored: StoredMessage = { @@ -399,31 +534,129 @@ export class Orchestrator { this.events.emit('message', stored); } - // Load group memory let memory = ''; try { memory = await readGroupFile(groupId, 'CLAUDE.md'); - } catch { - // No memory file yet — that's fine - } + } catch {} - // Build conversation context const messages = await buildConversationMessages(groupId, CONTEXT_WINDOW_SIZE); + const systemPrompt = buildSystemPrompt(this.assistantName, memory, this.providerType); + + if (this.providerType === 'onnx') { + // Use streaming for local models + await this.invokeLocalAgent(groupId, messages, systemPrompt); + } else { + this.agentWorker.postMessage({ + type: 'invoke', + payload: { + groupId, + messages, + systemPrompt, + apiKey: this.apiKey, + model: this.model, + maxTokens: this.maxTokens, + }, + }); + } + } - const systemPrompt = buildSystemPrompt(this.assistantName, memory); + /** + * Invoke local LLM agent with streaming response + * Local models (Gemma, Qwen) are too small for reliable tool calling + */ + private async invokeLocalAgent( + groupId: string, + messages: ConversationMessage[], + systemPrompt: string + ): Promise { + // Reset abort flag + this._abortStreaming = false; + + try { + // Show thinking indicator while loading model + if (!this.localProvider || !this.localProvider.isReady()) { + this.events.emit('thinking-log', { + groupId, + kind: 'info', + timestamp: Date.now(), + label: 'Loading model', + detail: 'Initializing local model...', + }); + + await this.initializeLocalProvider(); + + // Check if aborted during loading + if (this._abortStreaming) { + throw new Error('Aborted'); + } + } - // Send to agent worker - this.agentWorker.postMessage({ - type: 'invoke', - payload: { + this.events.emit('thinking-log', { groupId, - messages, - systemPrompt, - apiKey: this.apiKey, - model: this.model, - maxTokens: this.maxTokens, - }, - }); + kind: 'info', + timestamp: Date.now(), + label: 'Starting inference', + detail: `Using ${this.localProvider?.modelId || 'local model'}`, + }); + + // Switch to responding state + this.setState('responding'); + this.events.emit('typing', { groupId, typing: false }); + + const tempId = 'streaming-' + Date.now(); + let fullResponse = ''; + + // Stream tokens with throttling + let lastUpdate = Date.now(); + const UPDATE_INTERVAL = 100; // Update every 100ms max + + for await (const token of this.localProvider!.chatStream(messages, { + maxTokens: Math.min(this.maxTokens, 1024), + systemPrompt: systemPrompt, + })) { + // Check for abort + if (this._abortStreaming) { + fullResponse += ' [stopped]'; + break; + } + + fullResponse += token; + + const now = Date.now(); + if (now - lastUpdate > UPDATE_INTERVAL) { + this.events.emit('message', { + id: tempId, + groupId, + sender: this.assistantName, + content: fullResponse, + timestamp: Date.now(), + channel: groupId.startsWith('tg:') ? 'telegram' : 'browser', + isFromMe: true, + isTrigger: false, + } as StoredMessage); + lastUpdate = now; + } + } + + // Final update with complete response + await this.deliverResponse(groupId, fullResponse); + + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + + // Don't show error if aborted intentionally + if (errorMsg === 'Aborted' || this._abortStreaming) { + this.events.emit('streaming-aborted', { groupId }); + } else { + this.events.emit('error', { + groupId, + error: `Local model error: ${errorMsg}`, + }); + } + + this.events.emit('typing', { groupId, typing: false }); + this.setState('idle'); + } } private async handleWorkerMessage(msg: WorkerOutbound): Promise { @@ -480,10 +713,8 @@ export class Orchestrator { } private async handleCompactDone(groupId: string, summary: string): Promise { - // Clear old messages await clearGroupMessages(groupId); - // Save the summary as a system-style message from the assistant const stored: StoredMessage = { id: ulid(), groupId, @@ -502,7 +733,6 @@ export class Orchestrator { } private async deliverResponse(groupId: string, text: string): Promise { - // Save to DB const stored: StoredMessage = { id: ulid(), groupId, @@ -515,16 +745,13 @@ export class Orchestrator { }; await saveMessage(stored); - // Route to channel await this.router.send(groupId, text); - // Play notification chime for scheduled task responses if (this.pendingScheduledTasks.has(groupId)) { this.pendingScheduledTasks.delete(groupId); playNotificationChime(); } - // Emit for UI this.events.emit('message', stored); this.events.emit('typing', { groupId, typing: false }); @@ -537,25 +764,46 @@ export class Orchestrator { // System prompt builder // --------------------------------------------------------------------------- -function buildSystemPrompt(assistantName: string, memory: string): string { +function buildSystemPrompt( + assistantName: string, + memory: string, + providerType: ProviderType = 'claude' +): string { const parts = [ `You are ${assistantName}, a personal AI assistant running in the user's browser.`, - '', - 'You have access to the following tools:', - '- **bash**: Execute commands in a sandboxed Linux VM (Alpine). Use for scripts, text processing, package installation.', - '- **javascript**: Execute JavaScript code. Lighter than bash — no VM boot needed. Use for calculations, data transforms.', - '- **read_file** / **write_file** / **list_files**: Manage files in the group workspace (persisted in browser storage).', - '- **fetch_url**: Make HTTP requests (subject to CORS).', - '- **update_memory**: Persist important context to CLAUDE.md — loaded on every conversation.', - '- **create_task**: Schedule recurring tasks with cron expressions.', - '', - 'Guidelines:', - '- Be concise and direct.', - '- Use tools proactively when they help answer the question.', - '- Update memory when you learn important preferences or context.', - '- For scheduled tasks, confirm the schedule with the user.', - '- Strip tags from your responses — they are for your internal reasoning only.', ]; + + if (providerType === 'onnx') { + // Local model - simple chat mode, no tools + parts.push( + '', + 'You are a helpful AI assistant. You can have conversations, answer questions, and help with various tasks.', + '', + 'Guidelines:', + '- Be helpful, friendly, and concise.', + '- If you don\'t know something, say so honestly.', + '- You cannot execute commands, create files, or access the internet.', + '- For tasks requiring those capabilities, suggest the user switch to Claude mode.', + ); + } else { + // Claude - full tool access + parts.push( + '', + 'You have access to the following tools:', + '- **bash**: Execute commands in a sandboxed Linux VM (Alpine).', + '- **javascript**: Execute JavaScript code.', + '- **read_file** / **write_file** / **list_files**: Manage files.', + '- **fetch_url**: Make HTTP requests.', + '- **update_memory**: Persist important context.', + '- **create_task**: Schedule recurring tasks.', + '', + 'Guidelines:', + '- Be concise and direct.', + '- Use tools proactively when they help answer the question.', + '- Update memory when you learn important preferences.', + '- Strip tags from your responses.', + ); + } if (memory) { parts.push('', '## Persistent Memory', '', memory); @@ -564,8 +812,27 @@ function buildSystemPrompt(assistantName: string, memory: string): string { return parts.join('\n'); } +/** + * Strip HTML from text + */ +function stripHtml(html: string): string { + let text = html; + text = text.replace(/<(script|style|noscript|svg|head)[^>]*>[\s\S]*?<\/\1>/gi, ''); + text = text.replace(//g, ''); + text = text.replace(/<[^>]+>/g, ' '); + text = text.replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + .replace(/&#\d+;/g, ''); + text = text.replace(/[ \t]+/g, ' ').replace(/\n\s*\n/g, '\n').trim(); + return text; +} + // --------------------------------------------------------------------------- -// Notification chime (Web Audio API — no external files needed) +// Notification chime // --------------------------------------------------------------------------- function playNotificationChime(): void { @@ -573,7 +840,6 @@ function playNotificationChime(): void { const ctx = new AudioContext(); const now = ctx.currentTime; - // Two-tone chime: C5 → E5 const frequencies = [523.25, 659.25]; for (let i = 0; i < frequencies.length; i++) { const osc = ctx.createOscillator(); @@ -592,9 +858,6 @@ function playNotificationChime(): void { osc.stop(now + i * 0.15 + 0.4); } - // Clean up context after sounds finish setTimeout(() => ctx.close(), 1000); - } catch { - // AudioContext may not be available — fail silently - } -} + } catch {} +} \ No newline at end of file diff --git a/src/providers/claude.ts b/src/providers/claude.ts new file mode 100644 index 0000000..d663a02 --- /dev/null +++ b/src/providers/claude.ts @@ -0,0 +1,406 @@ +// --------------------------------------------------------------------------- +// OpenBrowserClaw — Claude Provider (Anthropic API) +// --------------------------------------------------------------------------- + +import type { + LLMProvider, + ProviderInfo, + ProviderType, + LoadProgressCallback, + ModelInfo, + ChatOptions, + ChatWithToolsResult, + ToolCallResult, +} from './types'; +import type { ConversationMessage, TokenUsage, ToolDefinition } from '../types'; +import { ANTHROPIC_API_URL, ANTHROPIC_API_VERSION } from '../config'; + +// Available Claude models +const CLAUDE_MODELS: ModelInfo[] = [ + { + id: 'claude-opus-4-6', + name: 'Claude Opus 4.6', + contextLength: 200000, + capabilities: ['text-generation', 'vision', 'tool-use'], + isLocal: false, + }, + { + id: 'claude-sonnet-4-6', + name: 'Claude Sonnet 4.6', + contextLength: 200000, + capabilities: ['text-generation', 'vision', 'tool-use'], + isLocal: false, + }, + { + id: 'claude-haiku-4-5-20251001', + name: 'Claude Haiku 4.5', + contextLength: 200000, + capabilities: ['text-generation', 'vision', 'tool-use'], + isLocal: false, + }, +]; + +/** + * Claude Provider for Anthropic API + */ +export class ClaudeProvider implements LLMProvider { + readonly name = 'Claude (Anthropic)'; + readonly type: ProviderType = 'claude'; + + private apiKey: string = ''; + private model: string = 'claude-sonnet-4-6'; + private abortController: AbortController | null = null; + private _info: ProviderInfo; + + constructor() { + this._info = { + name: this.name, + requiresApiKey: true, + features: { + streaming: true, + toolUse: true, + vision: true, + }, + limits: { + maxTokens: 200000, + rateLimit: null, + }, + }; + } + + /** Provider information */ + get info(): ProviderInfo { + return this._info; + } + + /** + * Check if provider is ready + */ + isReady(): boolean { + return this.apiKey.length > 0; + } + + /** + * Initialize provider (no-op for Claude, just validates API key) + */ + async initialize?(_onProgress?: LoadProgressCallback): Promise { + // No initialization needed for cloud API + } + + /** + * Set API key + */ + setApiKey(key: string): void { + this.apiKey = key; + } + + /** + * Set model + */ + setModel(model: string): void { + this.model = model; + } + + /** + * Get current model + */ + getModel(): string { + return this.model; + } + + /** + * Get available models + */ + async getModels(): Promise { + return CLAUDE_MODELS; + } + + /** + * Create streaming chat generator + */ + async *chatStream( + messages: ConversationMessage[], + options?: ChatOptions + ): AsyncGenerator { + if (!this.apiKey) { + throw new Error('API key not set'); + } + + this.abortController = new AbortController(); + const signal = options?.signal; + + if (signal) { + signal.addEventListener('abort', () => { + this.abortController?.abort(); + }); + } + + const body: Record = { + model: this.model, + max_tokens: options?.maxTokens || 4096, + messages: messages, + }; + + if (options?.systemPrompt) { + body.system = options.systemPrompt; + } + + try { + const response = await fetch(ANTHROPIC_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': ANTHROPIC_API_VERSION, + 'anthropic-dangerous-direct-browser-access': 'true', + }, + body: JSON.stringify(body), + signal: this.abortController.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Claude API error ${response.status}: ${errorText}`); + } + + const result = await response.json(); + + // Emit token usage + if (result.usage && options?.onTokenUsage) { + options.onTokenUsage({ + groupId: '', + inputTokens: result.usage.input_tokens || 0, + outputTokens: result.usage.output_tokens || 0, + cacheReadTokens: result.usage.cache_read_input_tokens || 0, + cacheCreationTokens: result.usage.cache_creation_input_tokens || 0, + contextLimit: 200000, + }); + } + + // Extract text from response + const textBlocks = result.content.filter( + (block: any) => block.type === 'text' + ); + + const fullText = textBlocks + .map((b: any) => b.text || '') + .join(''); + + // Yield in chunks for streaming effect + const chunkSize = 10; + for (let i = 0; i < fullText.length; i += chunkSize) { + if (this.abortController?.signal.aborted) { + return; + } + yield fullText.slice(i, i + chunkSize); + await new Promise(resolve => setTimeout(resolve, 5)); + } + } finally { + this.abortController = null; + } + } + + /** + * Simple chat completion + */ + async chat( + messages: ConversationMessage[], + options?: ChatOptions + ): Promise { + if (!this.apiKey) { + throw new Error('API key not set'); + } + + this.abortController = new AbortController(); + const signal = options?.signal; + + if (signal) { + signal.addEventListener('abort', () => { + this.abortController?.abort(); + }); + } + + const body: Record = { + model: this.model, + max_tokens: options?.maxTokens || 4096, + messages: messages, + }; + + if (options?.systemPrompt) { + body.system = options.systemPrompt; + } + + try { + const response = await fetch(ANTHROPIC_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': ANTHROPIC_API_VERSION, + 'anthropic-dangerous-direct-browser-access': 'true', + }, + body: JSON.stringify(body), + signal: this.abortController.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Claude API error ${response.status}: ${errorText}`); + } + + const result = await response.json(); + + // Emit token usage + if (result.usage && options?.onTokenUsage) { + options.onTokenUsage({ + groupId: '', + inputTokens: result.usage.input_tokens || 0, + outputTokens: result.usage.output_tokens || 0, + cacheReadTokens: result.usage.cache_read_input_tokens || 0, + cacheCreationTokens: result.usage.cache_creation_input_tokens || 0, + contextLimit: 200000, + }); + } + + // Extract text from response + const textBlocks = result.content.filter( + (block: any) => block.type === 'text' + ); + + const text = textBlocks + .map((b: any) => b.text || '') + .join(''); + + return { + role: 'assistant', + content: text, + }; + } finally { + this.abortController = null; + } + } + + /** + * Chat with tool support + */ + async chatWithTools( + messages: ConversationMessage[], + tools: ToolDefinition[], + options?: ChatOptions + ): Promise { + if (!this.apiKey) { + throw new Error('API key not set'); + } + + this.abortController = new AbortController(); + const signal = options?.signal; + + if (signal) { + signal.addEventListener('abort', () => { + this.abortController?.abort(); + }); + } + + const body: Record = { + model: this.model, + max_tokens: options?.maxTokens || 4096, + messages: messages, + tools: tools, + }; + + if (options?.systemPrompt) { + body.system = options.systemPrompt; + } + + try { + const response = await fetch(ANTHROPIC_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': ANTHROPIC_API_VERSION, + 'anthropic-dangerous-direct-browser-access': 'true', + }, + body: JSON.stringify(body), + signal: this.abortController.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Claude API error ${response.status}: ${errorText}`); + } + + const result = await response.json(); + + // Emit token usage + if (result.usage && options?.onTokenUsage) { + options.onTokenUsage({ + groupId: '', + inputTokens: result.usage.input_tokens || 0, + outputTokens: result.usage.output_tokens || 0, + cacheReadTokens: result.usage.cache_read_input_tokens || 0, + cacheCreationTokens: result.usage.cache_creation_input_tokens || 0, + contextLimit: 200000, + }); + } + + // Check for tool use + const toolUseBlocks = result.content.filter( + (block: any) => block.type === 'tool_use' + ); + + if (toolUseBlocks.length > 0) { + const toolCalls: ToolCallResult[] = toolUseBlocks.map((block: any) => ({ + name: block.name, + arguments: block.input || {}, + })); + + return { + message: { + role: 'assistant', + content: result.content, + }, + toolCalls, + hasToolCalls: true, + }; + } + + // No tool use - extract text + const textBlocks = result.content.filter( + (block: any) => block.type === 'text' + ); + + const text = textBlocks + .map((b: any) => b.text || '') + .join(''); + + return { + message: { + role: 'assistant', + content: text, + }, + toolCalls: [], + hasToolCalls: false, + }; + } finally { + this.abortController = null; + } + } + + /** + * Abort ongoing request + */ + abort(): void { + this.abortController?.abort(); + } + + /** + * Cleanup resources + */ + async dispose(): Promise { + this.abort(); + this.apiKey = ''; + } +} + +export default ClaudeProvider; diff --git a/src/providers/factory.ts b/src/providers/factory.ts new file mode 100644 index 0000000..0209524 --- /dev/null +++ b/src/providers/factory.ts @@ -0,0 +1,80 @@ +// --------------------------------------------------------------------------- +// OpenBrowserClaw — Provider Factory +// --------------------------------------------------------------------------- + +import type { LLMProvider, ProviderType, ProviderInfo, ProviderConfig } from './types'; +import { ClaudeProvider } from './claude'; +import { ONNXProvider } from './onnx'; + +/** + * Create a provider instance + */ +export function createProvider(config: ProviderConfig): LLMProvider { + switch (config.type) { + case 'claude': { + const provider = new ClaudeProvider(); + if (config.apiKey) { + provider.setApiKey(config.apiKey); + } + return provider; + } + case 'onnx': + return new ONNXProvider(); + default: + throw new Error(`Unknown provider type: ${config.type}`); + } +} + +/** + * Get list of available providers info + */ +export async function getAvailableProviders(): Promise { + const list: ProviderInfo[] = []; + + // Always add Claude provider + list.push(new ClaudeProvider().info); + + // Add ONNX provider with WebGPU status + const onnxProvider = new ONNXProvider(); + const info = onnxProvider.info; + + // Check WebGPU status + const gpu = await ONNXProvider.checkWebGPU(); + info.webGPUStatus = gpu; + + list.push(info); + + return list; +} + +/** + * Get provider by type + */ +export function getProviderByType(type: ProviderType): LLMProvider { + return createProvider({ type }); +} + +/** + * Check if a provider type is available + */ +export function isProviderAvailable(type: ProviderType): boolean { + return type === 'claude' || type === 'onnx'; +} + +// Re-export types and providers +export type { + LLMProvider, + ProviderType, + ProviderInfo, + ProviderConfig, + LoadProgressCallback, + ModelLoadProgress, + ModelInfo, + ChatOptions, + ChatWithToolsResult, + ToolCallResult, + StreamCallback, +} from './types'; + +export { ClaudeProvider } from './claude'; +export { ONNXProvider } from './onnx'; diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 0000000..3499faa --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1,30 @@ +// --------------------------------------------------------------------------- +// OpenBrowserClaw — Provider Exports +// --------------------------------------------------------------------------- + +// Export types +export type { + LLMProvider, + ProviderType, + ProviderInfo, + ProviderConfig, + LoadProgressCallback, + ModelLoadProgress, + ModelInfo, + ChatOptions, + ChatWithToolsResult, + ToolCallResult, + StreamCallback, +} from './types'; + +// Export providers +export { ClaudeProvider } from './claude'; +export { ONNXProvider } from './onnx'; + +// Export factory functions +export { + createProvider, + getAvailableProviders, + getProviderByType, + isProviderAvailable +} from './factory'; diff --git a/src/providers/onnx.ts b/src/providers/onnx.ts new file mode 100644 index 0000000..cd3e7eb --- /dev/null +++ b/src/providers/onnx.ts @@ -0,0 +1,530 @@ +// --------------------------------------------------------------------------- +// OpenBrowserClaw — ONNX Provider (Local LLM - Chat Only, No Tools) +// --------------------------------------------------------------------------- + +import type { + LLMProvider, + ProviderInfo, + ProviderType, + LoadProgressCallback, + ModelInfo, + ChatOptions, +} from './types'; +import type { ConversationMessage } from '../types'; +import { + LOCAL_MODELS, + type LocalModelId, + DEFAULT_LOCAL_MODEL_ID, +} from '../config'; + +/** + * Model architecture types + */ +type ModelArchitecture = 'pipeline' | 'qwen'; + +/** + * Architecture mapping for local models + */ +const MODEL_ARCHITECTURES: Record = { + GEMMA_3_1B: 'pipeline', + QWEN_3_5_0_8B: 'qwen', +}; + +/** + * ONNX Provider for local LLM inference using Transformers.js + * Supports different model architectures: pipeline (Gemma) and Qwen specific + */ +export class ONNXProvider implements LLMProvider { + readonly name = 'ONNX (Local)'; + readonly type: ProviderType = 'onnx'; + + private _generator: any = null; + private _tokenizer: any = null; + private _processor: any = null; // For Qwen architecture + private _model: any = null; // For Qwen architecture + private _isLoading = false; + private _isReady = false; + private _info: ProviderInfo; + private _modelId: LocalModelId; + private _architecture: ModelArchitecture; + private _abortController: AbortController | null = null; + + constructor(modelId: LocalModelId = DEFAULT_LOCAL_MODEL_ID) { + this._modelId = modelId; + this._architecture = MODEL_ARCHITECTURES[modelId]; + const modelConfig = LOCAL_MODELS[modelId]; + this._info = { + name: `${modelConfig.name} (Local)`, + requiresApiKey: false, + features: { + streaming: true, + toolUse: false, + vision: false, + }, + limits: { + maxTokens: modelConfig.contextLength, + rateLimit: null, + }, + }; + } + + get info(): ProviderInfo { + return this._info; + } + + get modelId(): LocalModelId { + return this._modelId; + } + + /** + * Check WebGPU availability in the browser + */ + static async checkWebGPU(): Promise<{ available: boolean; reason?: string }> { + if (typeof navigator === 'undefined') { + return { available: false, reason: 'Not in browser environment' }; + } + + const nav = navigator as Navigator & { gpu?: { requestAdapter: () => Promise } }; + if (nav.gpu) { + try { + const adapter = await nav.gpu.requestAdapter(); + if (adapter) return { available: true }; + return { available: false, reason: 'No WebGPU adapter found' }; + } catch (e) { + return { available: false, reason: `WebGPU error: ${e}` }; + } + } + return { available: false, reason: 'WebGPU not supported in this browser' }; + } + + /** + * Load the ONNX model with progress reporting + */ + private async loadModel(onProgress?: LoadProgressCallback): Promise { + if (this._isReady && (this._generator || this._model)) return; + + if (this._isLoading) { + while (this._isLoading) { + // Check for abort during loading wait + if (this._abortController?.signal.aborted) { + throw new Error('Model loading aborted'); + } + await new Promise(r => setTimeout(r, 100)); + } + return; + } + + this._isLoading = true; + this._abortController = new AbortController(); + const modelConfig = LOCAL_MODELS[this._modelId]; + + try { + onProgress?.({ progress: 0, status: 'Initializing...' }); + + // Check for abort + if (this._abortController.signal.aborted) { + throw new Error('Aborted'); + } + + const { pipeline, env } = await import('@huggingface/transformers'); + + // Disable local models, fetch from Hugging Face + env.allowLocalModels = false; + + // Check WebGPU availability + const webGPU = await ONNXProvider.checkWebGPU(); + const useWebGPU = webGPU.available; + + onProgress?.({ + progress: 5, + status: useWebGPU ? 'WebGPU available' : 'Using CPU mode (slow)' + }); + + // Check abort before loading + if (this._abortController.signal.aborted) { + throw new Error('Aborted'); + } + + // Progress callback for model loading + const progressCallback = (p: any) => { + if (this._abortController?.signal.aborted) { + throw new Error('Aborted'); + } + if (p.status === 'progress' && typeof p.progress === 'number') { + const scaledProgress = 5 + Math.round(p.progress * 90); + onProgress?.({ + progress: Math.min(scaledProgress, 95), + status: p.file ? `Loading: ${p.file}` : 'Downloading model files...' + }); + } + }; + + // Load model based on architecture + if (this._architecture === 'qwen') { + await this.loadQwenModel(useWebGPU, progressCallback); + } else { + await this.loadPipelineModel(useWebGPU, progressCallback); + } + + onProgress?.({ progress: 100, status: 'Model ready!' }); + this._isReady = true; + } catch (error) { + console.error('Failed to load ONNX model:', error); + throw new Error( + `Failed to load local model. ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } finally { + this._isLoading = false; + this._abortController = null; + } + } + + /** + * Load Gemma model using pipeline API + */ + private async loadPipelineModel( + useWebGPU: boolean, + progressCallback: (p: any) => void + ): Promise { + const modelConfig = LOCAL_MODELS[this._modelId]; + const { pipeline, env } = await import('@huggingface/transformers'); + + env.allowLocalModels = false; + + if (useWebGPU) { + this._generator = await pipeline( + 'text-generation', + modelConfig.id, + { + dtype: 'q4', + device: 'webgpu', + progress_callback: progressCallback, + } + ); + } else { + this._generator = await pipeline( + 'text-generation', + modelConfig.id, + { + dtype: 'fp32', + progress_callback: progressCallback, + } + ); + } + + this._tokenizer = this._generator.tokenizer; + } + + /** + * Load Qwen model using specific Qwen3_5ForConditionalGeneration + */ + private async loadQwenModel( + useWebGPU: boolean, + progressCallback: (p: any) => void + ): Promise { + const modelConfig = LOCAL_MODELS[this._modelId]; + const { + AutoProcessor, + Qwen3_5ForConditionalGeneration, + env + } = await import('@huggingface/transformers'); + + env.allowLocalModels = false; + + // Load processor + this._processor = await AutoProcessor.from_pretrained(modelConfig.id); + + // Load model with Qwen-specific dtype configuration + if (useWebGPU) { + this._model = await Qwen3_5ForConditionalGeneration.from_pretrained( + modelConfig.id, + { + dtype: { + embed_tokens: 'q4', + vision_encoder: 'fp16', + decoder_model_merged: 'q4', + }, + device: 'webgpu', + progress_callback: progressCallback, + } + ); + } else { + this._model = await Qwen3_5ForConditionalGeneration.from_pretrained( + modelConfig.id, + { + dtype: 'fp32', + progress_callback: progressCallback, + } + ); + } + + this._tokenizer = this._processor.tokenizer; + } + + /** + * Check if provider is ready for inference + */ + isReady(): boolean { + if (this._architecture === 'qwen') { + return this._isReady && this._model !== null && this._processor !== null; + } + return this._isReady && this._generator !== null; + } + + /** + * Initialize the provider and load the model + */ + async initialize(onProgress?: LoadProgressCallback): Promise { + const webGPU = await ONNXProvider.checkWebGPU(); + this._info = { ...this._info, webGPUStatus: webGPU }; + await this.loadModel(onProgress); + } + + /** + * Get available models for this provider + */ + async getModels(): Promise { + return Object.entries(LOCAL_MODELS).map(([id, config]) => ({ + id: config.id, + name: config.name, + contextLength: config.contextLength, + capabilities: ['text-generation'], + isLocal: true, + })); + } + + /** + * Build formatted messages for the chat template + */ + private buildMessages( + messages: ConversationMessage[], + systemPrompt: string + ): Array<{ role: string; content: string }> { + const result: Array<{ role: string; content: string }> = [ + { role: 'system', content: systemPrompt } + ]; + + for (const msg of messages) { + let content = ''; + if (typeof msg.content === 'string') { + content = msg.content; + } else if (Array.isArray(msg.content)) { + content = msg.content + .filter((b): b is { type: 'text'; text: string } => b.type === 'text') + .map(b => b.text) + .join('\n'); + } + + if (!content) continue; + + // Merge consecutive messages from same role + const lastNonSystem = result.length > 1 ? result[result.length - 1] : null; + + if (lastNonSystem && lastNonSystem.role === msg.role) { + lastNonSystem.content += '\n' + content; + } else { + result.push({ role: msg.role, content }); + } + } + + // Ensure proper user/assistant alternation for chat models + const systemMsg = result[0]; + const chatMessages = result.slice(1); + + const filtered: Array<{ role: string; content: string }> = []; + let expectedRole: 'user' | 'assistant' = 'user'; + + for (const msg of chatMessages) { + if (msg.role === expectedRole) { + filtered.push(msg); + expectedRole = expectedRole === 'user' ? 'assistant' : 'user'; + } + } + + return [systemMsg, ...filtered]; + } + + /** + * Streaming chat completion + */ + async *chatStream( + messages: ConversationMessage[], + options?: ChatOptions + ): AsyncGenerator { + if (this._architecture === 'qwen') { + yield* this.qwenChatStream(messages, options); + } else { + yield* this.pipelineChatStream(messages, options); + } + } + + /** + * Streaming chat for pipeline-based models (Gemma) + */ + private async *pipelineChatStream( + messages: ConversationMessage[], + options?: ChatOptions + ): AsyncGenerator { + if (!this._generator) throw new Error('Pipeline not initialized'); + + const { TextStreamer } = await import('@huggingface/transformers'); + + const formattedMessages = this.buildMessages( + messages, + options?.systemPrompt || 'You are a helpful assistant.' + ); + + let accumulatedText = ''; + let isComplete = false; + let lastYieldedLength = 0; + + // Create custom streamer to capture tokens + const streamer = new TextStreamer(this._tokenizer, { + skip_prompt: true, + skip_special_tokens: true, + callback_function: (token: string) => { + accumulatedText += token; + }, + }); + + // Start generation + const generationPromise = this._generator(formattedMessages, { + max_new_tokens: options?.maxTokens || 512, + do_sample: false, + streamer, + }); + + generationPromise + .then(() => { isComplete = true; }) + .catch(() => { isComplete = true; }); + + // Yield tokens as they arrive + while (!isComplete) { + // Check for abort + if (this._abortController?.signal.aborted) { + break; + } + + await new Promise(resolve => setTimeout(resolve, 50)); + + if (accumulatedText.length > lastYieldedLength) { + const newText = accumulatedText.slice(lastYieldedLength); + lastYieldedLength = accumulatedText.length; + yield newText; + } + } + + // Yield any remaining text + if (accumulatedText.length > lastYieldedLength) { + yield accumulatedText.slice(lastYieldedLength); + } + } + + /** + * Streaming chat for Qwen architecture + */ + private async *qwenChatStream( + messages: ConversationMessage[], + options?: ChatOptions + ): AsyncGenerator { + if (!this._model || !this._processor) throw new Error('Qwen model not initialized'); + + const { TextStreamer } = await import('@huggingface/transformers'); + + // Build conversation + const formattedMessages = this.buildMessages( + messages, + options?.systemPrompt || 'You are a helpful assistant.' + ); + + // Apply chat template + const text = this._processor.apply_chat_template(formattedMessages, { + add_generation_prompt: true, + }); + + // Prepare inputs + const inputs = await this._processor(text); + + let accumulatedText = ''; + let isComplete = false; + let lastYieldedLength = 0; + + // Create streamer + const streamer = new TextStreamer(this._tokenizer, { + skip_prompt: true, + skip_special_tokens: false, + callback_function: (token: string) => { + accumulatedText += token; + }, + }); + + // Generate + const generationPromise = this._model.generate({ + ...inputs, + max_new_tokens: options?.maxTokens || 512, + streamer, + }); + + generationPromise + .then(() => { isComplete = true; }) + .catch(() => { isComplete = true; }); + + // Stream results + while (!isComplete) { + // Check for abort + if (this._abortController?.signal.aborted) { + break; + } + + await new Promise(resolve => setTimeout(resolve, 50)); + + if (accumulatedText.length > lastYieldedLength) { + const newText = accumulatedText.slice(lastYieldedLength); + lastYieldedLength = accumulatedText.length; + yield newText; + } + } + + if (accumulatedText.length > lastYieldedLength) { + yield accumulatedText.slice(lastYieldedLength); + } + } + + /** + * Non-streaming chat completion + */ + async chat( + messages: ConversationMessage[], + options?: ChatOptions + ): Promise { + // Collect all chunks from streaming + let fullContent = ''; + for await (const chunk of this.chatStream(messages, options)) { + fullContent += chunk; + } + + return { role: 'assistant', content: fullContent }; + } + + /** + * Abort ongoing generation or loading + */ + abort(): void { + this._abortController?.abort(); + this._abortController = null; + } + + /** + * Cleanup resources + */ + async dispose(): Promise { + this._generator = null; + this._tokenizer = null; + this._processor = null; + this._model = null; + this._isReady = false; + this._abortController = null; + } +} + +export default ONNXProvider; \ No newline at end of file diff --git a/src/providers/types.ts b/src/providers/types.ts new file mode 100644 index 0000000..9bc4993 --- /dev/null +++ b/src/providers/types.ts @@ -0,0 +1,140 @@ +// --------------------------------------------------------------------------- +// OpenBrowserClaw — LLM Provider Types +// --------------------------------------------------------------------------- + +import type { ConversationMessage, TokenUsage, ToolDefinition } from '../types'; + +/** Provider types available */ +export type ProviderType = 'claude' | 'onnx'; + +/** Progress callback for model loading */ +export type LoadProgressCallback = (progress: ModelLoadProgress) => void; + +/** Model loading progress information */ +export interface ModelLoadProgress { + progress: number; // 0-100 + status: string; // Status message + loaded?: number; // Bytes loaded + total?: number; // Total bytes + file?: string; // Current file being loaded +} + +/** Provider information */ +export interface ProviderInfo { + name: string; + requiresApiKey: boolean; + features: { + streaming: boolean; + toolUse: boolean; + vision: boolean; + }; + limits?: { + maxTokens: number; + rateLimit?: number | null; + }; + webGPUStatus?: { + available: boolean; + reason?: string; + }; +} + +/** Stream callback for token-by-token responses */ +export type StreamCallback = (token: string) => void; + +/** Model information */ +export interface ModelInfo { + id: string; + name: string; + contextLength: number; + capabilities: string[]; + isLocal: boolean; +} + +/** Options for chat completion */ +export interface ChatOptions { + systemPrompt?: string; + maxTokens?: number; + temperature?: number; + onToken?: StreamCallback; + signal?: AbortSignal; + onTokenUsage?: (usage: TokenUsage) => void; +} + +/** Tool call result */ +export interface ToolCallResult { + name: string; + arguments: Record; +} + +/** Result from chatWithTools */ +export interface ChatWithToolsResult { + message: ConversationMessage; + toolCalls: ToolCallResult[]; + hasToolCalls: boolean; +} + +/** + * LLM Provider interface + * All providers must implement this interface + */ +export interface LLMProvider { + /** Provider display name */ + readonly name: string; + + /** Provider type identifier */ + readonly type: ProviderType; + + /** Get provider information and capabilities */ + info: ProviderInfo; + + /** Check if provider is ready for inference */ + isReady(): boolean; + + /** Initialize provider (load model, validate API key, etc.) */ + initialize?(onProgress?: LoadProgressCallback): Promise; + + /** Get available models */ + getModels(): Promise; + + /** Set API key (for cloud providers) */ + setApiKey?(key: string): void; + + /** Simple chat completion */ + chat(messages: ConversationMessage[], options?: ChatOptions): Promise; + + /** Streaming chat completion */ + chatStream( + messages: ConversationMessage[], + options?: ChatOptions + ): AsyncGenerator; + + /** Chat with tool support (Claude only) */ + chatWithTools?( + messages: ConversationMessage[], + tools: ToolDefinition[], + options?: ChatOptions + ): Promise; + + /** Parse tool calls from response text (Claude only) */ + parseToolCalls?(text: string): ToolCallResult[]; + + /** Clean tool markers from response (Claude only) */ + cleanResponse?(text: string): string; + + /** Format tool result for next message (Claude only) */ + formatToolResult?(toolName: string, result: string): string; + + /** Abort ongoing operation */ + abort(): void; + + /** Cleanup resources */ + dispose?(): Promise; +} + +/** Provider configuration */ +export interface ProviderConfig { + type: ProviderType; + apiKey?: string; + modelUrl?: string; + localModelId?: string; +} \ No newline at end of file diff --git a/src/stores/orchestrator-store.ts b/src/stores/orchestrator-store.ts index 70bf775..1dc3b86 100644 --- a/src/stores/orchestrator-store.ts +++ b/src/stores/orchestrator-store.ts @@ -25,12 +25,23 @@ interface OrchestratorStoreState { activeGroupId: string; ready: boolean; + // --- provider state --- + providerType: 'claude' | 'onnx'; + providerLoading: boolean; + providerLoadProgress: number; + providerLoadStatus: string; + + // --- streaming state --- + streamingContent: string; + isStreaming: boolean; + // --- actions --- sendMessage: (text: string) => void; newSession: () => Promise; compactContext: () => Promise; clearError: () => void; loadHistory: () => Promise; + abortStreaming: () => void; } let orchestratorInstance: Orchestrator | null = null; @@ -51,6 +62,16 @@ export const useOrchestratorStore = create((set, get) => activeGroupId: DEFAULT_GROUP_ID, ready: false, + // Provider state + providerType: 'claude', + providerLoading: false, + providerLoadProgress: 0, + providerLoadStatus: '', + + // Streaming state + streamingContent: '', + isStreaming: false, + sendMessage: (text) => { const orch = getOrchestrator(); orch.submitMessage(text, get().activeGroupId); @@ -72,6 +93,12 @@ export const useOrchestratorStore = create((set, get) => const msgs = await getRecentMessages(get().activeGroupId, 200); set({ messages: msgs }); }, + + abortStreaming: () => { + const orch = getOrchestrator(); + orch.abortStreaming(); + set({ isStreaming: false }); + }, })); /** @@ -82,9 +109,28 @@ export async function initOrchestratorStore(orch: Orchestrator): Promise { orchestratorInstance = orch; const store = useOrchestratorStore; + // Set initial provider type + store.setState({ providerType: orch.getProviderType() }); + // Subscribe to events orch.events.on('message', (msg) => { - store.setState((s) => ({ messages: [...s.messages, msg] })); + const msgId = msg.id as string; + + // Check if this is a streaming message (starts with 'streaming-') + if (msgId.startsWith('streaming-')) { + // Update streaming content only, don't add to messages array + store.setState({ + streamingContent: msg.content, + isStreaming: true + }); + } else { + // Regular message - add to history and clear streaming + store.setState((s) => ({ + messages: [...s.messages, msg], + streamingContent: '', + isStreaming: false, + })); + } }); orch.events.on('typing', ({ typing }) => { @@ -110,7 +156,7 @@ export async function initOrchestratorStore(orch: Orchestrator): Promise { orch.events.on('state-change', (state) => { store.setState({ state }); if (state === 'idle') { - store.setState({ toolActivity: null }); + store.setState({ toolActivity: null, isStreaming: false, streamingContent: '' }); } }); @@ -125,6 +171,8 @@ export async function initOrchestratorStore(orch: Orchestrator): Promise { tokenUsage: null, toolActivity: null, isTyping: false, + streamingContent: '', + isStreaming: false, }); }); @@ -141,6 +189,18 @@ export async function initOrchestratorStore(orch: Orchestrator): Promise { store.setState({ ready: true }); }); + orch.events.on('streaming-aborted', () => { + store.setState({ isStreaming: false, streamingContent: '' }); + }); + + orch.events.on('provider-loading', (data) => { + store.setState({ + providerLoading: data.loading, + providerLoadProgress: data.progress, + providerLoadStatus: data.status, + }); + }); + // Load initial history await store.getState().loadHistory(); -} +} \ No newline at end of file