diff --git a/packages/aipex-react/src/adapters/chat-adapter.ts b/packages/aipex-react/src/adapters/chat-adapter.ts index 6ebdbce..851c482 100644 --- a/packages/aipex-react/src/adapters/chat-adapter.ts +++ b/packages/aipex-react/src/adapters/chat-adapter.ts @@ -140,6 +140,14 @@ export class ChatAdapter { break; case "content_delta": + // When text arrives after tool calls, start a new assistant message. + // This mirrors aipex's behavior where each model response after tool + // execution becomes a separate message, enabling the turn-based + // collapsing logic in the message list. + if (this.toolsAddedSinceLastText) { + this.state.currentAssistantMessageId = null; + this.toolsAddedSinceLastText = false; + } this.ensureAssistantMessage(); this.updateStatus("streaming"); this.appendContentDelta(event.delta); @@ -179,11 +187,13 @@ export class ChatAdapter { case "execution_complete": this.updateStatus("idle"); this.state.currentAssistantMessageId = null; + this.toolsAddedSinceLastText = false; break; case "error": this.updateStatus("error"); this.state.currentAssistantMessageId = null; + this.toolsAddedSinceLastText = false; break; } } diff --git a/packages/aipex-react/src/components/chatbot/components/message-item.tsx b/packages/aipex-react/src/components/chatbot/components/message-item.tsx index 2f663a1..18e1100 100644 --- a/packages/aipex-react/src/components/chatbot/components/message-item.tsx +++ b/packages/aipex-react/src/components/chatbot/components/message-item.tsx @@ -257,6 +257,7 @@ export function DefaultMessageItem({ {message.metadata.needLogin && ( chrome.runtime?.openOptionsPage?.()} /> )} diff --git a/packages/aipex-react/src/components/settings/index.tsx b/packages/aipex-react/src/components/settings/index.tsx index b1d0273..0998f00 100644 --- a/packages/aipex-react/src/components/settings/index.tsx +++ b/packages/aipex-react/src/components/settings/index.tsx @@ -22,6 +22,7 @@ import { Mic, Package, Palette, + Plug, Plus, Search, Settings, @@ -221,6 +222,7 @@ export function SettingsPage({ onSave, onTestConnection, skillsContent, + connectionContent, sttConfig, initialTab, initialSkill: _initialSkill, @@ -826,9 +828,14 @@ export function SettingsPage({ className="w-full" > @@ -844,6 +851,15 @@ export function SettingsPage({ {t("settings.skillsTab")} )} + {connectionContent && ( + + + {language === "zh" ? "连接" : "Connection"} + + )} {/* General Tab */} @@ -1680,6 +1696,13 @@ export function SettingsPage({ {skillsContent} )} + + {/* Connection Tab */} + {connectionContent && ( + + {connectionContent} + + )} diff --git a/packages/aipex-react/src/components/settings/types.ts b/packages/aipex-react/src/components/settings/types.ts index ed05a12..c22fe45 100644 --- a/packages/aipex-react/src/components/settings/types.ts +++ b/packages/aipex-react/src/components/settings/types.ts @@ -22,6 +22,8 @@ export interface SettingsPageProps { onSave?: (settings: AppSettings) => void; onTestConnection?: (settings: AppSettings) => Promise; skillsContent?: ReactNode; + /** Optional content for a "Connection" tab (e.g. MCP bridge panel). */ + connectionContent?: ReactNode; /** Optional ElevenLabs STT config adapter; when provided the STT card is shown. */ sttConfig?: STTConfigAdapter; /** Pre-select a tab on mount (e.g. from URL params). */ @@ -38,7 +40,7 @@ export interface ProviderConfig { export type ProviderConfigs = Record; -export type SettingsTab = "general" | "ai" | "skills"; +export type SettingsTab = "general" | "ai" | "skills" | "connection"; export interface SaveStatus { type: "success" | "error" | "info" | ""; diff --git a/packages/aipex-react/src/hooks/use-chat-config.ts b/packages/aipex-react/src/hooks/use-chat-config.ts index 9bfa92f..f088ad4 100644 --- a/packages/aipex-react/src/hooks/use-chat-config.ts +++ b/packages/aipex-react/src/hooks/use-chat-config.ts @@ -83,25 +83,43 @@ export function useChatConfig( }); const [isLoading, setIsLoading] = useState(autoLoad); + const applyStoredSettings = useCallback((stored: unknown) => { + setSettings((prev: AppSettings) => ({ + ...prev, + ...(stored as Partial), + customModels: (stored as AppSettings).customModels ?? [], + providerType: (stored as AppSettings).providerType ?? "openai", + providerEnabled: (stored as AppSettings).providerEnabled ?? false, + })); + }, []); + const loadSettings = useCallback(async () => { setIsLoading(true); try { const stored = await storageAdapter.load(STORAGE_KEYS.SETTINGS); if (stored) { - setSettings((prev: AppSettings) => ({ - ...prev, - ...stored, - customModels: (stored as AppSettings).customModels ?? [], - providerType: (stored as AppSettings).providerType ?? "openai", - providerEnabled: (stored as AppSettings).providerEnabled ?? false, - })); + applyStoredSettings(stored); } } catch (error) { console.error("Failed to load chat settings:", error); } finally { setIsLoading(false); } - }, [storageAdapter]); + }, [storageAdapter, applyStoredSettings]); + + // Silent reload that does NOT touch isLoading, so existing UI stays mounted. + // Used by the storage watcher to pick up changes (e.g. model switch) + // without unmounting ChatBot. + const reloadSettingsSilently = useCallback(async () => { + try { + const stored = await storageAdapter.load(STORAGE_KEYS.SETTINGS); + if (stored) { + applyStoredSettings(stored); + } + } catch (error) { + console.error("Failed to reload chat settings:", error); + } + }, [storageAdapter, applyStoredSettings]); const saveSettings = useCallback( async (newSettings: AppSettings) => { @@ -119,16 +137,16 @@ export function useChatConfig( void loadSettings(); } - // Set up storage change listener for real-time sync + // Set up storage change listener for real-time sync. + // Uses silent reload so the UI does not unmount (preserving chat state). const unwatch = storageAdapter.watch(STORAGE_KEYS.SETTINGS, () => { - // Reload settings when storage changes (e.g., from settings page) - void loadSettings(); + void reloadSettingsSilently(); }); return () => { unwatch(); }; - }, [autoLoad, loadSettings, storageAdapter]); + }, [autoLoad, loadSettings, reloadSettingsSilently, storageAdapter]); const updateSetting = useCallback( async ( diff --git a/packages/aipex-react/src/hooks/use-chat.ts b/packages/aipex-react/src/hooks/use-chat.ts index 413bb47..37a4fcc 100644 --- a/packages/aipex-react/src/hooks/use-chat.ts +++ b/packages/aipex-react/src/hooks/use-chat.ts @@ -91,9 +91,6 @@ export function useChat( const [sessionId, setSessionId] = useState(null); const [metrics, setMetrics] = useState(null); - // Cumulative session-level metrics (sum across all runs) - const cumulativeMetricsRef = useRef(null); - // Refs for stable callbacks const handlersRef = useRef(handlers); handlersRef.current = handlers; @@ -102,6 +99,18 @@ export function useChat( configRef.current = config; const activeGeneratorRef = useRef | null>(null); + const prevAgentRef = useRef(agent); + + // When the agent instance changes (e.g. model switch), reset the sessionId + // so subsequent messages create a fresh session on the new agent, but + // preserve existing UI messages so the conversation history stays visible. + useEffect(() => { + if (agent && prevAgentRef.current && agent !== prevAgentRef.current) { + setSessionId(null); + setMetrics(null); + } + prevAgentRef.current = agent; + }, [agent]); // Create adapter with callbacks const adapter = useMemo(() => { @@ -156,24 +165,13 @@ export function useChat( handlersRef.current?.onError?.(event.error); } - // Handle metrics update – accumulate across the session + // Handle metrics update – show latest completion metrics (not cumulative) if (event.type === "metrics_update") { - const prev = cumulativeMetricsRef.current; - const cumulative: AgentMetrics = { - tokensUsed: (prev?.tokensUsed ?? 0) + event.metrics.tokensUsed, - promptTokens: - (prev?.promptTokens ?? 0) + event.metrics.promptTokens, - completionTokens: - (prev?.completionTokens ?? 0) + event.metrics.completionTokens, - // Non-cumulative fields: use latest values - itemCount: event.metrics.itemCount, - maxTurns: event.metrics.maxTurns, - duration: (prev?.duration ?? 0) + event.metrics.duration, - startTime: prev?.startTime ?? event.metrics.startTime, - }; - cumulativeMetricsRef.current = cumulative; - setMetrics(cumulative); - handlersRef.current?.onMetricsUpdate?.(cumulative, event.sessionId); + setMetrics(event.metrics); + handlersRef.current?.onMetricsUpdate?.( + event.metrics, + event.sessionId, + ); } // Process the event through adapter @@ -277,7 +275,6 @@ export function useChat( activeGeneratorRef.current = null; setSessionId(null); setMetrics(null); - cumulativeMetricsRef.current = null; adapter.reset(configRef.current?.initialMessages ?? []); }, [adapter, agent, sessionId]); diff --git a/packages/aipex-react/src/types/chat.ts b/packages/aipex-react/src/types/chat.ts index 8230ec9..17251c7 100644 --- a/packages/aipex-react/src/types/chat.ts +++ b/packages/aipex-react/src/types/chat.ts @@ -128,6 +128,8 @@ export interface ChatbotSlots { afterMessages?: () => ReactNode; /** Extra content rendered inside PromptInput (e.g., context/skill loaders) */ promptExtras?: () => ReactNode; + /** Handler called when user clicks Login in a LoginPrompt inside a message */ + onLogin?: () => void; } // ============ Components Configuration ============ diff --git a/packages/browser-ext/manifest.json b/packages/browser-ext/manifest.json index 7ce5683..0edb5cf 100644 --- a/packages/browser-ext/manifest.json +++ b/packages/browser-ext/manifest.json @@ -81,6 +81,7 @@ "debugger", "cookies", "webNavigation", - "audioCapture" + "audioCapture", + "alarms" ] } diff --git a/packages/browser-ext/src/background.ts b/packages/browser-ext/src/background.ts index 19c0532..ff66207 100644 --- a/packages/browser-ext/src/background.ts +++ b/packages/browser-ext/src/background.ts @@ -637,4 +637,87 @@ chrome.runtime.onMessageExternal.addListener( }, ); +// ============================================================================= +// WebSocket MCP Bridge +// ============================================================================= +import { wsMcpServer } from "@aipexstudio/browser-runtime"; + +// Handle MCP bridge messages +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message.request === "ws-bridge-connect") { + const url = message.url as string; + wsMcpServer + .connect(url) + .then(() => { + updateMcpBadge(true); + sendResponse({ success: true }); + }) + .catch((error) => { + sendResponse({ + success: false, + error: error instanceof Error ? error.message : String(error), + }); + }); + return true; + } + + if (message.request === "ws-bridge-disconnect") { + wsMcpServer + .disconnect() + .then(() => { + updateMcpBadge(false); + sendResponse({ success: true }); + }) + .catch((error) => { + sendResponse({ + success: false, + error: error instanceof Error ? error.message : String(error), + }); + }); + return true; + } + + if (message.request === "ws-bridge-status") { + sendResponse(wsMcpServer.getStatus()); + return true; + } + + return false; +}); + +// Update badge to show MCP connection status +function updateMcpBadge(connected: boolean) { + if (connected) { + chrome.action.setBadgeText({ text: "ON" }); + chrome.action.setBadgeBackgroundColor({ color: "#22c55e" }); + } else { + chrome.action.setBadgeText({ text: "" }); + } +} + +// Handle keepalive alarms for the WebSocket connection +chrome.alarms.onAlarm.addListener((alarm) => { + wsMcpServer.handleAlarm(alarm); +}); + +// Track MCP connection status for badge updates +wsMcpServer.onStatusChange((state) => { + updateMcpBadge(state.status === "connected"); +}); + +// Auto-connect to saved URL on startup +wsMcpServer + .getSavedUrl() + .then((url) => { + if (url) { + console.log("[WsMcpServer] Auto-connecting to saved URL:", url); + wsMcpServer.connect(url).catch(() => { + // connect() handles its own retry logic + }); + } + }) + .catch(() => { + // Ignore storage errors on startup + }); + console.log("AIPex background service worker started"); diff --git a/packages/browser-ext/src/lib/browser-chat-header.tsx b/packages/browser-ext/src/lib/browser-chat-header.tsx index 9b5fd0d..ff4768a 100644 --- a/packages/browser-ext/src/lib/browser-chat-header.tsx +++ b/packages/browser-ext/src/lib/browser-chat-header.tsx @@ -5,30 +5,14 @@ import { useChatContext } from "@aipexstudio/aipex-react/components/chatbot"; import { Button } from "@aipexstudio/aipex-react/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@aipexstudio/aipex-react/components/ui/dropdown-menu"; import { useTranslation } from "@aipexstudio/aipex-react/i18n/context"; import { getRuntime } from "@aipexstudio/aipex-react/lib/runtime"; import { cn } from "@aipexstudio/aipex-react/lib/utils"; import type { HeaderProps } from "@aipexstudio/aipex-react/types"; import { conversationStorage } from "@aipexstudio/browser-runtime"; -import { - KeyboardIcon, - MicIcon, - MoreHorizontalIcon, - PlusIcon, - SettingsIcon, - Share2Icon, - SparklesIcon, -} from "lucide-react"; +import { KeyboardIcon, MicIcon, PlusIcon, SettingsIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { UserProfile, useAuth } from "../auth"; -import { shareConversation } from "../services/share-conversation"; import { ConversationHistory } from "./conversation-history"; import { useInputMode } from "./input-mode-context"; import { fromStorageFormat, toStorageFormat } from "./message-adapter"; @@ -43,7 +27,7 @@ export function BrowserChatHeader({ }: HeaderProps) { const { t } = useTranslation(); const runtime = getRuntime(); - const { messages, setMessages, interrupt, sendMessage } = useChatContext(); + const { messages, setMessages, interrupt } = useChatContext(); const { user, login, isLoading: isAuthLoading } = useAuth(); const [currentConversationId, setCurrentConversationId] = useState< @@ -149,34 +133,6 @@ export function BrowserChatHeader({ setInputMode(inputMode === "voice" ? "text" : "voice"); }, [inputMode, setInputMode]); - // Share conversation - const [isSharing, setIsSharing] = useState(false); - - const handleShare = useCallback(async () => { - if (isSharing) return; - - const nonSystemMessages = messages.filter((m) => m.role !== "system"); - if (nonSystemMessages.length === 0) return; - - setIsSharing(true); - try { - const { url } = await shareConversation(messages); - chrome.tabs.create({ url }); - } catch (error) { - console.error( - "[Share] Failed:", - error instanceof Error ? error.message : String(error), - ); - } finally { - setIsSharing(false); - } - }, [messages, isSharing]); - - // Save as Skill — instructs the AI to create a skill from the conversation - const handleSaveAsSkill = useCallback(() => { - sendMessage("use skill-creator skill to save the conversation"); - }, [sendMessage]); - return (
- {/* Right side - More menu, New Chat, User Profile */} + {/* Right side - New Chat, User Profile */}
- {/* More actions dropdown (Share, Save as Skill) */} - - - - - - m.role !== "system").length === 0 - } - > - - {isSharing ? "Sharing..." : "Share Conversation"} - - - m.role !== "system").length === 0 - } - > - - Save as Skill - - - - + ) : ( + + )} +
+ + {/* Error display */} + {error && ( +
+ {error} +
+ )} + + {/* Info */} +
+

+ The bridge exposes tools/list and tools/call{" "} + over the MCP protocol, allowing external agents to use AIPex browser + automation tools. +

+

Only localhost connections (127.0.0.1, ::1) are allowed.

+
+ + ); +} diff --git a/packages/browser-runtime/src/index.ts b/packages/browser-runtime/src/index.ts index 7c6be9d..5bf6dbd 100644 --- a/packages/browser-runtime/src/index.ts +++ b/packages/browser-runtime/src/index.ts @@ -37,3 +37,5 @@ export * from "./storage/index.js"; export * from "./tools/index.js"; // Voice // export * from "./voice/index.js"; +// WebSocket MCP Bridge +export * from "./ws-bridge/index.js"; diff --git a/packages/browser-runtime/src/lib/vm/skill-api.ts b/packages/browser-runtime/src/lib/vm/skill-api.ts index 961929b..67d8bc2 100644 --- a/packages/browser-runtime/src/lib/vm/skill-api.ts +++ b/packages/browser-runtime/src/lib/vm/skill-api.ts @@ -4,14 +4,10 @@ * between QuickJS VM and the host environment */ +import type { FileStats } from "./types"; import { zenfs } from "./zenfs-manager"; -export interface FileStats { - isFile: boolean; - isDirectory: boolean; - size: number; - mtime: Date; -} +export type { FileStats }; export interface ToolDefinition { name: string; diff --git a/packages/browser-runtime/src/lib/vm/types.ts b/packages/browser-runtime/src/lib/vm/types.ts new file mode 100644 index 0000000..4069d28 --- /dev/null +++ b/packages/browser-runtime/src/lib/vm/types.ts @@ -0,0 +1,6 @@ +export interface FileStats { + isFile: boolean; + isDirectory: boolean; + size: number; + mtime: Date; +} diff --git a/packages/browser-runtime/src/lib/vm/zenfs-manager.ts b/packages/browser-runtime/src/lib/vm/zenfs-manager.ts index 0948349..25db532 100644 --- a/packages/browser-runtime/src/lib/vm/zenfs-manager.ts +++ b/packages/browser-runtime/src/lib/vm/zenfs-manager.ts @@ -5,7 +5,7 @@ import { configure, fs } from "@zenfs/core"; import { IndexedDB } from "@zenfs/dom"; -import type { FileStats } from "./skill-api"; +import type { FileStats } from "./types"; type FsPromises = { mkdir: (...args: any[]) => Promise; diff --git a/packages/browser-runtime/src/runtime/browser-automation-host.ts b/packages/browser-runtime/src/runtime/browser-automation-host.ts index 033de3e..b71f952 100644 --- a/packages/browser-runtime/src/runtime/browser-automation-host.ts +++ b/packages/browser-runtime/src/runtime/browser-automation-host.ts @@ -1,30 +1,17 @@ import type { ContextProvider } from "@aipexstudio/aipex-core"; -import type { RuntimeAddon } from "./runtime-addon.js"; -import type { RuntimeBroadcastMessage } from "./types.js"; +import type { + AutomationTarget, + RuntimeAddon, + RuntimeBroadcastMessage, + SnapshotCaptureOptions, + SnapshotResult, +} from "./types.js"; -export interface AutomationTarget { - tabId: number; - frameId?: number; - windowId?: number; -} - -export interface SnapshotCaptureOptions { - includeDom?: boolean; - includeScreenshot?: boolean; - includeContext?: boolean; - reason?: string; - tabId?: number; -} - -export interface SnapshotResult { - id: string; - capturedAt: number; - screenshot?: string; - dom?: string; - title?: string; - url?: string; - metadata?: Record; -} +export type { + AutomationTarget, + SnapshotCaptureOptions, + SnapshotResult, +} from "./types.js"; export interface CaptureSessionOptions { target: AutomationTarget; diff --git a/packages/browser-runtime/src/runtime/runtime-addon.ts b/packages/browser-runtime/src/runtime/runtime-addon.ts index 08989b1..a7fad55 100644 --- a/packages/browser-runtime/src/runtime/runtime-addon.ts +++ b/packages/browser-runtime/src/runtime/runtime-addon.ts @@ -1,22 +1,4 @@ -import type { - AutomationTarget, - SnapshotCaptureOptions, - SnapshotResult, -} from "./browser-automation-host.js"; -import type { RuntimeBroadcastMessage } from "./types.js"; - -export interface SnapshotHookContext { - target: AutomationTarget; - options?: SnapshotCaptureOptions; -} - -export interface RuntimeAddon { - id: string; - initialize?(): Promise | void; - onMessage?(message: RuntimeBroadcastMessage): Promise | void; - onBeforeSnapshot?(ctx: SnapshotHookContext): Promise | void; - onAfterSnapshot?( - result: SnapshotResult, - ctx: SnapshotHookContext, - ): Promise | void; -} +export type { + RuntimeAddon, + SnapshotHookContext, +} from "./types.js"; diff --git a/packages/browser-runtime/src/runtime/types.ts b/packages/browser-runtime/src/runtime/types.ts index dfd8fbb..c037e4c 100644 --- a/packages/browser-runtime/src/runtime/types.ts +++ b/packages/browser-runtime/src/runtime/types.ts @@ -8,3 +8,43 @@ export interface RuntimeBroadcastMessage { export interface RuntimeAddonCleanup { dispose(): Promise | void; } + +export interface AutomationTarget { + tabId: number; + frameId?: number; + windowId?: number; +} + +export interface SnapshotCaptureOptions { + includeDom?: boolean; + includeScreenshot?: boolean; + includeContext?: boolean; + reason?: string; + tabId?: number; +} + +export interface SnapshotResult { + id: string; + capturedAt: number; + screenshot?: string; + dom?: string; + title?: string; + url?: string; + metadata?: Record; +} + +export interface SnapshotHookContext { + target: AutomationTarget; + options?: SnapshotCaptureOptions; +} + +export interface RuntimeAddon { + id: string; + initialize?(): Promise | void; + onMessage?(message: RuntimeBroadcastMessage): Promise | void; + onBeforeSnapshot?(ctx: SnapshotHookContext): Promise | void; + onAfterSnapshot?( + result: SnapshotResult, + ctx: SnapshotHookContext, + ): Promise | void; +} diff --git a/packages/browser-runtime/src/skill/lib/storage/skill-storage.ts b/packages/browser-runtime/src/skill/lib/storage/skill-storage.ts index 74ce67f..39f935a 100644 --- a/packages/browser-runtime/src/skill/lib/storage/skill-storage.ts +++ b/packages/browser-runtime/src/skill/lib/storage/skill-storage.ts @@ -401,7 +401,7 @@ export class SkillStorage { // Check if skills directory exists const skillsDirExists = await zenfs.exists(skillsPath); if (!skillsDirExists) { - console.log("[SkillStorage] /skills directory does not exist yet"); + console.debug("[SkillStorage] /skills directory does not exist yet"); return skills; } @@ -445,7 +445,7 @@ export class SkillStorage { enabled: true, }); - console.log(`[SkillStorage] Found skill in ZenFS: ${entry}`); + console.debug(`[SkillStorage] Found skill in ZenFS: ${entry}`); } catch (error) { console.warn( `[SkillStorage] Failed to process skill ${entry}:`, @@ -494,7 +494,7 @@ export class SkillStorage { const stats = { added: 0, skipped: 0, failed: 0 }; try { - console.log("[SkillStorage] Starting sync from ZenFS..."); + console.debug("[SkillStorage] Starting sync from ZenFS..."); // Scan ZenFS for all skills const zenfsSkills = await this.scanZenFSForSkills(); @@ -528,7 +528,7 @@ export class SkillStorage { try { await this.saveToIndexedDB(skill); stats.added++; - console.log(`[SkillStorage] Added skill from ZenFS: ${skill.name}`); + console.debug(`[SkillStorage] Added skill from ZenFS: ${skill.name}`); } catch (error) { console.error( `[SkillStorage] Failed to add skill ${skill.name}:`, @@ -543,7 +543,7 @@ export class SkillStorage { // Update last sync time this.lastSyncTime = Date.now(); - console.log( + console.debug( `[SkillStorage] Sync completed: ${stats.added} added, ${stats.skipped} skipped, ${stats.failed} failed`, ); } catch (error) { diff --git a/packages/browser-runtime/src/tools/index.ts b/packages/browser-runtime/src/tools/index.ts index a541de0..3901998 100644 --- a/packages/browser-runtime/src/tools/index.ts +++ b/packages/browser-runtime/src/tools/index.ts @@ -128,47 +128,8 @@ export function registerDefaultBrowserTools( return registry; } -/** - * Get the currently active tab - * @throws Error if no active tab is found - */ -export async function getActiveTab(): Promise { - const [tab] = await chrome.tabs.query({ - active: true, - currentWindow: true, - }); - - if (!tab?.id) { - throw new Error("No active tab found"); - } - - return tab; -} - -/** - * Execute a script in a specific tab - */ -export async function executeScriptInTab( - tabId: number, - func: (...args: Args) => T, - args: Args, -): Promise { - const results = await chrome.scripting.executeScript({ - target: { tabId }, - func, - args, - }); - - return results[0]?.result as T; -} - -/** - * Execute a script in the active tab - */ -export async function executeScriptInActiveTab( - func: (...args: Args) => T, - args: Args, -): Promise { - const tab = await getActiveTab(); - return await executeScriptInTab(tab.id!, func, args); -} +export { + executeScriptInActiveTab, + executeScriptInTab, + getActiveTab, +} from "./tab-utils"; diff --git a/packages/browser-runtime/src/tools/page.ts b/packages/browser-runtime/src/tools/page.ts index f8389ce..7a9f49a 100644 --- a/packages/browser-runtime/src/tools/page.ts +++ b/packages/browser-runtime/src/tools/page.ts @@ -1,6 +1,6 @@ import { tool } from "@aipexstudio/aipex-core"; import { z } from "zod"; -import { getActiveTab } from "./index"; +import { getActiveTab } from "./tab-utils"; /** * Get page metadata including title, description, keywords, etc. diff --git a/packages/browser-runtime/src/tools/screenshot.ts b/packages/browser-runtime/src/tools/screenshot.ts index a2288d3..b009b93 100644 --- a/packages/browser-runtime/src/tools/screenshot.ts +++ b/packages/browser-runtime/src/tools/screenshot.ts @@ -3,11 +3,11 @@ import { z } from "zod"; import { cacheScreenshotMetadata } from "../automation/computer"; import { RuntimeScreenshotStorage } from "../lib/screenshot-storage"; import { getAutomationMode } from "../runtime/automation-mode"; -import { getActiveTab } from "./index"; import { captureVisibleTabWithElementCrop, MAX_PADDING, } from "./screenshot-helpers.js"; +import { getActiveTab } from "./tab-utils"; // Re-export the shared helper types/function so existing consumers aren't broken export type { @@ -52,8 +52,16 @@ async function compressImage( export const captureScreenshotTool = tool({ name: "capture_screenshot", - description: - "Capture screenshot of current visible tab and return as base64 data URL. When sendToLLM=true, the screenshot will be sent to the LLM for visual analysis AND visual coordinate tools (computer) will be unlocked for subsequent interactions. NOTE: This tool requires focus mode.", + description: `[HIGH-COST FALLBACK] Capture screenshot of current visible tab. + +TRY search_elements FIRST: For most interactions (clicking, filling, reading), use search_elements + UID-based tools. They are faster and don't send images to LLM. + +USE THIS ONLY WHEN: +- search_elements cannot find the target after 2 query attempts +- You need to see visual layout, images, charts, or canvas content +- The page uses non-standard rendering that snapshots miss + +When sendToLLM=true: Sends image to LLM (higher latency/cost, may capture sensitive on-screen data) and enables the computer tool for coordinate-based actions. NOTE: This tool requires focus mode.`, parameters: z.object({ sendToLLM: z .boolean() @@ -61,7 +69,7 @@ export const captureScreenshotTool = tool({ .optional() .default(false) .describe( - "Whether to send the screenshot to LLM for visual analysis. When true, visual coordinate tools will be enabled.", + "Whether to send the screenshot to LLM for visual analysis. When true, enables computer tool for coordinate actions. Use sparingly - adds latency and token cost.", ), }), execute: async ({ sendToLLM = false }) => { @@ -202,8 +210,16 @@ export const captureScreenshotTool = tool({ export const captureTabScreenshotTool = tool({ name: "capture_tab_screenshot", - description: - "Capture screenshot of a specific tab by ID. When sendToLLM=true, the screenshot will be sent to the LLM for visual analysis AND visual coordinate tools (computer) will be unlocked for subsequent interactions. NOTE: This tool requires focus mode.", + description: `[HIGH-COST FALLBACK] Capture screenshot of a specific tab by ID. + +TRY search_elements FIRST: Visual verification is expensive. Use search_elements + UID-based tools for most interactions. + +USE THIS ONLY WHEN: +- Visual verification is essential +- search_elements failed to find the target +- You need to see images, charts, or canvas content + +When sendToLLM=true: Sends image to LLM (higher latency/cost) and enables coordinate-based actions. NOTE: This tool requires focus mode.`, parameters: z.object({ tabId: z.number().describe("The tab ID to capture"), sendToLLM: z @@ -341,8 +357,9 @@ const MAX_SELECTOR_LENGTH = 500; export const captureScreenshotWithHighlightTool = tool({ name: "capture_screenshot_with_highlight", - description: - "Capture screenshot of the current visible tab, optionally highlighting and cropping to a specific element identified by CSS selector. The screenshot is always sent to the LLM for visual analysis. NOTE: This tool requires focus mode.", + description: `[HIGH-COST] Capture screenshot of the current visible tab, optionally highlighting and cropping to a specific element identified by CSS selector. The screenshot is always sent to the LLM for visual analysis. + +PREFER search_elements for finding/interacting with elements. Use this only when you need to visually verify element appearance or layout. NOTE: This tool requires focus mode.`, parameters: z.object({ selector: z .string() diff --git a/packages/browser-runtime/src/tools/snapshot.ts b/packages/browser-runtime/src/tools/snapshot.ts index fc67b0b..fdc3494 100644 --- a/packages/browser-runtime/src/tools/snapshot.ts +++ b/packages/browser-runtime/src/tools/snapshot.ts @@ -1,7 +1,7 @@ import { tool } from "@aipexstudio/aipex-core"; import { z } from "zod"; import * as snapshotProvider from "../automation/snapshot-provider"; -import { getActiveTab } from "./index"; +import { getActiveTab } from "./tab-utils"; export const takeSnapshotTool = tool({ name: "take_snapshot", diff --git a/packages/browser-runtime/src/tools/tab-utils.ts b/packages/browser-runtime/src/tools/tab-utils.ts new file mode 100644 index 0000000..5679dcd --- /dev/null +++ b/packages/browser-runtime/src/tools/tab-utils.ts @@ -0,0 +1,44 @@ +/** + * Get the currently active tab + * @throws Error if no active tab is found + */ +export async function getActiveTab(): Promise { + const [tab] = await chrome.tabs.query({ + active: true, + currentWindow: true, + }); + + if (!tab?.id) { + throw new Error("No active tab found"); + } + + return tab; +} + +/** + * Execute a script in a specific tab + */ +export async function executeScriptInTab( + tabId: number, + func: (...args: Args) => T, + args: Args, +): Promise { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func, + args, + }); + + return results[0]?.result as T; +} + +/** + * Execute a script in the active tab + */ +export async function executeScriptInActiveTab( + func: (...args: Args) => T, + args: Args, +): Promise { + const tab = await getActiveTab(); + return await executeScriptInTab(tab.id!, func, args); +} diff --git a/packages/browser-runtime/src/tools/tab.ts b/packages/browser-runtime/src/tools/tab.ts index 42db061..8431b98 100644 --- a/packages/browser-runtime/src/tools/tab.ts +++ b/packages/browser-runtime/src/tools/tab.ts @@ -1,7 +1,7 @@ import { tool } from "@aipexstudio/aipex-core"; import { z } from "zod"; import { getAutomationMode } from "../runtime/automation-mode"; -import { getActiveTab } from "./index"; +import { getActiveTab } from "./tab-utils"; /** * Get all open tabs across all windows diff --git a/packages/browser-runtime/src/ws-bridge/index.ts b/packages/browser-runtime/src/ws-bridge/index.ts new file mode 100644 index 0000000..025a6f2 --- /dev/null +++ b/packages/browser-runtime/src/ws-bridge/index.ts @@ -0,0 +1,13 @@ +export { + type ConnectionStatus, + WsMcpServer, + type WsMcpServerState, + wsMcpServer, +} from "./ws-mcp-server.js"; +export { + type JSONRPCMessage, + type JSONRPCNotification, + type JSONRPCRequest, + type JSONRPCResponse, + WebSocketClientTransport, +} from "./ws-transport.js"; diff --git a/packages/browser-runtime/src/ws-bridge/ws-mcp-server.ts b/packages/browser-runtime/src/ws-bridge/ws-mcp-server.ts new file mode 100644 index 0000000..4413964 --- /dev/null +++ b/packages/browser-runtime/src/ws-bridge/ws-mcp-server.ts @@ -0,0 +1,463 @@ +/** + * WebSocket command executor for the AIPex extension. + * + * Connects as a WebSocket client to the aipex-mcp-bridge and listens + * for tool execution commands. The bridge is the true MCP server — this + * class simply executes tools and returns results. + * + * Handles: + * - tools/call -- executes a tool from allBrowserTools + * - ping -- responds with {} for keepalive + * + * No MCP protocol negotiation (initialize, tools/list) is needed on this + * side — the bridge handles that entirely with static tool schemas. + */ + +import type { FunctionTool } from "@aipexstudio/aipex-core"; +import { allBrowserTools } from "../tools/index.js"; +import { + type JSONRPCMessage, + type JSONRPCRequest, + WebSocketClientTransport, +} from "./ws-transport.js"; + +export type ConnectionStatus = + | "disconnected" + | "connecting" + | "connected" + | "error"; + +export interface WsMcpServerState { + status: ConnectionStatus; + url: string | null; + error: string | null; + connectedAt: number | null; + reconnectAttempt: number; +} + +type StatusListener = (state: WsMcpServerState) => void; + +const KEEPALIVE_ALARM_NAME = "ws-mcp-keepalive"; +const KEEPALIVE_INTERVAL_MINUTES = 0.4; +const TOOL_CALL_TIMEOUT_MS = 60_000; + +const STORAGE_KEY_WS_URL = "ws-mcp-url"; + +function getReconnectDelayMs(attempt: number): number { + const withJitter = (base: number) => + Math.round(base * (0.7 + Math.random() * 0.6)); + return withJitter(Math.min(500 * 2 ** attempt, 10_000)); +} + +/** + * Find a tool by name from the registered browser tools. + */ +function findBrowserTool(name: string): FunctionTool | undefined { + return allBrowserTools.find((t) => t.name === name); +} + +/** + * Format a tool execution result into MCP content blocks. + */ +function buildMcpContent( + data: unknown, +): Array<{ type: string; text?: string; data?: string; mimeType?: string }> { + if (data === null || data === undefined) { + return [{ type: "text", text: "null" }]; + } + + if (typeof data === "string") { + // Check if it's a base64 data URL (screenshot) + if (data.startsWith("data:image/")) { + const commaIndex = data.indexOf(","); + if (commaIndex > 0) { + const mimeType = data.slice(5, data.indexOf(";")); + const base64Data = data.slice(commaIndex + 1); + return [{ type: "image", data: base64Data, mimeType }]; + } + } + return [{ type: "text", text: data }]; + } + + return [{ type: "text", text: JSON.stringify(data, null, 2) }]; +} + +export class WsMcpServer { + private transport: WebSocketClientTransport | null = null; + private state: WsMcpServerState = { + status: "disconnected", + url: null, + error: null, + connectedAt: null, + reconnectAttempt: 0, + }; + private listeners: Set = new Set(); + private reconnectTimer: ReturnType | null = null; + private autoReconnectEnabled = true; + + private validateUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`Invalid WebSocket URL: ${url}`); + } + + const hostname = parsed.hostname; + const allowedHosts = ["localhost", "127.0.0.1", "[::1]", "::1"]; + if (!allowedHosts.includes(hostname)) { + throw new Error( + `Only localhost connections are allowed. Got: ${hostname}`, + ); + } + + if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { + throw new Error( + `URL must use ws:// or wss:// protocol. Got: ${parsed.protocol}`, + ); + } + } + + async connect(url: string): Promise { + this.validateUrl(url); + this.cancelReconnect(); + + if ( + this.state.status === "connected" || + this.state.status === "connecting" + ) { + await this.disconnect(); + } + + this.updateState({ + status: "connecting", + url, + error: null, + connectedAt: null, + }); + + try { + const transport = new WebSocketClientTransport(url); + this.transport = transport; + + transport.onclose = () => { + this.handleDisconnect(); + }; + + transport.onerror = (error: Error) => { + console.error("[WsMcpServer] Transport error:", error.message); + }; + + transport.onmessage = (message: JSONRPCMessage) => { + this.handleMessage(message); + }; + + await transport.start(); + this.updateState({ + status: "connected", + connectedAt: Date.now(), + reconnectAttempt: 0, + }); + this.startKeepalive(); + this.persistUrl(url); + this.autoReconnectEnabled = true; + console.log(`[WsMcpServer] Connected to ${url}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.updateState({ status: "error", error: message, connectedAt: null }); + this.transport = null; + this.scheduleReconnect(url); + throw error; + } + } + + async disconnect(): Promise { + this.autoReconnectEnabled = false; + this.cancelReconnect(); + this.stopKeepalive(); + + if (this.transport) { + try { + await this.transport.close(); + } catch { + // ignore close errors + } + this.transport = null; + } + + this.updateState({ + status: "disconnected", + url: null, + error: null, + connectedAt: null, + reconnectAttempt: 0, + }); + this.clearPersistedUrl(); + console.log("[WsMcpServer] Disconnected"); + } + + isConnected(): boolean { + return this.state.status === "connected"; + } + + getStatus(): WsMcpServerState { + return { ...this.state }; + } + + onStatusChange(listener: StatusListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async getSavedUrl(): Promise { + try { + const result = await chrome.storage.local.get(STORAGE_KEY_WS_URL); + return (result[STORAGE_KEY_WS_URL] as string) || null; + } catch { + return null; + } + } + + // -- Auto-reconnect with exponential backoff -- + + private scheduleReconnect(url: string): void { + if (!this.autoReconnectEnabled) return; + this.cancelReconnect(); + + const attempt = this.state.reconnectAttempt; + const delay = getReconnectDelayMs(attempt); + console.log( + `[WsMcpServer] Scheduling reconnect attempt ${attempt + 1} in ${delay}ms`, + ); + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.updateState({ reconnectAttempt: attempt + 1 }); + this.connect(url).catch(() => { + // connect() itself schedules next retry on failure + }); + }, delay); + } + + private cancelReconnect(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + private persistUrl(url: string): void { + try { + chrome.storage.local.set({ [STORAGE_KEY_WS_URL]: url }); + } catch { + // ignore storage errors + } + } + + private clearPersistedUrl(): void { + try { + chrome.storage.local.remove(STORAGE_KEY_WS_URL); + } catch { + // ignore + } + } + + // -- Message handling -- + + private handleMessage(message: JSONRPCMessage): void { + if (!("method" in message) || !("id" in message)) { + return; + } + const request = message as JSONRPCRequest; + this.handleRequest(request).catch((error) => { + console.error("[WsMcpServer] Unhandled error processing request:", error); + this.sendError(request.id, -32603, "Internal error"); + }); + } + + private async handleRequest(request: JSONRPCRequest): Promise { + switch (request.method) { + case "tools/call": + return this.handleToolsCall(request); + case "ping": + return this.sendResult(request.id, {}); + default: + return this.sendError( + request.id, + -32601, + `Method not found: ${request.method}`, + ); + } + } + + private async handleToolsCall(request: JSONRPCRequest): Promise { + const params = (request.params || {}) as Record; + const name = params.name as string | undefined; + const args = (params.arguments || {}) as Record; + + if (!name) { + return this.sendError( + request.id, + -32602, + "Missing required parameter: name", + ); + } + + try { + const toolExecution = this.executeTool(name, args); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => + reject( + new Error( + `Tool '${name}' timed out after ${TOOL_CALL_TIMEOUT_MS}ms`, + ), + ), + TOOL_CALL_TIMEOUT_MS, + ); + }); + + const result = await Promise.race([toolExecution, timeoutPromise]); + + await this.sendResult(request.id, { + content: buildMcpContent(result), + }); + } catch (error) { + await this.sendResult(request.id, { + content: [ + { + type: "text", + text: `Error: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }); + } + } + + private async executeTool( + name: string, + args: Record, + ): Promise { + const browserTool = findBrowserTool(name); + if (!browserTool) { + throw new Error(`Tool not found: ${name}`); + } + + // FunctionTool.invoke(runContext, inputJsonString, details?) + // We pass an empty object as RunContext since we're outside the agent loop. + return await (browserTool as any).invoke({} as any, JSON.stringify(args)); + } + + private async sendResult( + id: string | number, + result: unknown, + ): Promise { + if (!this.transport) return; + await this.transport.send({ + jsonrpc: "2.0", + id, + result, + } as JSONRPCMessage); + } + + private async sendError( + id: string | number, + code: number, + message: string, + ): Promise { + if (!this.transport) return; + await this.transport.send({ + jsonrpc: "2.0", + id, + error: { code, message }, + } as JSONRPCMessage); + } + + // -- Connection lifecycle -- + + private handleDisconnect(): void { + this.stopKeepalive(); + const lastUrl = this.state.url; + this.transport = null; + if (this.state.status !== "disconnected") { + this.updateState({ + status: "disconnected", + error: null, + connectedAt: null, + }); + console.log("[WsMcpServer] Connection closed by remote"); + if (lastUrl && this.autoReconnectEnabled) { + this.scheduleReconnect(lastUrl); + } + } + } + + private updateState(partial: Partial): void { + this.state = { ...this.state, ...partial }; + for (const listener of this.listeners) { + try { + listener(this.getStatus()); + } catch { + // don't let listener errors break us + } + } + } + + // -- Service Worker Keepalive -- + + private startKeepalive(): void { + try { + chrome.alarms.create(KEEPALIVE_ALARM_NAME, { + periodInMinutes: KEEPALIVE_INTERVAL_MINUTES, + }); + } catch (error) { + console.warn("[WsMcpServer] Failed to create keepalive alarm:", error); + } + } + + private stopKeepalive(): void { + try { + chrome.alarms.clear(KEEPALIVE_ALARM_NAME); + } catch { + // ignore + } + } + + handleAlarm(alarm: chrome.alarms.Alarm): void { + if (alarm.name !== KEEPALIVE_ALARM_NAME) return; + + if (this.state.status === "connected" && this.transport?.isOpen) { + this.sendPing(); + return; + } + + if (this.state.status === "connecting") { + return; + } + + const url = this.state.url; + if (url && this.autoReconnectEnabled) { + this.connect(url).catch(() => { + // connect() schedules its own retry on failure + }); + } + } + + private async sendPing(): Promise { + if (!this.transport?.isOpen) return; + try { + await this.transport.send({ + jsonrpc: "2.0", + id: `ping-${Date.now()}`, + method: "ping", + } as JSONRPCMessage); + } catch { + console.warn("[WsMcpServer] Ping send failed, closing connection"); + this.handleDisconnect(); + } + } +} + +export const wsMcpServer = new WsMcpServer(); diff --git a/packages/browser-runtime/src/ws-bridge/ws-transport.ts b/packages/browser-runtime/src/ws-bridge/ws-transport.ts new file mode 100644 index 0000000..3c21844 --- /dev/null +++ b/packages/browser-runtime/src/ws-bridge/ws-transport.ts @@ -0,0 +1,129 @@ +/** + * WebSocket client transport for MCP-over-WebSocket. + * + * Uses the browser-native WebSocket API. AIPex connects as a WebSocket + * client to the host agent's WebSocket server, then JSON-RPC 2.0 messages + * flow bidirectionally over that connection. + * + * No dependency on @modelcontextprotocol/sdk to avoid pulling ajv + * (which uses `new Function()`) into the MV3 service worker bundle. + */ + +export interface JSONRPCRequest { + jsonrpc: "2.0"; + id: string | number; + method: string; + params?: Record; +} + +export interface JSONRPCResponse { + jsonrpc: "2.0"; + id: string | number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +export interface JSONRPCNotification { + jsonrpc: "2.0"; + method: string; + params?: Record; +} + +export type JSONRPCMessage = + | JSONRPCRequest + | JSONRPCResponse + | JSONRPCNotification; + +function isValidJsonRpcMessage(data: unknown): data is JSONRPCMessage { + if (typeof data !== "object" || data === null) return false; + const msg = data as Record; + return msg.jsonrpc === "2.0"; +} + +const WS_CLOSE_NORMAL = 1000; + +export class WebSocketClientTransport { + private socket: WebSocket | null = null; + private url: string; + private closeFired = false; + + onmessage?: (message: JSONRPCMessage) => void; + onerror?: (error: Error) => void; + onclose?: () => void; + + constructor(url: string) { + this.url = url; + } + + start(): Promise { + if (this.socket) { + throw new Error("WebSocketClientTransport already started"); + } + + this.closeFired = false; + + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.url); + this.socket = ws; + + ws.onopen = () => { + resolve(); + }; + + ws.onerror = () => { + const error = new Error("WebSocket error"); + reject(error); + this.onerror?.(error); + }; + + ws.onclose = () => { + this.socket = null; + if (!this.closeFired) { + this.closeFired = true; + this.onclose?.(); + } + }; + + ws.onmessage = (event: MessageEvent) => { + let parsed: unknown; + try { + parsed = JSON.parse(String(event.data)); + } catch (error) { + this.onerror?.( + error instanceof Error ? error : new Error(String(error)), + ); + return; + } + if (!isValidJsonRpcMessage(parsed)) { + this.onerror?.(new Error("Received non-JSON-RPC message")); + return; + } + this.onmessage?.(parsed); + }; + }); + } + + async send(message: JSONRPCMessage): Promise { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error("WebSocket not connected"); + } + this.socket.send(JSON.stringify(message)); + } + + async close(): Promise { + const ws = this.socket; + if (!ws) return; + + this.socket = null; + if ( + ws.readyState === WebSocket.OPEN || + ws.readyState === WebSocket.CONNECTING + ) { + ws.close(WS_CLOSE_NORMAL); + } + } + + get isOpen(): boolean { + return this.socket?.readyState === WebSocket.OPEN; + } +} diff --git a/packages/core/src/config/ai-providers.ts b/packages/core/src/config/ai-providers.ts index 3a4172a..b064948 100644 --- a/packages/core/src/config/ai-providers.ts +++ b/packages/core/src/config/ai-providers.ts @@ -1,4 +1,4 @@ -import type { ProviderType } from "./settings.js"; +import type { ProviderType } from "./types.js"; export interface AIProviderConfig { name: string; diff --git a/packages/core/src/config/settings.ts b/packages/core/src/config/settings.ts index 83cfa52..7790ad7 100644 --- a/packages/core/src/config/settings.ts +++ b/packages/core/src/config/settings.ts @@ -1,6 +1,7 @@ import type { AIProviderKey } from "./ai-providers.js"; +import type { ProviderType } from "./types.js"; -export type ProviderType = "google" | "openai" | "claude"; +export type { ProviderType }; export interface CustomModelConfig { id: string; diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts new file mode 100644 index 0000000..f2158de --- /dev/null +++ b/packages/core/src/config/types.ts @@ -0,0 +1 @@ +export type ProviderType = "google" | "openai" | "claude"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8bde618..525a9f9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4,10 +4,7 @@ import type { Agent as OpenAIAgent, } from "@openai/agents"; import type { AiSdkModel } from "@openai/agents-extensions"; -import type { AIPex } from "./agent/aipex.js"; import type { Context, ContextManager } from "./context/index.js"; -import type { ConversationManager } from "./conversation/manager.js"; -import type { Session } from "./conversation/session.js"; import type { AgentError } from "./utils/errors.js"; // Re-export types from @openai/agents for convenient access @@ -73,7 +70,7 @@ export interface AIPexOptions< * Fully custom ConversationManager instance. * When provided, storage and compression options are ignored. */ - conversationManager?: ConversationManager; + conversationManager?: import("./conversation/manager.js").ConversationManager; /** * Context manager for providing additional context to the agent. @@ -161,7 +158,7 @@ export interface MetricsPayload { } export interface AgentPluginContext { - agent: AIPex; + agent: import("./agent/aipex.js").AIPex; } export interface AgentPluginHooks { @@ -240,8 +237,8 @@ export interface SessionTree { } export interface SessionStorageAdapter { - save(session: Session): Promise; - load(id: string): Promise; + save(session: import("./conversation/session.js").Session): Promise; + load(id: string): Promise; delete(id: string): Promise; listAll(): Promise; getSessionTree(rootId?: string): Promise;