+ {/* User Avatar Button */}
+
+
+ {/* Dropdown Menu */}
+ {showDropdown && (
+
+ {/* User Info */}
+
+
+ {user.name || user.email.split("@")[0]}
+
+
+ {user.email}
+
+
+
+ {/* Account Button */}
+
+
+ {/* Logout Button */}
+
+
+ )}
+
+ {/* Click outside to close dropdown */}
+ {showDropdown && (
+
setShowDropdown(false)}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ setShowDropdown(false);
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ aria-label="Close dropdown"
+ />
+ )}
+
+ );
+};
+
+export default UserProfile;
diff --git a/packages/browser-ext/src/auth/index.ts b/packages/browser-ext/src/auth/index.ts
new file mode 100644
index 0000000..fd70852
--- /dev/null
+++ b/packages/browser-ext/src/auth/index.ts
@@ -0,0 +1,7 @@
+/**
+ * Auth module exports
+ */
+
+export type { AuthContextType, User } from "./AuthProvider";
+export { AuthProvider, useAuth } from "./AuthProvider";
+export { UserProfile } from "./UserProfile";
diff --git a/packages/browser-ext/src/lib/automation-mode-toolbar.tsx b/packages/browser-ext/src/lib/automation-mode-toolbar.tsx
index 26aad09..0b96c37 100644
--- a/packages/browser-ext/src/lib/automation-mode-toolbar.tsx
+++ b/packages/browser-ext/src/lib/automation-mode-toolbar.tsx
@@ -11,6 +11,7 @@ import {
validateAutomationMode,
} from "@aipexstudio/aipex-core";
import type { InputToolbarSlotProps } from "@aipexstudio/aipex-react";
+import { TokenUsageIndicator } from "@aipexstudio/aipex-react/components/chatbot";
import { Button } from "@aipexstudio/aipex-react/components/ui/button";
import {
DropdownMenu,
@@ -78,6 +79,9 @@ export function AutomationModeInputToolbar({
return (
+ {/* Token Usage Indicator - compact mode next to automation mode */}
+
+
{/* Automation Mode Selector */}
diff --git a/packages/browser-ext/src/lib/browser-agent-config.ts b/packages/browser-ext/src/lib/browser-agent-config.ts
index 36f652d..14f31c4 100644
--- a/packages/browser-ext/src/lib/browser-agent-config.ts
+++ b/packages/browser-ext/src/lib/browser-agent-config.ts
@@ -110,5 +110,5 @@ export function useBrowserTools(): FunctionTool[] {
export const BROWSER_AGENT_CONFIG = {
instructions: SYSTEM_PROMPT,
name: "AIPex Browser Assistant",
- maxTurns: 10,
+ maxTurns: 2000,
} as const;
diff --git a/packages/browser-ext/src/lib/browser-chat-header.tsx b/packages/browser-ext/src/lib/browser-chat-header.tsx
index 46ff66e..5deb85c 100644
--- a/packages/browser-ext/src/lib/browser-chat-header.tsx
+++ b/packages/browser-ext/src/lib/browser-chat-header.tsx
@@ -1,6 +1,6 @@
/**
* BrowserChatHeader
- * Custom header with conversation persistence, history dropdown, intervention toggle
+ * Custom header with conversation persistence and history dropdown
*/
import { useChatContext } from "@aipexstudio/aipex-react/components/chatbot";
@@ -12,9 +12,8 @@ import type { HeaderProps } from "@aipexstudio/aipex-react/types";
import { conversationStorage } from "@aipexstudio/browser-runtime";
import { PlusIcon, SettingsIcon } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
+import { UserProfile, useAuth } from "../auth";
import { ConversationHistory } from "./conversation-history";
-import { useInterventionMode } from "./intervention-mode-context";
-import { InterventionModeToggleHeader } from "./intervention-ui";
import { fromStorageFormat, toStorageFormat } from "./message-adapter";
export function BrowserChatHeader({
@@ -28,7 +27,7 @@ export function BrowserChatHeader({
const { t } = useTranslation();
const runtime = getRuntime();
const { messages, setMessages, interrupt } = useChatContext();
- const { mode, setMode } = useInterventionMode();
+ const { user, login, isLoading: isAuthLoading } = useAuth();
const [currentConversationId, setCurrentConversationId] = useState<
string | undefined
@@ -146,26 +145,35 @@ export function BrowserChatHeader({
{t("common.settings")}
- {/* Center - Intervention toggle and History */}
-
-
-
-
+ {/* Center - History */}
+
- {/* Right side - New Chat */}
-
+ {/* Right side - New Chat and User Profile */}
+
+
+
+ {/* User Profile or Login Button */}
+ {!isAuthLoading &&
+ (user ? (
+
+ ) : (
+
+ ))}
+
{children}
diff --git a/packages/browser-ext/src/lib/intervention-ui.tsx b/packages/browser-ext/src/lib/intervention-ui.tsx
index 8ff7edf..0ec280d 100644
--- a/packages/browser-ext/src/lib/intervention-ui.tsx
+++ b/packages/browser-ext/src/lib/intervention-ui.tsx
@@ -65,8 +65,27 @@ export function InterventionUI({ mode }: InterventionUIProps) {
setTimeout(() => setCurrentIntervention(null), 3000);
};
- const handleInterventionCancel = () => {
- setCurrentIntervention(null);
+ const handleInterventionCancel = (event: InterventionEvent) => {
+ // Update state to show the cancellation reason instead of immediately hiding
+ const current = interventionManager.getCurrentIntervention();
+ if (current) {
+ setCurrentIntervention(current);
+ } else if (
+ event.data &&
+ typeof event.data === "object" &&
+ "result" in event.data
+ ) {
+ // If we don't have current intervention but have result data,
+ // log the cancel reason for debugging
+ const result = (event.data as { result?: { error?: string } }).result;
+ if (result?.error) {
+ console.log(
+ `[InterventionUI] Intervention cancelled: ${result.error}`,
+ );
+ }
+ }
+ // Keep visible briefly so user can see the cancellation, then hide
+ setTimeout(() => setCurrentIntervention(null), 2000);
};
const handleInterventionTimeout = () => {
diff --git a/packages/browser-ext/src/lib/message-adapter.ts b/packages/browser-ext/src/lib/message-adapter.ts
index f479f65..558266c 100644
--- a/packages/browser-ext/src/lib/message-adapter.ts
+++ b/packages/browser-ext/src/lib/message-adapter.ts
@@ -29,10 +29,15 @@ export function toStorageFormat(
case "tool":
// Map tool to tool_use or tool_result based on state
if (part.output !== undefined) {
+ // Avoid double-stringifying if output is already a string
+ const content =
+ typeof part.output === "string"
+ ? part.output
+ : JSON.stringify(part.output);
return {
type: "tool_result",
tool_use_id: part.toolCallId,
- content: JSON.stringify(part.output),
+ content,
is_error: part.state === "error",
};
}
@@ -56,15 +61,79 @@ export function toStorageFormat(
}
/**
- * Convert runtime UIMessage back to aipex-react UIMessage for display
+ * Safely parse a JSON string, returning undefined on failure
+ */
+function safeJsonParse
(value: unknown): T | undefined {
+ if (typeof value !== "string") {
+ return undefined;
+ }
+ try {
+ return JSON.parse(value) as T;
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Check if a tool result indicates a business-level failure.
+ * Many tools return { success: false, error: "..." } instead of throwing.
+ */
+function extractBusinessFailure(
+ result: unknown,
+): { errorMessage: string } | null {
+ if (result === null || result === undefined) {
+ return null;
+ }
+
+ if (typeof result !== "object") {
+ return null;
+ }
+
+ const obj = result as Record;
+
+ // Check for common failure patterns: { success: false, error: ... }
+ if (obj.success === false) {
+ // Extract error message
+ if (typeof obj.error === "string" && obj.error.length > 0) {
+ return { errorMessage: obj.error };
+ }
+ if (typeof obj.message === "string" && obj.message.length > 0) {
+ return { errorMessage: obj.message };
+ }
+ // Generic failure message
+ return { errorMessage: "Operation failed" };
+ }
+
+ return null;
+}
+
+/**
+ * Convert runtime UIMessage back to aipex-react UIMessage for display.
+ * This function:
+ * - Correlates tool_use and tool_result parts by id to restore proper toolName and input
+ * - Parses JSON-stringified tool content
+ * - Detects {success: false, error} patterns and sets state/errorText accordingly
*/
export function fromStorageFormat(
messages: RuntimeUIMessage[],
): ReactUIMessage[] {
- return messages.map((msg) => ({
- id: msg.id,
- role: msg.role as ReactUIMessage["role"],
- parts: msg.parts.map((part) => {
+ return messages.map((msg) => {
+ // First pass: build a map of tool_use parts by their ID
+ const toolUseMap = new Map<
+ string,
+ { name: string; input: Record }
+ >();
+ for (const part of msg.parts) {
+ if (part.type === "tool_use") {
+ toolUseMap.set(part.id, {
+ name: part.name,
+ input: part.input,
+ });
+ }
+ }
+
+ // Second pass: convert parts with proper correlation
+ const convertedParts = msg.parts.map((part) => {
switch (part.type) {
case "text":
return { type: "text", text: part.text };
@@ -77,26 +146,126 @@ export function fromStorageFormat(
url: part.imageData,
};
case "tool_use":
+ // We'll merge this with tool_result if both exist,
+ // but if no result, show as executing/pending
return {
type: "tool",
toolName: part.name,
toolCallId: part.id,
input: part.input,
- state: "completed" as const,
+ state: "pending" as const,
};
- case "tool_result":
+ case "tool_result": {
+ // Correlate with tool_use to get proper toolName and input
+ const toolUseInfo = toolUseMap.get(part.tool_use_id);
+ const toolName = toolUseInfo?.name ?? "unknown";
+ const input = toolUseInfo?.input ?? {};
+
+ // Parse the content - it may be JSON-stringified
+ let parsedOutput: unknown = part.content;
+ const parsed = safeJsonParse(part.content);
+ if (parsed !== undefined) {
+ parsedOutput = parsed;
+ }
+
+ // Check for is_error flag first
+ if (part.is_error) {
+ // Extract error message from the parsed output if possible
+ let errorText = "Tool execution failed";
+ if (typeof parsedOutput === "string" && parsedOutput.length > 0) {
+ errorText = parsedOutput;
+ } else if (
+ typeof parsedOutput === "object" &&
+ parsedOutput !== null
+ ) {
+ const obj = parsedOutput as Record;
+ if (typeof obj.error === "string") {
+ errorText = obj.error;
+ } else if (typeof obj.message === "string") {
+ errorText = obj.message;
+ }
+ }
+ return {
+ type: "tool",
+ toolName,
+ toolCallId: part.tool_use_id,
+ input,
+ output: parsedOutput,
+ state: "error" as const,
+ errorText,
+ };
+ }
+
+ // Check for business-level failure ({success: false, error: ...})
+ const failureInfo = extractBusinessFailure(parsedOutput);
+ if (failureInfo) {
+ return {
+ type: "tool",
+ toolName,
+ toolCallId: part.tool_use_id,
+ input,
+ output: parsedOutput,
+ state: "error" as const,
+ errorText: failureInfo.errorMessage,
+ };
+ }
+
+ // Normal successful completion
return {
type: "tool",
- toolName: "unknown",
+ toolName,
toolCallId: part.tool_use_id,
- input: {},
- output: part.content,
- state: part.is_error ? ("error" as const) : ("completed" as const),
+ input,
+ output: parsedOutput,
+ state: "completed" as const,
};
+ }
default:
return { type: "text", text: "[unknown]" };
}
- }),
- timestamp: msg.timestamp,
- })) as ReactUIMessage[];
+ });
+
+ // Third pass: merge tool_use with tool_result if both exist for the same call
+ // This avoids showing duplicate tool parts
+ const mergedParts: (typeof convertedParts)[number][] = [];
+ const processedToolCallIds = new Set();
+
+ for (const part of convertedParts) {
+ if (part.type === "tool") {
+ const toolCallId = part.toolCallId;
+ // Skip if toolCallId is missing or we've already processed this tool call
+ if (!toolCallId || processedToolCallIds.has(toolCallId)) {
+ continue;
+ }
+
+ // Find if there's a corresponding result for this tool call
+ const resultPart = convertedParts.find(
+ (p) =>
+ p.type === "tool" &&
+ p.toolCallId === toolCallId &&
+ p.state !== "pending" &&
+ p !== part,
+ );
+
+ if (resultPart && resultPart.type === "tool") {
+ // Use the result part (which has the full info)
+ mergedParts.push(resultPart);
+ } else {
+ // No result, use the original part
+ mergedParts.push(part);
+ }
+
+ processedToolCallIds.add(toolCallId);
+ } else {
+ mergedParts.push(part);
+ }
+ }
+
+ return {
+ id: msg.id,
+ role: msg.role as ReactUIMessage["role"],
+ parts: mergedParts,
+ timestamp: msg.timestamp,
+ };
+ }) as ReactUIMessage[];
}
diff --git a/packages/browser-ext/src/pages/common/app-root.tsx b/packages/browser-ext/src/pages/common/app-root.tsx
index 1b6d784..5a0bc0c 100644
--- a/packages/browser-ext/src/pages/common/app-root.tsx
+++ b/packages/browser-ext/src/pages/common/app-root.tsx
@@ -13,6 +13,7 @@ import type { Theme } from "@aipexstudio/aipex-react/theme/types";
import { ChromeStorageAdapter } from "@aipexstudio/browser-runtime";
import React, { useState } from "react";
import ReactDOM from "react-dom/client";
+import { AuthProvider } from "../../auth";
import { chromeStorageAdapter } from "../../hooks";
import { AutomationModeInputToolbar } from "../../lib/automation-mode-toolbar";
import {
@@ -97,7 +98,9 @@ export function renderChatApp() {
const App = () => (
-
+
+
+
);
diff --git a/packages/browser-ext/src/services/web-auth.ts b/packages/browser-ext/src/services/web-auth.ts
new file mode 100644
index 0000000..387f277
--- /dev/null
+++ b/packages/browser-ext/src/services/web-auth.ts
@@ -0,0 +1,63 @@
+/**
+ * Public website configuration and authentication cookie utilities
+ */
+
+export const WEBSITE_URL = "https://www.claudechrome.com";
+
+/**
+ * Aggregate claudechrome website authentication cookies and generate Cookie header content.
+ * Note: Returns only presence indication, not actual cookie values in logs (security).
+ */
+export async function getAuthCookieHeader(): Promise {
+ try {
+ const cookies = await chrome.cookies.getAll({ url: WEBSITE_URL });
+
+ const relevantCookies = cookies.filter(
+ (cookie) =>
+ cookie.name.includes("better-auth") || cookie.name.includes("session"),
+ );
+
+ if (!relevantCookies.length) {
+ console.log("[web-auth] No auth cookies found");
+ return undefined;
+ }
+
+ // Log only cookie presence, not values
+ console.log("[web-auth] Found auth cookies:", relevantCookies.length);
+
+ return relevantCookies
+ .map((cookie) => `${cookie.name}=${cookie.value}`)
+ .join("; ");
+ } catch (_error) {
+ console.warn("[web-auth] Failed to get cookies");
+ return undefined;
+ }
+}
+
+/**
+ * Check if user has authentication cookies (without reading values)
+ */
+export async function hasAuthCookies(): Promise {
+ try {
+ const cookies = await chrome.cookies.getAll({ url: WEBSITE_URL });
+ return cookies.some(
+ (cookie) =>
+ cookie.name.includes("better-auth") || cookie.name.includes("session"),
+ );
+ } catch (_error) {
+ console.warn("[web-auth] Failed to check cookies");
+ return false;
+ }
+}
+
+/**
+ * List of known auth cookie names
+ */
+export const AUTH_COOKIE_NAMES = [
+ "__Secure-next-auth.session-token",
+ "next-auth.session-token",
+ "__Secure-better-auth.session_token",
+ "better-auth.session_token",
+ "__Secure-better-auth.session_data",
+ "better-auth.session_data",
+];
diff --git a/packages/browser-ext/vite.config.ts b/packages/browser-ext/vite.config.ts
index 61ba59c..d125d8e 100644
--- a/packages/browser-ext/vite.config.ts
+++ b/packages/browser-ext/vite.config.ts
@@ -20,6 +20,28 @@ export default defineConfig({
src: "host-access-config.json",
dest: ".",
},
+ // VAD assets for voice mode
+ {
+ src: "node_modules/@ricky0123/vad-web/dist/vad.worklet.bundle.min.js",
+ dest: "assets/vad",
+ },
+ {
+ src: "node_modules/@ricky0123/vad-web/dist/silero_vad_legacy.onnx",
+ dest: "assets/vad",
+ },
+ {
+ src: "node_modules/@ricky0123/vad-web/dist/silero_vad_v5.onnx",
+ dest: "assets/vad",
+ },
+ // ONNX runtime WASM files for VAD
+ {
+ src: "node_modules/onnxruntime-web/dist/*.wasm",
+ dest: "assets/onnx",
+ },
+ {
+ src: "node_modules/onnxruntime-web/dist/*.mjs",
+ dest: "assets/onnx",
+ },
],
}),
],
diff --git a/packages/browser-runtime/README.md b/packages/browser-runtime/README.md
index 0934092..ed58dc7 100644
--- a/packages/browser-runtime/README.md
+++ b/packages/browser-runtime/README.md
@@ -23,26 +23,48 @@ AIPex is split into layers so each stays focused:
## Features
-### 1) `allBrowserTools` (31 tools)
+### 1) `allBrowserTools` (32 tools)
`allBrowserTools` is a curated bundle of `FunctionTool`s that an agent can call.
It includes:
-- **Tab management**: list/switch/open/duplicate/close, basic grouping helpers
-- **UI operations**: locate elements, click, hover, fill inputs/forms, wait
+- **Tab management**: list/open/close, basic grouping helpers
+- **UI operations**: locate elements, click, hover, fill inputs/forms, computer tool
- **Page content**: metadata, scrolling, highlighting
-- **Screenshots**: capture to data URL / clipboard
-- **Downloads**: save text/images from the agent workflow
+- **Screenshots**: capture to data URL
+- **Downloads**: save images from the agent workflow
- **Human-in-the-loop interventions**: request/cancel interventions
+- **Skills**: load/execute skill scripts
Tool names included (strings used for tool-calling):
-- Tabs: `get_all_tabs`, `get_current_tab`, `switch_to_tab`, `create_new_tab`, `get_tab_info`, `duplicate_tab`, `close_tab`, `organize_tabs`, `ungroup_tabs`
-- UI ops: `search_elements`, `click`, `fill_element_by_uid`, `get_editor_value`, `fill_form`, `hover_element_by_uid`, `wait`
-- Page: `get_page_metadata`, `scroll_to_element`, `highlight_element`, `highlight_text_inline`
-- Screenshot: `capture_screenshot`, `capture_tab_screenshot`, `capture_screenshot_to_clipboard`
-- Download: `download_text_as_markdown`, `download_image`, `download_chat_images`, `download_current_chat_images`
-- Interventions: `list_interventions`, `get_intervention_info`, `request_intervention`, `cancel_intervention`
+- Tabs (7): `get_all_tabs`, `get_current_tab`, `create_new_tab`, `get_tab_info`, `close_tab`, `organize_tabs`, `ungroup_tabs`
+- UI ops (7): `search_elements`, `click`, `fill_element_by_uid`, `get_editor_value`, `fill_form`, `hover_element_by_uid`, `computer`
+- Page (4): `get_page_metadata`, `scroll_to_element`, `highlight_element`, `highlight_text_inline`
+- Screenshot (2): `capture_screenshot`, `capture_tab_screenshot`
+- Download (2): `download_image`, `download_chat_images`
+- Interventions (4): `list_interventions`, `get_intervention_info`, `request_intervention`, `cancel_intervention`
+- Skills (6): `load_skill`, `execute_skill_script`, and 4 other skill tools
+
+**Disabled tools** (exist in code but not in default bundle):
+
+- `switch_to_tab`: causes context switching issues
+- `duplicate_tab`: not enabled
+- `wait`: deprecated, replaced by `computer` tool's wait action
+- `capture_screenshot_to_clipboard`: not enabled
+- `download_text_as_markdown`: not enabled
+- `download_current_chat_images`: architecture issue
+
+**Available but not registered by default** (can be imported separately):
+
+- Bookmarks: `list_bookmarks`, `search_bookmarks`, `create_bookmark`, `delete_bookmark`, etc. (`tools/bookmark.ts`)
+- History: `get_recent_history`, `search_history`, `delete_history_item`, `clear_history`, etc. (`tools/history.ts`)
+- Clipboard: `copy_to_clipboard`, `read_from_clipboard`, `copy_page_as_markdown`, etc. (`tools/tools/clipboard/`)
+- Window management: `get_all_windows`, `switch_to_window`, `create_new_window`, etc. (`tools/tools/window-management/`)
+- Sessions: `get_all_sessions`, `restore_session`, etc. (`tools/tools/sessions/`)
+- Extensions: `get_all_extensions`, `set_extension_enabled`, etc. (`tools/tools/extensions/`)
+- Context menus: `create_context_menu_item`, etc. (`tools/tools/context-menus/`)
+- Tab groups: `create_tab_group`, `get_all_tab_groups`, etc. (`tools/tools/tab-groups/`)
> Note: `take_snapshot` exists but is intentionally not included in `allBrowserTools` because it is used internally.
diff --git a/packages/browser-runtime/src/intervention/index.ts b/packages/browser-runtime/src/intervention/index.ts
index 36963d8..2b7d1a2 100644
--- a/packages/browser-runtime/src/intervention/index.ts
+++ b/packages/browser-runtime/src/intervention/index.ts
@@ -13,7 +13,10 @@ export {
userSelectionIntervention,
} from "./implementations/user-selection.js";
export { voiceInputIntervention } from "./implementations/voice-input.js";
-export { interventionManager } from "./intervention-manager.js";
+export {
+ type CancelReason,
+ interventionManager,
+} from "./intervention-manager.js";
export { interventionRegistry } from "./intervention-registry.js";
// Types
export type {
diff --git a/packages/browser-runtime/src/intervention/intervention-manager.ts b/packages/browser-runtime/src/intervention/intervention-manager.ts
index aa32bf1..4b9b4b7 100644
--- a/packages/browser-runtime/src/intervention/intervention-manager.ts
+++ b/packages/browser-runtime/src/intervention/intervention-manager.ts
@@ -23,6 +23,34 @@ import type {
type EventListener = (event: InterventionEvent) => void;
+/**
+ * Reasons why an intervention can be cancelled.
+ * Used to provide more informative error messages to users.
+ */
+export type CancelReason =
+ | "user" // User clicked cancel
+ | "tab_switched" // User switched to another tab
+ | "page_navigated" // Page URL changed
+ | "mode_disabled"; // Intervention mode was disabled
+
+/**
+ * Map cancel reasons to human-readable messages
+ */
+function getCancelMessage(reason: CancelReason): string {
+ switch (reason) {
+ case "user":
+ return "Cancelled by user";
+ case "tab_switched":
+ return "Cancelled: browser tab switched";
+ case "page_navigated":
+ return "Cancelled: page navigated to a different URL";
+ case "mode_disabled":
+ return "Cancelled: intervention mode was disabled";
+ default:
+ return "Intervention was cancelled";
+ }
+}
+
export class InterventionManager {
private static instance: InterventionManager;
private currentIntervention: InterventionState | null = null;
@@ -69,7 +97,10 @@ export class InterventionManager {
// If switching to disabled, cancel all ongoing interventions
if (mode === "disabled" && this.currentIntervention) {
- this.cancelIntervention(this.currentIntervention.request.id);
+ this.cancelIntervention(
+ this.currentIntervention.request.id,
+ "mode_disabled",
+ );
}
}
@@ -278,8 +309,10 @@ export class InterventionManager {
/**
* Cancel an intervention
+ * @param id - The intervention ID to cancel
+ * @param reason - Why the intervention is being cancelled (defaults to "user")
*/
- cancelIntervention(id: string): boolean {
+ cancelIntervention(id: string, reason: CancelReason = "user"): boolean {
if (
!this.currentIntervention ||
this.currentIntervention.request.id !== id
@@ -290,7 +323,10 @@ export class InterventionManager {
return false;
}
- console.log(`[InterventionManager] Cancelling intervention: ${id}`);
+ const cancelMessage = getCancelMessage(reason);
+ console.log(
+ `[InterventionManager] Cancelling intervention: ${id} (reason: ${reason})`,
+ );
// Cancel operation
if (this.abortController) {
@@ -304,7 +340,7 @@ export class InterventionManager {
const result: InterventionResult = {
success: false,
- error: "Cancelled by user",
+ error: cancelMessage,
status: "cancelled",
timestamp: Date.now(),
duration: Date.now() - this.currentIntervention.startTime,
@@ -314,7 +350,7 @@ export class InterventionManager {
this.currentIntervention.result = result;
this.currentIntervention.endTime = Date.now();
- this.emitEvent("cancel", id, { result });
+ this.emitEvent("cancel", id, { result, reason });
this.processNextRequest();
return true;
@@ -389,7 +425,10 @@ export class InterventionManager {
console.log(
"[InterventionManager] Tab switched, cancelling intervention",
);
- this.cancelIntervention(this.currentIntervention.request.id);
+ this.cancelIntervention(
+ this.currentIntervention.request.id,
+ "tab_switched",
+ );
}
}
});
@@ -405,7 +444,10 @@ export class InterventionManager {
console.log(
"[InterventionManager] Page navigated, cancelling intervention",
);
- this.cancelIntervention(this.currentIntervention.request.id);
+ this.cancelIntervention(
+ this.currentIntervention.request.id,
+ "page_navigated",
+ );
}
}
});
diff --git a/packages/browser-runtime/src/lib/vm/quickjs-manager.ts b/packages/browser-runtime/src/lib/vm/quickjs-manager.ts
index 1b6a2d8..7d7e85e 100644
--- a/packages/browser-runtime/src/lib/vm/quickjs-manager.ts
+++ b/packages/browser-runtime/src/lib/vm/quickjs-manager.ts
@@ -7,6 +7,9 @@
*/
import { default as RELEASE_SYNC } from "@jitl/quickjs-ng-wasmfile-release-sync";
+// Import the WASM file as a URL so Vite/bundler handles it correctly
+// This ensures the wasm is properly bundled and the URL is correct at runtime
+import quickjsWasmUrl from "@jitl/quickjs-ng-wasmfile-release-sync/wasm?url";
import fs from "@zenfs/core";
import type {
QuickJSContext,
@@ -17,6 +20,18 @@ import type {
import { newQuickJSWASMModuleFromVariant, Scope } from "quickjs-emscripten";
import type { SkillAPIBridge } from "./skill-api";
+/**
+ * QuickJS sync variant interface - matches the structure expected by newQuickJSWASMModuleFromVariant
+ */
+interface QuickJSVariantLike {
+ importModuleLoader: () => Promise<
+ (options?: Record) => unknown
+ >;
+}
+
+// Type assertion for the variant - the default export type is not fully recognized
+const variant = RELEASE_SYNC as unknown as QuickJSVariantLike;
+
interface ExecutionContext {
skillId: string;
workingDir: string;
@@ -54,9 +69,45 @@ class QuickJSManager {
"[QuickJS] Initializing runtime with RELEASE_SYNC variant...",
);
+ // Sanity check: ensure the WASM URL was properly resolved by Vite
+ if (!quickjsWasmUrl) {
+ throw new Error(
+ "[QuickJS] WASM URL is not defined. Vite may not have bundled the wasm file correctly.",
+ );
+ }
+ console.log(`[QuickJS] WASM URL resolved to: ${quickjsWasmUrl}`);
+
// Use RELEASE_SYNC variant (required for Chrome extensions due to CSP restrictions)
// Chrome extensions don't allow 'wasm-eval' which asyncify variants need
- this.quickjs = await newQuickJSWASMModuleFromVariant(RELEASE_SYNC);
+ // Wrap the variant to override locateFile so the Emscripten loader can find the wasm
+ const variantWithLocateFile = {
+ ...variant,
+ importModuleLoader: async () => {
+ // Get the original module loader
+ const originalLoader = await variant.importModuleLoader();
+ // Return a wrapped version that injects locateFile
+ return (moduleOptions?: Record) => {
+ return originalLoader({
+ ...moduleOptions,
+ // Override locateFile to return the correct URL for the wasm file
+ locateFile: (path: string, prefix: string) => {
+ if (path.endsWith(".wasm")) {
+ console.log(
+ `[QuickJS] locateFile intercepted for ${path}, returning: ${quickjsWasmUrl}`,
+ );
+ return quickjsWasmUrl;
+ }
+ // For non-wasm files, use the default behavior
+ return prefix + path;
+ },
+ });
+ };
+ },
+ };
+
+ this.quickjs = await newQuickJSWASMModuleFromVariant(
+ variantWithLocateFile,
+ );
this.runtime = this.quickjs.newRuntime();
// Set memory and stack limits
diff --git a/packages/browser-runtime/src/skill/lib/services/skill-manager.ts b/packages/browser-runtime/src/skill/lib/services/skill-manager.ts
index acfe1fa..888db48 100644
--- a/packages/browser-runtime/src/skill/lib/services/skill-manager.ts
+++ b/packages/browser-runtime/src/skill/lib/services/skill-manager.ts
@@ -450,6 +450,106 @@ export class SkillManager {
}
}
+ /**
+ * Refresh skill metadata from SKILL.md file.
+ * This is called when SKILL.md is edited and saved via the file manager.
+ * It re-parses the frontmatter and updates both IndexedDB and the registry.
+ */
+ async refreshSkillMetadata(skillId: string): Promise {
+ if (!this.initialized) {
+ throw new Error("SkillManager not initialized");
+ }
+
+ // Validate skillId to prevent path traversal
+ if (
+ !skillId ||
+ skillId.includes("/") ||
+ skillId.includes("\\") ||
+ skillId.includes("..")
+ ) {
+ throw new Error(`Invalid skill ID: ${skillId}`);
+ }
+
+ try {
+ // Get current metadata
+ const currentMetadata = await skillStorage.getSkillMetadata(skillId);
+ if (!currentMetadata) {
+ throw new Error(`Skill not found: ${skillId}`);
+ }
+
+ // Read the SKILL.md content from ZenFS
+ const skillPath = zenfs.getSkillPath(skillId);
+ const skillMdPath = `${skillPath}/SKILL.md`;
+
+ const skillMdExists = await zenfs.exists(skillMdPath);
+ if (!skillMdExists) {
+ throw new Error(`SKILL.md not found for skill: ${skillId}`);
+ }
+
+ const skillMdContent = (await zenfs.readFile(
+ skillMdPath,
+ "utf8",
+ )) as string;
+
+ // Parse the frontmatter to extract description and version
+ const parsedMetadata = skillRegistry.parseSkillMetadata(skillMdContent);
+
+ // Check that name hasn't changed (we don't support rename)
+ if (parsedMetadata.name && parsedMetadata.name !== skillId) {
+ throw new Error(
+ `Skill name mismatch: expected "${skillId}" but found "${parsedMetadata.name}" in SKILL.md. Skill renaming is not supported.`,
+ );
+ }
+
+ // Build updates object (only update fields that are present in frontmatter)
+ const updates: Partial = {};
+ if (parsedMetadata.description !== undefined) {
+ updates.description = parsedMetadata.description;
+ }
+ if (parsedMetadata.version !== undefined) {
+ updates.version = parsedMetadata.version;
+ }
+
+ // Update in IndexedDB if there are changes
+ if (Object.keys(updates).length > 0) {
+ await skillStorage.updateSkill(skillId, updates);
+ }
+
+ // Get the updated metadata
+ const updatedMetadata = await skillStorage.getSkillMetadata(skillId);
+ if (!updatedMetadata) {
+ throw new Error(
+ `Failed to retrieve updated metadata for skill: ${skillId}`,
+ );
+ }
+
+ // Update the registry with updated metadata and refreshed content
+ const existingSkill = skillRegistry.getSkill(currentMetadata.name);
+ if (existingSkill) {
+ skillRegistry.updateSkill(currentMetadata.name, {
+ metadata: updatedMetadata,
+ skillMdContent: skillMdContent,
+ });
+ }
+
+ console.log(`✅ Skill metadata refreshed: ${skillId}`);
+
+ // Emit an event so UI components can react
+ this._emit("skill_loaded", {
+ type: "skill_metadata_refreshed",
+ skillId,
+ skillName: currentMetadata.name,
+ skillMetadata: updatedMetadata,
+ });
+ } catch (error) {
+ console.error(
+ `❌ Failed to refresh skill metadata for ${skillId}:`,
+ error,
+ );
+ throw error;
+ }
+ }
+
getRegisteredTools(): any[] {
return skillExecutor.getRegisteredTools();
}
diff --git a/packages/browser-runtime/src/tools/index.ts b/packages/browser-runtime/src/tools/index.ts
index ba00e87..056a29e 100644
--- a/packages/browser-runtime/src/tools/index.ts
+++ b/packages/browser-runtime/src/tools/index.ts
@@ -24,14 +24,13 @@ import {
getAllTabsTool,
getCurrentTabTool,
getTabInfoTool,
- organizeTabsTool,
ungroupTabsTool,
} from "./tab";
import { downloadChatImagesTool, downloadImageTool } from "./tools/downloads";
/**
* All browser tools registered for AI use
- * Total: 32 tools (28 core + 4 intervention tools)
+ * Total: 31 tools (27 core + 4 intervention tools)
*
* Disabled tools (per aipex):
* - switch_to_tab (causes context switching issues)
@@ -40,6 +39,7 @@ import { downloadChatImagesTool, downloadImageTool } from "./tools/downloads";
* - capture_screenshot_to_clipboard (not enabled in aipex)
* - download_text_as_markdown (not enabled in aipex)
* - download_current_chat_images (architecture issue, not enabled in aipex)
+ * - organize_tabs (stub implementation, temporarily disabled until AI grouping is complete)
*/
type BrowserFunctionTool = FunctionTool<
unknown,
@@ -48,13 +48,13 @@ type BrowserFunctionTool = FunctionTool<
>;
const browserFunctionTools: BrowserFunctionTool[] = [
- // Browser/Tab Management (7 tools)
+ // Browser/Tab Management (6 tools)
+ // Note: organize_tabs temporarily disabled (stub/not shipped)
getAllTabsTool,
getCurrentTabTool,
createNewTabTool,
getTabInfoTool,
closeTabTool,
- organizeTabsTool,
ungroupTabsTool,
// UI Operations (7 tools) - computer tool replaces visual XY tools
diff --git a/packages/browser-runtime/src/tools/interventions/index.ts b/packages/browser-runtime/src/tools/interventions/index.ts
index 4cefe77..72636b7 100644
--- a/packages/browser-runtime/src/tools/interventions/index.ts
+++ b/packages/browser-runtime/src/tools/interventions/index.ts
@@ -256,8 +256,10 @@ export const cancelInterventionTool = tool({
};
}
+ // Pass "user" as the reason since this is an AI/user-initiated cancellation
const cancelled = interventionManager.cancelIntervention(
currentIntervention.request.id,
+ "user",
);
if (cancelled) {
diff --git a/packages/browser-runtime/src/tools/organize-tabs.ts b/packages/browser-runtime/src/tools/organize-tabs.ts
new file mode 100644
index 0000000..ca47610
--- /dev/null
+++ b/packages/browser-runtime/src/tools/organize-tabs.ts
@@ -0,0 +1,515 @@
+/**
+ * AI-powered tab organization module
+ *
+ * This module provides the logic to group tabs using AI or fallback to domain-based grouping.
+ * The AI integration uses a configurable callback pattern, allowing the extension to inject
+ * the actual LLM call implementation.
+ */
+
+import { z } from "zod";
+
+// ============================================================================
+// Types
+// ============================================================================
+
+/**
+ * Valid tab group color values (matching chrome.tabGroups.Color enum values)
+ */
+export type TabGroupColor =
+ | "blue"
+ | "red"
+ | "yellow"
+ | "green"
+ | "orange"
+ | "purple"
+ | "pink"
+ | "cyan"
+ | "grey";
+
+export interface TabData {
+ id: number;
+ title: string;
+ url: string;
+ hostname: string;
+}
+
+export interface TabGroupResult {
+ emoji: string;
+ category: string;
+ color: TabGroupColor;
+ tabIds: number[];
+}
+
+export interface OrganizeTabsResult {
+ success: boolean;
+ groupedTabs?: number;
+ groups?: number;
+ error?: string;
+}
+
+/**
+ * LLM response schema for tab grouping
+ */
+export const TabGroupingResponseSchema = z.object({
+ groups: z.array(
+ z.object({
+ emoji: z.string(),
+ category: z.string(),
+ color: z.enum([
+ "blue",
+ "red",
+ "yellow",
+ "green",
+ "orange",
+ "purple",
+ "pink",
+ "cyan",
+ "grey",
+ ]),
+ tabIds: z.array(z.number()),
+ }),
+ ),
+});
+
+export type TabGroupingResponse = z.infer;
+
+/**
+ * Callback type for AI-powered tab classification.
+ * The extension should provide this when AI is available.
+ */
+export type TabClassificationCallback = (
+ tabData: TabData[],
+ language: "en" | "zh",
+) => Promise;
+
+// ============================================================================
+// Module State
+// ============================================================================
+
+let aiClassificationCallback: TabClassificationCallback | null = null;
+
+/**
+ * Set the AI classification callback for tab organization.
+ * This should be called by the extension when the agent is ready.
+ */
+export function setTabClassificationCallback(
+ callback: TabClassificationCallback | null,
+): void {
+ aiClassificationCallback = callback;
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+const VALID_COLORS: TabGroupColor[] = [
+ "blue",
+ "red",
+ "yellow",
+ "green",
+ "orange",
+ "purple",
+ "pink",
+ "cyan",
+ "grey",
+];
+
+function getRandomColor(): TabGroupColor {
+ return VALID_COLORS[Math.floor(Math.random() * VALID_COLORS.length)]!;
+}
+
+// Regex patterns for character sanitization - using RegExp constructor to satisfy linter
+// biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally matching control characters for sanitization
+const CONTROL_CHARS_REGEX = /[\u0000-\u001F\u007F-\u009F]/g;
+
+/**
+ * Sanitize string for AI request - remove problematic characters
+ */
+function sanitizeForAI(str: string): string {
+ return str
+ .replace(/[\uD800-\uDFFF]/g, "") // Remove surrogate pairs
+ .replace(CONTROL_CHARS_REGEX, "") // Remove control characters
+ .replace(/[\u{1F600}-\u{1F64F}]/gu, "") // Remove emoji ranges
+ .replace(/[\u{1F300}-\u{1F5FF}]/gu, "")
+ .replace(/[\u{1F680}-\u{1F6FF}]/gu, "")
+ .replace(/[\u{1F1E0}-\u{1F1FF}]/gu, "")
+ .replace(/[\u{2600}-\u{26FF}]/gu, "")
+ .replace(/[\u{2700}-\u{27BF}]/gu, "")
+ .replace(/[^\x20-\x7E\u4e00-\u9fff]/g, "") // Keep ASCII and Chinese
+ .trim();
+}
+
+/**
+ * Sanitize category string for display
+ */
+function sanitizeString(str: string): string {
+ return str
+ .replace(/[\uD800-\uDFFF]/g, "")
+ .replace(CONTROL_CHARS_REGEX, "")
+ .replace(/[^\x20-\x7E\u4e00-\u9fff]/g, "")
+ .trim();
+}
+
+/**
+ * Validate emoji - more permissive but safe
+ */
+function validateEmoji(emoji: string | undefined): string {
+ if (!emoji || typeof emoji !== "string") {
+ return "📁";
+ }
+ const trimmed = emoji.trim();
+ if (trimmed.length === 0 || trimmed.includes("\u0000")) {
+ return "📁";
+ }
+ return trimmed;
+}
+
+/**
+ * Extract hostname from URL safely
+ */
+function getHostname(url: string): string {
+ try {
+ return new URL(url).hostname;
+ } catch {
+ const match = url.match(/:\/\/([^/]+)/);
+ return match?.[1] || url.split("://")[0] || "";
+ }
+}
+
+// ============================================================================
+// Fallback: Domain-Based Grouping
+// ============================================================================
+
+interface DomainGroup {
+ domain: string;
+ category: string;
+ emoji: string;
+ color: TabGroupColor;
+}
+
+const DOMAIN_CATEGORIES: DomainGroup[] = [
+ // Development
+ { domain: "github.com", category: "Dev", emoji: "💻", color: "grey" },
+ { domain: "gitlab.com", category: "Dev", emoji: "💻", color: "grey" },
+ { domain: "stackoverflow.com", category: "Dev", emoji: "💻", color: "grey" },
+ { domain: "npmjs.com", category: "Dev", emoji: "💻", color: "grey" },
+ { domain: "vercel.com", category: "Dev", emoji: "💻", color: "grey" },
+
+ // Google
+ { domain: "google.com", category: "Google", emoji: "🔍", color: "blue" },
+ { domain: "youtube.com", category: "Video", emoji: "🎬", color: "red" },
+ { domain: "gmail.com", category: "Mail", emoji: "📧", color: "red" },
+ { domain: "docs.google.com", category: "Docs", emoji: "📄", color: "blue" },
+
+ // Social
+ { domain: "twitter.com", category: "Social", emoji: "🐦", color: "cyan" },
+ { domain: "x.com", category: "Social", emoji: "🐦", color: "cyan" },
+ { domain: "linkedin.com", category: "Social", emoji: "💼", color: "blue" },
+ { domain: "facebook.com", category: "Social", emoji: "👥", color: "blue" },
+ { domain: "reddit.com", category: "Social", emoji: "🗨️", color: "orange" },
+
+ // Shopping
+ { domain: "amazon.com", category: "Shop", emoji: "🛒", color: "yellow" },
+ { domain: "ebay.com", category: "Shop", emoji: "🛒", color: "yellow" },
+ { domain: "taobao.com", category: "Shop", emoji: "🛒", color: "orange" },
+ { domain: "jd.com", category: "Shop", emoji: "🛒", color: "red" },
+
+ // News
+ { domain: "cnn.com", category: "News", emoji: "📰", color: "red" },
+ { domain: "bbc.com", category: "News", emoji: "📰", color: "red" },
+ { domain: "reuters.com", category: "News", emoji: "📰", color: "blue" },
+
+ // AI
+ { domain: "openai.com", category: "AI", emoji: "🤖", color: "green" },
+ { domain: "anthropic.com", category: "AI", emoji: "🤖", color: "orange" },
+ { domain: "claude.ai", category: "AI", emoji: "🤖", color: "orange" },
+ { domain: "chatgpt.com", category: "AI", emoji: "🤖", color: "green" },
+];
+
+function getDomainCategory(hostname: string): DomainGroup | null {
+ const lowerHost = hostname.toLowerCase();
+ for (const category of DOMAIN_CATEGORIES) {
+ if (lowerHost.includes(category.domain)) {
+ return category;
+ }
+ }
+ return null;
+}
+
+/**
+ * Fallback grouping by domain when AI is not available
+ */
+function groupTabsByDomain(tabs: TabData[]): TabGroupResult[] {
+ const groups = new Map<
+ string,
+ {
+ category: string;
+ emoji: string;
+ color: TabGroupColor;
+ tabIds: number[];
+ }
+ >();
+
+ const otherTabs: number[] = [];
+
+ for (const tab of tabs) {
+ const domainInfo = getDomainCategory(tab.hostname);
+ if (domainInfo) {
+ const key = domainInfo.category;
+ const existing = groups.get(key);
+ if (existing) {
+ existing.tabIds.push(tab.id);
+ } else {
+ groups.set(key, {
+ category: domainInfo.category,
+ emoji: domainInfo.emoji,
+ color: domainInfo.color,
+ tabIds: [tab.id],
+ });
+ }
+ } else {
+ // Group remaining tabs by root domain
+ const rootDomain =
+ tab.hostname.split(".").slice(-2).join(".") || tab.hostname;
+ if (rootDomain) {
+ const key = `domain:${rootDomain}`;
+ const existing = groups.get(key);
+ if (existing) {
+ existing.tabIds.push(tab.id);
+ } else {
+ groups.set(key, {
+ category: rootDomain.split(".")[0] || "Other",
+ emoji: "🌐",
+ color: getRandomColor(),
+ tabIds: [tab.id],
+ });
+ }
+ } else {
+ otherTabs.push(tab.id);
+ }
+ }
+ }
+
+ // Convert to results, excluding single-tab groups
+ const results: TabGroupResult[] = [];
+ for (const [, group] of groups) {
+ if (group.tabIds.length >= 2) {
+ results.push(group);
+ } else {
+ otherTabs.push(...group.tabIds);
+ }
+ }
+
+ // Add "Other" group if there are uncategorized tabs
+ if (otherTabs.length >= 2) {
+ results.push({
+ category: "Other",
+ emoji: "📁",
+ color: "grey",
+ tabIds: otherTabs,
+ });
+ }
+
+ return results;
+}
+
+// ============================================================================
+// Main Implementation
+// ============================================================================
+
+/**
+ * Use AI to automatically group tabs by topic/purpose.
+ * Falls back to domain-based grouping if AI is not available.
+ */
+export async function groupTabsByAI(): Promise {
+ try {
+ // Get all tabs in current window
+ const tabs = await chrome.tabs.query({ currentWindow: true });
+ const validTabs = tabs.filter((tab) => tab.url && tab.id);
+
+ if (validTabs.length === 0) {
+ return { success: true, groupedTabs: 0, groups: 0 };
+ }
+
+ // Get active tab for collapse logic
+ const [activeTab] = await chrome.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+
+ // Prepare tab data
+ const tabData: TabData[] = validTabs.map((tab) => ({
+ id: tab.id!,
+ title: sanitizeForAI(tab.title || ""),
+ url: tab.url!,
+ hostname: sanitizeForAI(getHostname(tab.url!)),
+ }));
+
+ let groupingResult: TabGroupResult[];
+
+ // Try AI classification if callback is available
+ if (aiClassificationCallback) {
+ try {
+ // Detect language - simple heuristic
+ const hasChineseChars = tabData.some(
+ (t) => /[\u4e00-\u9fff]/.test(t.title) || t.hostname.endsWith(".cn"),
+ );
+ const language = hasChineseChars ? "zh" : "en";
+
+ const aiResponse = await aiClassificationCallback(tabData, language);
+ const parsed = TabGroupingResponseSchema.safeParse(aiResponse);
+
+ if (parsed.success) {
+ groupingResult = parsed.data.groups.map((g) => ({
+ emoji: validateEmoji(g.emoji),
+ category: sanitizeString(g.category),
+ color: VALID_COLORS.includes(g.color) ? g.color : getRandomColor(),
+ tabIds: g.tabIds.filter((id) => validTabs.some((t) => t.id === id)),
+ }));
+ } else {
+ console.warn(
+ "[organize_tabs] AI response parsing failed, using fallback:",
+ parsed.error,
+ );
+ groupingResult = groupTabsByDomain(tabData);
+ }
+ } catch (aiError) {
+ console.warn(
+ "[organize_tabs] AI classification failed, using fallback:",
+ aiError,
+ );
+ groupingResult = groupTabsByDomain(tabData);
+ }
+ } else {
+ // No AI available, use domain-based fallback
+ console.log(
+ "[organize_tabs] No AI callback set, using domain-based grouping",
+ );
+ groupingResult = groupTabsByDomain(tabData);
+ }
+
+ // Apply groups to tabs
+ const windowId = validTabs[0]!.windowId;
+ let groupCount = 0;
+
+ for (const group of groupingResult) {
+ if (group.tabIds.length === 0) continue;
+
+ const displayName = `${group.emoji} ${group.category}`;
+
+ try {
+ // Check for existing group with same name
+ const existingGroups = await chrome.tabGroups.query({ windowId });
+ const existingGroup = existingGroups.find(
+ (g) => g.title === displayName,
+ );
+
+ if (existingGroup) {
+ // Add tabs to existing group
+ await chrome.tabs.group({
+ tabIds: group.tabIds as [number, ...number[]],
+ groupId: existingGroup.id,
+ });
+ // Collapse unless it contains active tab
+ const containsActiveTab = group.tabIds.includes(activeTab?.id ?? -1);
+ await chrome.tabGroups.update(existingGroup.id, {
+ collapsed: !containsActiveTab,
+ });
+ } else {
+ // Create new group
+ const groupId = await chrome.tabs.group({
+ createProperties: { windowId },
+ tabIds: group.tabIds as [number, ...number[]],
+ });
+ await chrome.tabGroups.update(groupId, {
+ title: displayName,
+ color: group.color,
+ });
+ // Collapse unless it contains active tab
+ const containsActiveTab = group.tabIds.includes(activeTab?.id ?? -1);
+ await chrome.tabGroups.update(groupId, {
+ collapsed: !containsActiveTab,
+ });
+ }
+ groupCount++;
+ } catch (groupError) {
+ console.warn(`[organize_tabs] Failed to create group:`, groupError);
+ }
+ }
+
+ return {
+ success: true,
+ groupedTabs: validTabs.length,
+ groups: groupCount,
+ };
+ } catch (error) {
+ console.error("[organize_tabs] Error:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+}
+
+// ============================================================================
+// Prompt Template for AI Classification
+// ============================================================================
+
+/**
+ * Generate the prompt for AI tab classification.
+ * This can be used by the extension to create the appropriate AI request.
+ */
+export function generateTabClassificationPrompt(
+ tabData: TabData[],
+ language: "en" | "zh",
+): string {
+ const languageInstructions =
+ language === "zh"
+ ? {
+ categoryInstruction: "简单的分类名称(1-2个中文字)",
+ exampleCategories: ["新闻", "购物", "工作"],
+ languageNote: "使用简单的中文词汇作为分类名称。",
+ }
+ : {
+ categoryInstruction: "A simple category name (1-2 words in English)",
+ exampleCategories: ["News", "Shopping", "Work"],
+ languageNote: "Use simple English words for categories.",
+ };
+
+ return `Classify these browser tabs into 3-7 meaningful groups based on their content, purpose, or topic. For each group, provide an appropriate emoji, color, and a simple category name.
+
+Tab data:
+${JSON.stringify(tabData, null, 2)}
+
+You must return a JSON object with a "groups" key containing an array where each item has:
+1. "emoji": A single emoji that represents the group content
+2. "category": ${languageInstructions.categoryInstruction}
+3. "color": A color from this list: blue, red, yellow, green, orange, purple, pink, cyan, grey
+4. "tabIds": Array of tab IDs that belong to this group
+
+Example response format:
+{
+ "groups": [
+ {
+ "emoji": "[emoji]",
+ "category": "${languageInstructions.exampleCategories[0]}",
+ "color": "blue",
+ "tabIds": [123, 124, 125]
+ },
+ {
+ "emoji": "[emoji]",
+ "category": "${languageInstructions.exampleCategories[1]}",
+ "color": "green",
+ "tabIds": [126, 127]
+ },
+ {
+ "emoji": "[emoji]",
+ "category": "${languageInstructions.exampleCategories[2]}",
+ "color": "purple",
+ "tabIds": [128, 129]
+ }
+ ]
+}
+
+Important: Use only common, standard emojis and ${languageInstructions.languageNote} Choose colors that match the content theme.`;
+}
diff --git a/packages/browser-runtime/src/tools/tools/tab-groups/index.ts b/packages/browser-runtime/src/tools/tools/tab-groups/index.ts
index 027f7c2..2a0a03a 100644
--- a/packages/browser-runtime/src/tools/tools/tab-groups/index.ts
+++ b/packages/browser-runtime/src/tools/tools/tab-groups/index.ts
@@ -135,8 +135,14 @@ export async function deleteTabGroup(groupId: number): Promise<{
}
}
+/**
+ * Tool to remove all tab groups in the current window.
+ * Note: This tool uses the name "ungroup_tabs" for consistency with legacy naming.
+ * Do not register this alongside the default ungroupTabsTool from ./tab.ts to avoid
+ * duplicate tool name registration.
+ */
export const ungroupAllTabsTool = tool({
- name: "ungroup_all_tabs",
+ name: "ungroup_tabs",
description: "Remove all tab groups in the current window",
parameters: z.object({}),
execute: async () => {
diff --git a/packages/browser-runtime/src/types/external-modules.d.ts b/packages/browser-runtime/src/types/external-modules.d.ts
index 7cb4982..2111ac4 100644
--- a/packages/browser-runtime/src/types/external-modules.d.ts
+++ b/packages/browser-runtime/src/types/external-modules.d.ts
@@ -3,6 +3,12 @@ declare module "@jitl/quickjs-ng-wasmfile-release-sync" {
export default releaseSyncVariant;
}
+// Declaration for the wasm subpath export with Vite's ?url suffix
+declare module "@jitl/quickjs-ng-wasmfile-release-sync/wasm?url" {
+ const wasmUrl: string;
+ export default wasmUrl;
+}
+
declare module "@zenfs/core" {
export const configure: (...args: any[]) => Promise;
export const fs: {
diff --git a/packages/browser-runtime/src/types/url.d.ts b/packages/browser-runtime/src/types/url.d.ts
new file mode 100644
index 0000000..6088414
--- /dev/null
+++ b/packages/browser-runtime/src/types/url.d.ts
@@ -0,0 +1,16 @@
+/**
+ * TypeScript declaration for Vite's ?url import suffix.
+ * When importing a file with ?url, Vite returns the public URL of the asset
+ * after bundling, rather than the file contents.
+ *
+ * This is used for importing WASM files and other assets that need to be
+ * fetched at runtime with their correct bundled URL.
+ *
+ * @example
+ * import wasmUrl from "@some-package/file.wasm?url";
+ * // wasmUrl is a string containing the URL to the wasm file
+ */
+declare module "*?url" {
+ const url: string;
+ export default url;
+}
diff --git a/packages/browser-runtime/vitest.config.ts b/packages/browser-runtime/vitest.config.ts
index c05c84f..fa3656d 100644
--- a/packages/browser-runtime/vitest.config.ts
+++ b/packages/browser-runtime/vitest.config.ts
@@ -9,6 +9,11 @@ export default defineConfig({
concurrent: false,
},
silent: true,
- exclude: ["**/node_modules/**", "**/dist/**"],
+ exclude: [
+ "**/node_modules/**",
+ "**/dist/**",
+ // Puppeteer tests require Chrome browser installation - run separately with: vitest run --config vitest.puppeteer.config.ts
+ "**/*.puppeteer.test.ts",
+ ],
},
});
diff --git a/packages/core/package.json b/packages/core/package.json
index 9c9229d..9f16882 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -56,7 +56,7 @@
"@ai-sdk/anthropic": "^3.0.0",
"@ai-sdk/google": "^3.0.0",
"@ai-sdk/openai": "^3.0.0",
- "@openrouter/ai-sdk-provider": "^0.4.0"
+ "@openrouter/ai-sdk-provider": "^2.0.0"
},
"peerDependenciesMeta": {
"@ai-sdk/openai": {
diff --git a/packages/core/src/agent/aipex.test.ts b/packages/core/src/agent/aipex.test.ts
index 3377165..27f2901 100644
--- a/packages/core/src/agent/aipex.test.ts
+++ b/packages/core/src/agent/aipex.test.ts
@@ -19,22 +19,35 @@ function createMockRunResult(
overrides: {
finalOutput?: string;
usage?: { promptTokens?: number; completionTokens?: number };
+ /** Multiple raw responses (for testing multi-turn within single execution) */
+ rawResponses?: Array<{
+ usage?: { inputTokens?: number; outputTokens?: number };
+ }>;
streamEvents?: any[];
} = {},
): StreamedRunResult {
const events = overrides.streamEvents ?? [];
+
+ // Build rawResponses: if explicit rawResponses provided, use it; otherwise use usage shorthand
+ let rawResponses: Array<{
+ usage?: { inputTokens?: number; outputTokens?: number };
+ }> = [];
+ if (overrides.rawResponses) {
+ rawResponses = overrides.rawResponses;
+ } else if (overrides.usage) {
+ rawResponses = [
+ {
+ usage: {
+ inputTokens: overrides.usage.promptTokens ?? 0,
+ outputTokens: overrides.usage.completionTokens ?? 0,
+ },
+ },
+ ];
+ }
+
return {
finalOutput: overrides.finalOutput ?? "",
- rawResponses: overrides.usage
- ? [
- {
- usage: {
- inputTokens: overrides.usage.promptTokens ?? 0,
- outputTokens: overrides.usage.completionTokens ?? 0,
- },
- },
- ]
- : [],
+ rawResponses,
async *[Symbol.asyncIterator]() {
for (const event of events) {
yield event;
@@ -388,6 +401,94 @@ describe("AIPex", () => {
}
});
+ it("should use last rawResponse usage when multiple responses exist", async () => {
+ // Simulate a multi-turn execution where multiple model responses occur
+ // (e.g., tool calls triggering additional model calls)
+ vi.mocked(run).mockResolvedValue(
+ createMockRunResult({
+ finalOutput: "Final response",
+ rawResponses: [
+ // First response (e.g., tool call)
+ { usage: { inputTokens: 100, outputTokens: 50 } },
+ // Second response (e.g., another tool call)
+ { usage: { inputTokens: 200, outputTokens: 100 } },
+ // Final response - this should be used
+ { usage: { inputTokens: 500, outputTokens: 250 } },
+ ],
+ streamEvents: [
+ {
+ type: "raw_model_stream_event",
+ data: { type: "output_text_delta", delta: "Final response" },
+ },
+ ],
+ }),
+ );
+
+ const agent = AIPex.create({
+ instructions: "Test",
+ model: mockModel,
+ });
+
+ const events: AgentEvent[] = [];
+ for await (const event of agent.chat("Input")) {
+ events.push(event);
+ }
+
+ const metricsEvent = events.find((e) => e.type === "metrics_update");
+ expect(metricsEvent).toBeDefined();
+ if (metricsEvent && metricsEvent.type === "metrics_update") {
+ // Should use the LAST response's usage, not the sum
+ expect(metricsEvent.metrics.promptTokens).toBe(500);
+ expect(metricsEvent.metrics.completionTokens).toBe(250);
+ expect(metricsEvent.metrics.tokensUsed).toBe(750);
+ }
+
+ const completeEvent = events.find((e) => e.type === "execution_complete");
+ expect(completeEvent).toBeDefined();
+ if (completeEvent && completeEvent.type === "execution_complete") {
+ expect(completeEvent.metrics.tokensUsed).toBe(750);
+ }
+ });
+
+ it("should handle rawResponses with some entries missing usage", async () => {
+ vi.mocked(run).mockResolvedValue(
+ createMockRunResult({
+ finalOutput: "Response",
+ rawResponses: [
+ { usage: { inputTokens: 100, outputTokens: 50 } },
+ {}, // No usage
+ { usage: undefined },
+ { usage: { inputTokens: 300, outputTokens: 150 } }, // Last with usage
+ ],
+ streamEvents: [
+ {
+ type: "raw_model_stream_event",
+ data: { type: "output_text_delta", delta: "Response" },
+ },
+ ],
+ }),
+ );
+
+ const agent = AIPex.create({
+ instructions: "Test",
+ model: mockModel,
+ });
+
+ const events: AgentEvent[] = [];
+ for await (const event of agent.chat("Input")) {
+ events.push(event);
+ }
+
+ const metricsEvent = events.find((e) => e.type === "metrics_update");
+ expect(metricsEvent).toBeDefined();
+ if (metricsEvent && metricsEvent.type === "metrics_update") {
+ // Should find the last response WITH usage data
+ expect(metricsEvent.metrics.promptTokens).toBe(300);
+ expect(metricsEvent.metrics.completionTokens).toBe(150);
+ expect(metricsEvent.metrics.tokensUsed).toBe(450);
+ }
+ });
+
it("should accumulate session metrics across multiple conversations", async () => {
vi.mocked(run).mockResolvedValue(
createMockRunResult({
@@ -724,5 +825,182 @@ describe("AIPex", () => {
await expect(runPromise).resolves.toBeUndefined();
expect(events.some((event) => event.type === "error")).toBe(true);
});
+
+ it("should extract real error message from tool failure", async () => {
+ vi.mocked(run).mockResolvedValue(
+ createMockRunResult({
+ finalOutput: "",
+ streamEvents: [
+ {
+ type: "run_item_stream_event",
+ name: "tool_called",
+ item: { rawItem: { name: "screenshot", arguments: "{}" } },
+ },
+ {
+ type: "run_item_stream_event",
+ name: "tool_output",
+ item: {
+ rawItem: {
+ name: "screenshot",
+ status: "failed",
+ error: { message: "No active tab found" },
+ },
+ output: undefined,
+ },
+ },
+ ],
+ }),
+ );
+
+ const agent = AIPex.create({
+ instructions: "Tools",
+ model: mockModel,
+ });
+
+ const events: AgentEvent[] = [];
+ for await (const event of agent.chat("take screenshot")) {
+ events.push(event);
+ }
+
+ const errorEvent = events.find(
+ (event) => event.type === "tool_call_error",
+ );
+ expect(errorEvent).toBeDefined();
+ if (errorEvent?.type === "tool_call_error") {
+ expect(errorEvent.error.message).toBe("No active tab found");
+ }
+ });
+
+ it("should extract error message from JSON output on failure", async () => {
+ vi.mocked(run).mockResolvedValue(
+ createMockRunResult({
+ finalOutput: "",
+ streamEvents: [
+ {
+ type: "run_item_stream_event",
+ name: "tool_called",
+ item: { rawItem: { name: "organize_tabs", arguments: "{}" } },
+ },
+ {
+ type: "run_item_stream_event",
+ name: "tool_output",
+ item: {
+ rawItem: { name: "organize_tabs", status: "failed" },
+ output: JSON.stringify({
+ success: false,
+ error: "Cannot organize tabs in incognito window",
+ }),
+ },
+ },
+ ],
+ }),
+ );
+
+ const agent = AIPex.create({
+ instructions: "Tools",
+ model: mockModel,
+ });
+
+ const events: AgentEvent[] = [];
+ for await (const event of agent.chat("organize tabs")) {
+ events.push(event);
+ }
+
+ const errorEvent = events.find(
+ (event) => event.type === "tool_call_error",
+ );
+ expect(errorEvent).toBeDefined();
+ if (errorEvent?.type === "tool_call_error") {
+ expect(errorEvent.error.message).toBe(
+ "Cannot organize tabs in incognito window",
+ );
+ }
+ });
+
+ it("should sanitize sensitive data from error messages", async () => {
+ vi.mocked(run).mockResolvedValue(
+ createMockRunResult({
+ finalOutput: "",
+ streamEvents: [
+ {
+ type: "run_item_stream_event",
+ name: "tool_called",
+ item: { rawItem: { name: "api_call", arguments: "{}" } },
+ },
+ {
+ type: "run_item_stream_event",
+ name: "tool_output",
+ item: {
+ rawItem: { name: "api_call", status: "failed" },
+ output:
+ "Error: Request failed with Authorization: Bearer sk-1234567890abcdef",
+ },
+ },
+ ],
+ }),
+ );
+
+ const agent = AIPex.create({
+ instructions: "Tools",
+ model: mockModel,
+ });
+
+ const events: AgentEvent[] = [];
+ for await (const event of agent.chat("make api call")) {
+ events.push(event);
+ }
+
+ const errorEvent = events.find(
+ (event) => event.type === "tool_call_error",
+ );
+ expect(errorEvent).toBeDefined();
+ if (errorEvent?.type === "tool_call_error") {
+ expect(errorEvent.error.message).toContain("[REDACTED]");
+ expect(errorEvent.error.message).not.toContain("sk-1234567890abcdef");
+ }
+ });
+
+ it("should truncate long error messages", async () => {
+ const longMessage = "x".repeat(1000);
+ vi.mocked(run).mockResolvedValue(
+ createMockRunResult({
+ finalOutput: "",
+ streamEvents: [
+ {
+ type: "run_item_stream_event",
+ name: "tool_called",
+ item: { rawItem: { name: "failing_tool", arguments: "{}" } },
+ },
+ {
+ type: "run_item_stream_event",
+ name: "tool_output",
+ item: {
+ rawItem: { name: "failing_tool", status: "failed" },
+ output: longMessage,
+ },
+ },
+ ],
+ }),
+ );
+
+ const agent = AIPex.create({
+ instructions: "Tools",
+ model: mockModel,
+ });
+
+ const events: AgentEvent[] = [];
+ for await (const event of agent.chat("run failing tool")) {
+ events.push(event);
+ }
+
+ const errorEvent = events.find(
+ (event) => event.type === "tool_call_error",
+ );
+ expect(errorEvent).toBeDefined();
+ if (errorEvent?.type === "tool_call_error") {
+ expect(errorEvent.error.message.length).toBeLessThanOrEqual(500);
+ expect(errorEvent.error.message.endsWith("...")).toBe(true);
+ }
+ });
});
});
diff --git a/packages/core/src/agent/aipex.ts b/packages/core/src/agent/aipex.ts
index 0ba0ede..bd5f44c 100644
--- a/packages/core/src/agent/aipex.ts
+++ b/packages/core/src/agent/aipex.ts
@@ -45,7 +45,7 @@ export class AIPex {
this.agent = agent;
this.conversationManager = conversationManager;
this.contextManager = contextManager;
- this.maxTurns = maxTurns ?? 10;
+ this.maxTurns = maxTurns ?? 2000;
this.plugins = plugins;
this.pluginContext = { agent: this };
this.initializePlugins();
@@ -237,7 +237,11 @@ export class AIPex {
metrics: metricsSnapshot,
sessionId: session?.id ?? undefined,
});
- yield { type: "metrics_update", metrics: metricsSnapshot };
+ yield {
+ type: "metrics_update",
+ metrics: metricsSnapshot,
+ sessionId: session?.id,
+ };
if (session) {
session.addMetrics(metrics);
@@ -267,7 +271,11 @@ export class AIPex {
metrics: metricsSnapshot,
sessionId: session?.id ?? undefined,
});
- yield { type: "metrics_update", metrics: { ...metrics } };
+ yield {
+ type: "metrics_update",
+ metrics: { ...metrics },
+ sessionId: session?.id,
+ };
yield { type: "error", error: agentError };
if (session) {
session.addMetrics(metrics);
@@ -396,10 +404,12 @@ export class AIPex {
const status = this.getToolStatus(event.item);
if (status !== "completed") {
+ const toolName = this.extractToolName(event.item);
+ const failureMessage = this.extractToolFailureMessage(event.item, status);
return {
type: "tool_call_error",
- toolName: this.extractToolName(event.item),
- error: new Error(`Tool call ${status}`),
+ toolName,
+ error: new Error(failureMessage),
};
}
@@ -459,20 +469,168 @@ export class AIPex {
return rawOutput;
}
+ /**
+ * Extract a human-readable failure message from a tool execution.
+ * Attempts to find the real error message from various locations in the item,
+ * with basic truncation and sanitization.
+ */
+ private extractToolFailureMessage(
+ item: RunItemStreamEvent["item"],
+ status: string,
+ ): string {
+ const MAX_MESSAGE_LENGTH = 500;
+
+ // Try to extract error message from various sources
+ let message: string | undefined;
+
+ // Check item.output for error info
+ const outputCarrier = item as unknown as { output?: unknown };
+ if (outputCarrier.output !== undefined) {
+ message = this.extractErrorFromValue(outputCarrier.output);
+ }
+
+ // Check rawItem.output
+ if (!message) {
+ const rawOutput = (item as unknown as { rawItem?: { output?: unknown } })
+ .rawItem?.output;
+ if (rawOutput !== undefined) {
+ message = this.extractErrorFromValue(rawOutput);
+ }
+ }
+
+ // Check rawItem.error directly
+ if (!message) {
+ const rawError = (item as unknown as { rawItem?: { error?: unknown } })
+ .rawItem?.error;
+ if (rawError !== undefined) {
+ message = this.extractErrorFromValue(rawError);
+ }
+ }
+
+ // Fallback to status-based message
+ if (!message) {
+ message = `Tool call ${status}`;
+ }
+
+ // Truncate and sanitize
+ return this.sanitizeErrorMessage(message, MAX_MESSAGE_LENGTH);
+ }
+
+ /**
+ * Extract error message from a value that could be:
+ * - A string (possibly JSON)
+ * - An Error object
+ * - An object with error/message properties
+ */
+ private extractErrorFromValue(value: unknown): string | undefined {
+ if (value === undefined || value === null) {
+ return undefined;
+ }
+
+ // Handle Error objects
+ if (value instanceof Error) {
+ return value.message;
+ }
+
+ // Handle string values
+ if (typeof value === "string") {
+ // Try to parse as JSON
+ const parsed = safeJsonParse(value);
+ if (parsed !== undefined) {
+ return this.extractErrorFromValue(parsed);
+ }
+ return value;
+ }
+
+ // Handle objects with error-related properties
+ if (typeof value === "object") {
+ const obj = value as Record;
+
+ // Check for common error patterns
+ if (typeof obj.error === "string" && obj.error.length > 0) {
+ return obj.error;
+ }
+ if (typeof obj.message === "string" && obj.message.length > 0) {
+ return obj.message;
+ }
+ if (
+ obj.error &&
+ typeof obj.error === "object" &&
+ typeof (obj.error as Record).message === "string"
+ ) {
+ return (obj.error as Record).message as string;
+ }
+
+ // If it's a failure result object, try to extract useful info
+ if (obj.success === false) {
+ if (typeof obj.error === "string") {
+ return obj.error;
+ }
+ // Return a stringified version as last resort
+ try {
+ return JSON.stringify(obj);
+ } catch {
+ return undefined;
+ }
+ }
+ }
+
+ return undefined;
+ }
+
+ /**
+ * Sanitize and truncate error message for safe display.
+ * - Truncates to maxLength
+ * - Masks potential sensitive patterns (tokens, auth headers)
+ */
+ private sanitizeErrorMessage(message: string, maxLength: number): string {
+ let sanitized = message;
+
+ // Mask potential sensitive patterns
+ // Authorization headers
+ sanitized = sanitized.replace(
+ /Authorization:\s*(Bearer\s+)?[^\s,}"\]]+/gi,
+ "Authorization: [REDACTED]",
+ );
+ // API keys patterns
+ sanitized = sanitized.replace(
+ /(['"](api[_-]?key|apikey|token|secret|password)['"]\s*[=:]\s*['"])[^'"]+(['"])/gi,
+ "$1[REDACTED]$3",
+ );
+ // Bearer tokens in JSON
+ sanitized = sanitized.replace(
+ /(bearer\s+)[a-zA-Z0-9._-]{20,}/gi,
+ "$1[REDACTED]",
+ );
+
+ // Truncate if needed
+ if (sanitized.length > maxLength) {
+ sanitized = `${sanitized.substring(0, maxLength - 3)}...`;
+ }
+
+ return sanitized;
+ }
+
private applyUsageMetrics(
metrics: AgentMetrics,
result: { rawResponses?: Array<{ usage?: UsageShape }> },
): void {
const responses = result.rawResponses ?? [];
- let promptTokens = 0;
- let completionTokens = 0;
- for (const response of responses) {
- if (!response.usage) continue;
- promptTokens += response.usage.inputTokens ?? 0;
- completionTokens += response.usage.outputTokens ?? 0;
+ // Use the LAST response with usage data (typically the final model response)
+ // This represents the total tokens for this execution, not a running sum
+ let lastUsage: UsageShape | undefined;
+ for (let i = responses.length - 1; i >= 0; i--) {
+ const response = responses[i];
+ if (response?.usage) {
+ lastUsage = response.usage;
+ break;
+ }
}
+ const promptTokens = lastUsage?.inputTokens ?? 0;
+ const completionTokens = lastUsage?.outputTokens ?? 0;
+
metrics.promptTokens = promptTokens;
metrics.completionTokens = completionTokens;
metrics.tokensUsed = promptTokens + completionTokens;
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index e4c3dfb..8bde618 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -126,7 +126,7 @@ export type AgentEvent =
| { type: "contexts_attached"; contexts: Context[] }
| { type: "contexts_loaded"; providerId: string; count: number }
| { type: "context_error"; providerId: string; error: Error }
- | { type: "metrics_update"; metrics: AgentMetrics }
+ | { type: "metrics_update"; metrics: AgentMetrics; sessionId?: string }
| { type: "error"; error: AgentError }
| {
type: "execution_complete";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2e945d1..05bf863 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -80,6 +80,9 @@ importers:
'@radix-ui/react-use-controllable-state':
specifier: ^1.2.2
version: 1.2.2(@types/react@19.2.8)(react@19.2.3)
+ '@ricky0123/vad-web':
+ specifier: ^0.0.27
+ version: 0.0.27
ai:
specifier: ^6.0.28
version: 6.0.28(zod@4.3.6)
@@ -119,6 +122,9 @@ importers:
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
+ three:
+ specifier: ^0.177.0
+ version: 0.177.0
tokenlens:
specifier: ^1.3.1
version: 1.3.1
@@ -135,6 +141,9 @@ importers:
'@testing-library/react':
specifier: ^16.3.0
version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@types/chrome':
+ specifier: 0.1.32
+ version: 0.1.32
'@types/react':
specifier: 19.2.8
version: 19.2.8
@@ -144,6 +153,9 @@ importers:
'@types/react-syntax-highlighter':
specifier: ^15.5.13
version: 15.5.13
+ '@types/three':
+ specifier: ^0.177.0
+ version: 0.177.0
jsdom:
specifier: ^27.4.0
version: 27.4.0(@noble/hashes@1.8.0)
@@ -222,6 +234,9 @@ importers:
'@radix-ui/react-use-controllable-state':
specifier: ^1.2.2
version: 1.2.2(@types/react@19.2.8)(react@19.2.3)
+ '@ricky0123/vad-web':
+ specifier: ^0.0.27
+ version: 0.0.27
ahooks:
specifier: ^3.9.6
version: 3.9.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -255,6 +270,9 @@ importers:
nanoid:
specifier: ^5.1.6
version: 5.1.6
+ onnxruntime-web:
+ specifier: ^1.22.0
+ version: 1.24.1
react:
specifier: 19.2.3
version: 19.2.3
@@ -396,8 +414,8 @@ importers:
specifier: ^0.4.6
version: 0.4.6(@ai-sdk/provider@3.0.8)(@openai/agents@0.4.3(ws@8.19.0)(zod@4.3.6))(ai@6.0.28(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)
'@openrouter/ai-sdk-provider':
- specifier: ^0.4.0
- version: 0.4.6(zod@4.3.6)
+ specifier: ^2.0.0
+ version: 2.1.1(ai@6.0.28(zod@4.3.6))(zod@4.3.6)
lru-cache:
specifier: ^11.2.4
version: 11.2.4
@@ -471,15 +489,6 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
- '@ai-sdk/provider-utils@2.1.10':
- resolution: {integrity: sha512-4GZ8GHjOFxePFzkl3q42AU0DQOtTQ5w09vmaWUf/pKFXJPizlnzKSUkF0f+VkapIUfDugyMqPMT1ge8XQzVI7Q==}
- engines: {node: '>=18'}
- peerDependencies:
- zod: ^3.0.0
- peerDependenciesMeta:
- zod:
- optional: true
-
'@ai-sdk/provider-utils@4.0.10':
resolution: {integrity: sha512-VeDAiCH+ZK8Xs4hb9Cw7pHlujWNL52RKe8TExOkrw6Ir1AmfajBZTb9XUdKOZO08RwQElIKA8+Ltm+Gqfo8djQ==}
engines: {node: '>=18'}
@@ -504,10 +513,6 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
- '@ai-sdk/provider@1.0.9':
- resolution: {integrity: sha512-jie6ZJT2ZR0uVOVCDc9R2xCX5I/Dum/wEK28lx21PJx6ZnFAN9EzD2WsPhcDWfCgGx3OAZZ0GyM3CEobXpa9LA==}
- engines: {node: '>=18'}
-
'@ai-sdk/provider@3.0.2':
resolution: {integrity: sha512-HrEmNt/BH/hkQ7zpi2o6N3k1ZR1QTb7z85WYhYygiTxOQuaml4CMtHCWRbric5WPU+RNsYI7r1EpyVQMKO1pYw==}
engines: {node: '>=18'}
@@ -879,6 +884,9 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
+ '@dimforge/rapier3d-compat@0.12.0':
+ resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
+
'@emnapi/core@1.7.1':
resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
@@ -1198,11 +1206,12 @@ packages:
peerDependencies:
zod: ^4.0.0
- '@openrouter/ai-sdk-provider@0.4.6':
- resolution: {integrity: sha512-oUa8xtssyUhiKEU/aW662lsZ0HUvIUTRk8vVIF3Ha3KI/DnqX54zmVIuzYnaDpermqhy18CHqblAY4dDt1JW3g==}
+ '@openrouter/ai-sdk-provider@2.1.1':
+ resolution: {integrity: sha512-UypPbVnSExxmG/4Zg0usRiit3auvQVrjUXSyEhm0sZ9GQnW/d8p/bKgCk2neh1W5YyRSo7PNQvCrAEBHZnqQkQ==}
engines: {node: '>=18'}
peerDependencies:
- zod: ^3.0.0
+ ai: ^6.0.0
+ zod: ^3.25.0 || ^4.0.0
'@opentelemetry/api@1.9.0':
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
@@ -1308,6 +1317,36 @@ packages:
cpu: [x64]
os: [win32]
+ '@protobufjs/aspromise@1.1.2':
+ resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
+
+ '@protobufjs/base64@1.1.2':
+ resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
+
+ '@protobufjs/codegen@2.0.4':
+ resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
+
+ '@protobufjs/eventemitter@1.1.0':
+ resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
+
+ '@protobufjs/fetch@1.1.0':
+ resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==}
+
+ '@protobufjs/float@1.0.2':
+ resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
+
+ '@protobufjs/inquire@1.1.0':
+ resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
+
+ '@protobufjs/path@1.1.2':
+ resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
+
+ '@protobufjs/pool@1.1.0':
+ resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
+
+ '@protobufjs/utf8@1.1.0':
+ resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
+
'@puppeteer/browsers@2.11.1':
resolution: {integrity: sha512-YmhAxs7XPuxN0j7LJloHpfD1ylhDuFmmwMvfy/+6nBSrETT2ycL53LrhgPtR+f+GcPSybQVuQ5inWWu5MrWCpA==}
engines: {node: '>=18'}
@@ -1774,6 +1813,9 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
+ '@ricky0123/vad-web@0.0.27':
+ resolution: {integrity: sha512-4XFng44oj7qFQUrVYFpMnwRYJDFYrGUL0FmPWcrkF0gPneubJbu8KJvp+WaKn+70GNw2gwGZUMvd9hHiCJkUNg==}
+
'@rolldown/pluginutils@1.0.0-beta.47':
resolution: {integrity: sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==}
@@ -2035,6 +2077,9 @@ packages:
'@tootallnate/quickjs-emscripten@0.23.0':
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
+ '@tweenjs/tween.js@23.1.3':
+ resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
+
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -2115,12 +2160,21 @@ packages:
'@types/react@19.2.8':
resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==}
+ '@types/stats.js@0.17.4':
+ resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
+
+ '@types/three@0.177.0':
+ resolution: {integrity: sha512-/ZAkn4OLUijKQySNci47lFO+4JLE1TihEjsGWPUT+4jWqxtwOPPEwJV1C3k5MEx0mcBPCdkFjzRzDOnHEI1R+A==}
+
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+ '@types/webxr@0.5.24':
+ resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
+
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -2172,6 +2226,9 @@ packages:
'@webcomponents/custom-elements@1.6.0':
resolution: {integrity: sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww==}
+ '@webgpu/types@0.1.69':
+ resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==}
+
'@xterm/xterm@5.5.0':
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
@@ -3034,6 +3091,9 @@ packages:
resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==}
engines: {node: '>=6'}
+ flatbuffers@25.9.23:
+ resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==}
+
flow-parser@0.299.0:
resolution: {integrity: sha512-phGMRoNt6SNglPHGRbCyWm9/pxfe6t/t4++EIYPaBGWT6e0lphLBgUMrvpL62NbRo9R549o3oqrbKHq82kANCw==}
engines: {node: '>=0.4.0'}
@@ -3169,6 +3229,9 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ guid-typescript@1.0.9:
+ resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==}
+
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@@ -3564,6 +3627,9 @@ packages:
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
+ long@5.3.2:
+ resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
+
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -3697,6 +3763,9 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
+ meshoptimizer@0.18.1:
+ resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==}
+
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
@@ -3922,6 +3991,12 @@ packages:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
+ onnxruntime-common@1.24.1:
+ resolution: {integrity: sha512-UnV15u4p4XxoIV+jFP4hXPsW93s3QrwLSpi20HUDYHoTfI4z4sjzex3L4XDOxGGZJ/M/catrwAG2go958UQq0w==}
+
+ onnxruntime-web@1.24.1:
+ resolution: {integrity: sha512-i2u395dv+ZEQBdH+aORvlu19Bzvlg5AXJ7wjxnL350hknOP9z0UeP3pVfjkpMEWMPy2T6nCQxetKTmNia6wSzg==}
+
openai@6.15.0:
resolution: {integrity: sha512-F1Lvs5BoVvmZtzkUEVyh8mDQPPFolq4F+xdsx/DO8Hee8YF3IGAlZqUIsF+DVGhqf4aU0a3bTghsxB6OIsRy1g==}
hasBin: true
@@ -3934,18 +4009,6 @@ packages:
zod:
optional: true
- openai@6.18.0:
- resolution: {integrity: sha512-odLRYyz9rlzz6g8gKn61RM2oP5UUm428sE2zOxZqS9MzVfD5/XW8UoEjpnRkzTuScXP7ZbP/m7fC+bl8jCOZZw==}
- hasBin: true
- peerDependencies:
- ws: ^8.18.0
- zod: ^3.25 || ^4.0
- peerDependenciesMeta:
- ws:
- optional: true
- zod:
- optional: true
-
oxc-resolver@11.16.4:
resolution: {integrity: sha512-nvJr3orFz1wNaBA4neRw7CAn0SsjgVaEw1UHpgO/lzVW12w+nsFnvU/S6vVX3kYyFaZdxZheTExi/fa8R8PrZA==}
@@ -4075,6 +4138,9 @@ packages:
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
+ platform@1.3.6:
+ resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==}
+
plimit-lit@1.6.1:
resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==}
engines: {node: '>=12'}
@@ -4171,6 +4237,10 @@ packages:
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
+ protobufjs@7.5.4:
+ resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
+ engines: {node: '>=12.0.0'}
+
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -4423,9 +4493,6 @@ packages:
resolution: {integrity: sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==}
engines: {node: '>=0.10.0'}
- secure-json-parse@2.7.0:
- resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==}
-
semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
@@ -4628,6 +4695,9 @@ packages:
text-decoder@1.2.3:
resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
+ three@0.177.0:
+ resolution: {integrity: sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg==}
+
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -5059,15 +5129,6 @@ snapshots:
'@ai-sdk/provider-utils': 4.0.13(zod@4.3.6)
zod: 4.3.6
- '@ai-sdk/provider-utils@2.1.10(zod@4.3.6)':
- dependencies:
- '@ai-sdk/provider': 1.0.9
- eventsource-parser: 3.0.6
- nanoid: 3.3.11
- secure-json-parse: 2.7.0
- optionalDependencies:
- zod: 4.3.6
-
'@ai-sdk/provider-utils@4.0.10(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 3.0.5
@@ -5096,10 +5157,6 @@ snapshots:
eventsource-parser: 3.0.6
zod: 4.3.6
- '@ai-sdk/provider@1.0.9':
- dependencies:
- json-schema: 0.4.0
-
'@ai-sdk/provider@3.0.2':
dependencies:
json-schema: 0.4.0
@@ -5573,6 +5630,8 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {}
+ '@dimforge/rapier3d-compat@0.12.0': {}
+
'@emnapi/core@1.7.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
@@ -5807,7 +5866,7 @@ snapshots:
'@openai/agents-core@0.4.6(ws@8.19.0)(zod@4.3.6)':
dependencies:
debug: 4.4.3
- openai: 6.18.0(ws@8.19.0)(zod@4.3.6)
+ openai: 6.15.0(ws@8.19.0)(zod@4.3.6)
optionalDependencies:
'@modelcontextprotocol/sdk': 1.26.0(zod@4.3.6)
zod: 4.3.6
@@ -5870,10 +5929,9 @@ snapshots:
- utf-8-validate
- ws
- '@openrouter/ai-sdk-provider@0.4.6(zod@4.3.6)':
+ '@openrouter/ai-sdk-provider@2.1.1(ai@6.0.28(zod@4.3.6))(zod@4.3.6)':
dependencies:
- '@ai-sdk/provider': 1.0.9
- '@ai-sdk/provider-utils': 2.1.10(zod@4.3.6)
+ ai: 6.0.28(zod@4.3.6)
zod: 4.3.6
'@opentelemetry/api@1.9.0': {}
@@ -5940,6 +5998,29 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.16.4':
optional: true
+ '@protobufjs/aspromise@1.1.2': {}
+
+ '@protobufjs/base64@1.1.2': {}
+
+ '@protobufjs/codegen@2.0.4': {}
+
+ '@protobufjs/eventemitter@1.1.0': {}
+
+ '@protobufjs/fetch@1.1.0':
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ '@protobufjs/inquire': 1.1.0
+
+ '@protobufjs/float@1.0.2': {}
+
+ '@protobufjs/inquire@1.1.0': {}
+
+ '@protobufjs/path@1.1.2': {}
+
+ '@protobufjs/pool@1.1.0': {}
+
+ '@protobufjs/utf8@1.1.0': {}
+
'@puppeteer/browsers@2.11.1':
dependencies:
debug: 4.4.3
@@ -6410,6 +6491,10 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
+ '@ricky0123/vad-web@0.0.27':
+ dependencies:
+ onnxruntime-web: 1.24.1
+
'@rolldown/pluginutils@1.0.0-beta.47': {}
'@rollup/pluginutils@4.2.1':
@@ -6610,6 +6695,8 @@ snapshots:
'@tootallnate/quickjs-emscripten@0.23.0': {}
+ '@tweenjs/tween.js@23.1.3': {}
+
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
@@ -6704,10 +6791,24 @@ snapshots:
dependencies:
csstype: 3.2.3
+ '@types/stats.js@0.17.4': {}
+
+ '@types/three@0.177.0':
+ dependencies:
+ '@dimforge/rapier3d-compat': 0.12.0
+ '@tweenjs/tween.js': 23.1.3
+ '@types/stats.js': 0.17.4
+ '@types/webxr': 0.5.24
+ '@webgpu/types': 0.1.69
+ fflate: 0.8.2
+ meshoptimizer: 0.18.1
+
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
+ '@types/webxr@0.5.24': {}
+
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.2.2
@@ -6774,6 +6875,8 @@ snapshots:
'@webcomponents/custom-elements@1.6.0': {}
+ '@webgpu/types@0.1.69': {}
+
'@xterm/xterm@5.5.0':
optional: true
@@ -7700,6 +7803,8 @@ snapshots:
dependencies:
locate-path: 3.0.0
+ flatbuffers@25.9.23: {}
+
flow-parser@0.299.0: {}
follow-redirects@1.15.11: {}
@@ -7837,6 +7942,8 @@ snapshots:
graceful-fs@4.2.11: {}
+ guid-typescript@1.0.9: {}
+
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
@@ -8255,6 +8362,8 @@ snapshots:
lodash@4.17.21: {}
+ long@5.3.2: {}
+
longest-streak@3.1.0: {}
lowlight@1.20.0:
@@ -8465,6 +8574,8 @@ snapshots:
merge2@1.4.1: {}
+ meshoptimizer@0.18.1: {}
+
micromark-core-commonmark@2.0.3:
dependencies:
decode-named-character-reference: 1.3.0
@@ -8761,12 +8872,18 @@ snapshots:
dependencies:
mimic-fn: 2.1.0
- openai@6.15.0(ws@8.19.0)(zod@4.3.6):
- optionalDependencies:
- ws: 8.19.0
- zod: 4.3.6
+ onnxruntime-common@1.24.1: {}
- openai@6.18.0(ws@8.19.0)(zod@4.3.6):
+ onnxruntime-web@1.24.1:
+ dependencies:
+ flatbuffers: 25.9.23
+ guid-typescript: 1.0.9
+ long: 5.3.2
+ onnxruntime-common: 1.24.1
+ platform: 1.3.6
+ protobufjs: 7.5.4
+
+ openai@6.15.0(ws@8.19.0)(zod@4.3.6):
optionalDependencies:
ws: 8.19.0
zod: 4.3.6
@@ -8917,6 +9034,8 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
+ platform@1.3.6: {}
+
plimit-lit@1.6.1:
dependencies:
queue-lit: 1.5.2
@@ -9007,6 +9126,21 @@ snapshots:
property-information@7.1.0: {}
+ protobufjs@7.5.4:
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ '@protobufjs/base64': 1.1.2
+ '@protobufjs/codegen': 2.0.4
+ '@protobufjs/eventemitter': 1.1.0
+ '@protobufjs/fetch': 1.1.0
+ '@protobufjs/float': 1.0.2
+ '@protobufjs/inquire': 1.1.0
+ '@protobufjs/path': 1.1.2
+ '@protobufjs/pool': 1.1.0
+ '@protobufjs/utf8': 1.1.0
+ '@types/node': 25.2.2
+ long: 5.3.2
+
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
@@ -9347,8 +9481,6 @@ snapshots:
screenfull@5.2.0: {}
- secure-json-parse@2.7.0: {}
-
semver@5.7.2: {}
semver@6.3.1: {}
@@ -9624,6 +9756,8 @@ snapshots:
transitivePeerDependencies:
- react-native-b4a
+ three@0.177.0: {}
+
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}