diff --git a/apps/electron/electron.vite.config.ts b/apps/electron/electron.vite.config.ts index f3f46fd2f..a039fe53b 100644 --- a/apps/electron/electron.vite.config.ts +++ b/apps/electron/electron.vite.config.ts @@ -102,7 +102,13 @@ export default defineConfig({ input: resolve(__dirname, 'src/renderer/index.html'), external: [ 'web-worker', - 'mermaid' // Optional peer dependency - dynamically imported in @xnetjs/canvas + 'mermaid', // Optional peer dependency - dynamically imported in @xnetjs/canvas + // Native Node HNSW addon (imports node:fs / node-gyp-build) — reached + // via @xnetjs/workbench/ai → @xnetjs/vectors, which dynamically + // imports it and falls back to the pure-JS LinearVectorIndex when + // absent. Same exclusion apps/web ships; the renderer must not try + // to bundle it. + 'usearch' ] } }, diff --git a/apps/electron/package.json b/apps/electron/package.json index ba6a07473..9050aef9e 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -47,6 +47,7 @@ "@xnetjs/telemetry": "workspace:*", "@xnetjs/ui": "workspace:*", "@xnetjs/views": "workspace:*", + "@xnetjs/workbench": "workspace:*", "better-sqlite3": "^11.0.0", "electron-updater": "^6.3.0", "lucide-react": "^0.400.0", diff --git a/apps/electron/src/renderer/App.tsx b/apps/electron/src/renderer/App.tsx index fa3144f44..fc306d506 100644 --- a/apps/electron/src/renderer/App.tsx +++ b/apps/electron/src/renderer/App.tsx @@ -10,6 +10,7 @@ import type { ConnectHubRequest } from './components/ConnectHubDialog' import { useCommandPalette, CommandPalette } from '@xnetjs/ui' +import { AiChatPanel } from '@xnetjs/workbench/ai' import React, { useCallback, useEffect, useState } from 'react' import { ActionDock } from './components/ActionDock' import { AddSharedDialog } from './components/AddSharedDialog' @@ -55,6 +56,7 @@ export function App(): React.ReactElement { handleOpenDataWorkspace, handleOpenMeetings, handleOpenStories, + handleOpenAssistant, handleInsertSavedLensAsCanvasFrame, handleCommandStateChange, handlePendingInsertConsumed @@ -102,7 +104,8 @@ export function App(): React.ReactElement { handleOpenSettings, handleOpenSocialImport, handleOpenDataWorkspace, - handleOpenStories + handleOpenStories, + handleOpenAssistant }) const renderOverlay = () => { @@ -135,6 +138,21 @@ export function App(): React.ReactElement { ) } + if (shellState.kind === 'assistant') { + // The web workbench's AI chat, unchanged (0406): it detects the agent + // bridge through window.xnetAgentBridge (#638) and auto-pairs over IPC, + // so on desktop Claude Code arrives with the workspace tools attached. + return ( +
+
+
+ +
+
+
+ ) + } + if (shellState.kind === 'social-import') { return (
@@ -266,6 +284,7 @@ export function App(): React.ReactElement { onOpenSettings={handleOpenSettings} onOpenDataWorkspace={handleOpenDataWorkspace} onOpenMeetings={handleOpenMeetings} + onOpenAssistant={handleOpenAssistant} onOpenSocialImport={handleOpenSocialImport} onOpenStories={STORIES_ENABLED ? handleOpenStories : undefined} onAddShared={() => { diff --git a/apps/electron/src/renderer/components/SystemMenu.tsx b/apps/electron/src/renderer/components/SystemMenu.tsx index 5a376cc6b..f43f7ca45 100644 --- a/apps/electron/src/renderer/components/SystemMenu.tsx +++ b/apps/electron/src/renderer/components/SystemMenu.tsx @@ -16,6 +16,7 @@ import { Moon, Settings, Share2, + Sparkles, Sun } from 'lucide-react' import React from 'react' @@ -32,6 +33,7 @@ interface SystemMenuProps { onOpenSettings: () => void onOpenDataWorkspace: () => void onOpenMeetings: () => void + onOpenAssistant: () => void onOpenSocialImport: () => void onOpenStories?: () => void onAddShared: () => void @@ -77,6 +79,7 @@ export function SystemMenu({ onOpenSettings, onOpenDataWorkspace, onOpenMeetings, + onOpenAssistant, onOpenSocialImport, onOpenStories, onAddShared, @@ -128,6 +131,12 @@ export function SystemMenu({ Meetings + + + + Assistant + + diff --git a/apps/electron/src/renderer/shell/shell-state.ts b/apps/electron/src/renderer/shell/shell-state.ts index 54ec38b02..42cc31e95 100644 --- a/apps/electron/src/renderer/shell/shell-state.ts +++ b/apps/electron/src/renderer/shell/shell-state.ts @@ -26,6 +26,7 @@ export type ShellState = | { kind: 'social-import' } | { kind: 'meetings' } | { kind: 'stories' } + | { kind: 'assistant' } export type DocumentItem = { id: string @@ -56,6 +57,7 @@ export type ShellAction = | { type: 'open-social-import' } | { type: 'open-meetings' } | { type: 'open-stories' } + | { type: 'open-assistant' } export function shellReducer(_state: ShellState, action: ShellAction): ShellState { switch (action.type) { @@ -77,6 +79,8 @@ export function shellReducer(_state: ShellState, action: ShellAction): ShellStat return { kind: 'meetings' } case 'open-stories': return { kind: 'stories' } + case 'open-assistant': + return { kind: 'assistant' } } } @@ -88,6 +92,7 @@ export function overlayTitleFor(kind: ShellState['kind']): string | null { if (kind === 'social-import') return 'Social Import' if (kind === 'meetings') return 'Meetings' if (kind === 'stories') return 'Stories' + if (kind === 'assistant') return 'Assistant' return null } diff --git a/apps/electron/src/renderer/shell/use-document-shell.ts b/apps/electron/src/renderer/shell/use-document-shell.ts index ca2982376..aa7cff299 100644 --- a/apps/electron/src/renderer/shell/use-document-shell.ts +++ b/apps/electron/src/renderer/shell/use-document-shell.ts @@ -84,6 +84,7 @@ export interface DocumentShell { handleOpenDataWorkspace: () => void handleOpenMeetings: () => void handleOpenStories: () => void + handleOpenAssistant: () => void handleInsertSavedLensAsCanvasFrame: (view: SavedViewCanvasFrameInput) => void handleCommandStateChange: Dispatch> handlePendingInsertConsumed: (requestId: string) => void @@ -408,6 +409,10 @@ export function useDocumentShell(): DocumentShell { transitionShell({ type: 'open-stories' }) }, [transitionShell]) + const handleOpenAssistant = useCallback(() => { + transitionShell({ type: 'open-assistant' }) + }, [transitionShell]) + const handlePendingInsertConsumed = useCallback((requestId: string) => { setPendingCanvasInsert((current) => (current?.requestId === requestId ? null : current)) }, []) @@ -441,6 +446,7 @@ export function useDocumentShell(): DocumentShell { handleOpenDataWorkspace, handleOpenMeetings, handleOpenStories, + handleOpenAssistant, handleInsertSavedLensAsCanvasFrame, handleCommandStateChange: setCanvasCommandState, handlePendingInsertConsumed diff --git a/apps/electron/src/renderer/shell/use-shell-palette-commands.ts b/apps/electron/src/renderer/shell/use-shell-palette-commands.ts index baa3e3cff..ebd387d2f 100644 --- a/apps/electron/src/renderer/shell/use-shell-palette-commands.ts +++ b/apps/electron/src/renderer/shell/use-shell-palette-commands.ts @@ -26,6 +26,7 @@ export interface ShellPaletteCommandsOptions { handleOpenSocialImport: () => void handleOpenDataWorkspace: () => void handleOpenStories: () => void + handleOpenAssistant: () => void } export function useShellPaletteCommands(options: ShellPaletteCommandsOptions): PaletteCommand[] { @@ -41,7 +42,8 @@ export function useShellPaletteCommands(options: ShellPaletteCommandsOptions): P handleOpenSettings, handleOpenSocialImport, handleOpenDataWorkspace, - handleOpenStories + handleOpenStories, + handleOpenAssistant } = options return useMemo( @@ -505,6 +507,15 @@ export function useShellPaletteCommands(options: ShellPaletteCommandsOptions): P keywords: ['data', 'workspace', 'social', 'saved views', 'lenses'], execute: handleOpenDataWorkspace }, + { + id: 'open-assistant', + name: 'Open Assistant', + description: 'Chat with your workspace — Claude Code arrives with the xnet tools attached', + icon: 'sparkles', + group: 'Navigate', + keywords: ['ai', 'chat', 'claude', 'assistant', 'agent'], + execute: handleOpenAssistant + }, ...(STORIES_ENABLED ? [ { @@ -540,6 +551,7 @@ export function useShellPaletteCommands(options: ShellPaletteCommandsOptions): P handleOpenSettings, handleOpenSocialImport, handleOpenStories, + handleOpenAssistant, canvasCommandState, isCanvasInteractiveShell, recentDocuments, diff --git a/apps/web/src/workbench/views/AiChatPanel.ts b/apps/web/src/workbench/views/AiChatPanel.ts new file mode 100644 index 000000000..ff4ff6085 --- /dev/null +++ b/apps/web/src/workbench/views/AiChatPanel.ts @@ -0,0 +1,5 @@ +/** + * Shim (0406): canonical module lives in @xnetjs/workbench/ai. New code + * imports the package directly. + */ +export * from '@xnetjs/workbench/ai' diff --git a/apps/web/src/workbench/views/ai-chat-connector.ts b/apps/web/src/workbench/views/ai-chat-connector.ts index 1174b80dc..ff4ff6085 100644 --- a/apps/web/src/workbench/views/ai-chat-connector.ts +++ b/apps/web/src/workbench/views/ai-chat-connector.ts @@ -1,440 +1,5 @@ /** - * Maps a detected model connector + local settings to an AIProviderConfig - * (exploration 0174). Pure, so it is unit-tested without a browser. - * - * The cloud-key and local-server / bridge tiers resolve to a `createAIProvider` - * config. The in-tab tiers (webllm, prompt-api) need an injected engine/session - * and are constructed directly in the panel, so they return null here. + * Shim (0406): canonical module lives in @xnetjs/workbench/ai. New code + * imports the package directly. */ - -import type { AIProviderConfig, ConnectorDetection, ConnectorTier } from '@xnetjs/plugins' - -export type CloudProvider = 'anthropic' | 'openai' | 'openrouter' - -export interface AiChatSettings { - /** BYO cloud API key (stored locally, never sent to the hub). */ - apiKey?: string - /** Which cloud provider the key is for. */ - cloudProvider?: CloudProvider - /** Optional model id override. */ - model?: string - /** Base URL override for the local-server tier. */ - localBaseUrl?: string - /** Hub base URL for the managed tier (default `''` = same origin). */ - hubBaseUrl?: string - /** - * Pairing code for the local bridge daemon, sent as `Authorization: Bearer`. - * Under Electron it's auto-supplied over IPC; in a plain browser the user - * pastes the code `xnet bridge serve` prints. - */ - bridgeToken?: string -} - -/** localStorage keys (xnet:* convention). */ -export const AI_CHAT_STORAGE_KEYS = { - apiKey: 'xnet:ai-api-key', - cloudProvider: 'xnet:ai-cloud-provider', - model: 'xnet:ai-model', - localBaseUrl: 'xnet:ai-local-base-url', - /** The local-bridge pairing code (survives reload; per-launch tokens re-pair). */ - bridgeToken: 'xnet:ai-bridge-token', - /** The connector tier the user last selected (survives reload). */ - tier: 'xnet:ai-tier', - /** Opt-in: use on-device semantic (vector) entry search (exploration 0211). */ - semanticSearch: 'xnet:ai-semantic-search' -} as const - -/** Connector tiers that resolve to a `createAIProvider` config (vs. in-tab). */ -export const PROVIDER_CONFIG_TIERS: readonly ConnectorTier[] = [ - 'managed', - 'cloud-key', - 'local-server', - 'bridge' -] - -/** - * Tiers the panel can actually instantiate a provider for: the config-backed - * tiers plus the in-tab tiers `prompt-api` (built from an injected session) and - * `webllm` (built from a host-supplied `@mlc-ai/web-llm` engine, see - * `ai-webllm-engine.ts`). `webllm` is now safe to auto-select because the heavy - * model download is gated behind an explicit "load" gesture in the panel rather - * than firing the moment the tier is chosen. - */ -export const USABLE_TIERS: readonly ConnectorTier[] = [ - ...PROVIDER_CONFIG_TIERS, - 'prompt-api', - 'webllm' -] - -/** Whether the panel can build a working provider for this tier right now. */ -export function isUsableTier(tier: ConnectorTier): boolean { - return USABLE_TIERS.includes(tier) -} - -/** - * The most-preferred *available and usable* connector, or null. Mirrors - * `pickBestConnector` but skips tiers the panel can't instantiate (webllm), - * relying on the same preference ordering of the input. - */ -export function pickUsableConnector( - detections: readonly ConnectorDetection[] -): ConnectorDetection | null { - return detections.find((d) => d.available && isUsableTier(d.tier)) ?? null -} - -/** - * Resolve an AIProviderConfig for a connector, or null when the tier needs an - * in-tab engine (webllm / prompt-api) or required settings are missing. - */ -export function providerConfigForConnector( - detection: ConnectorDetection, - settings: AiChatSettings -): AIProviderConfig | null { - switch (detection.tier) { - case 'managed': { - // No key and no base-URL typing: the hub is the origin and injects the - // per-tenant credential. The model comes from the picker / plan default. - return { - type: 'managed', - options: { - baseUrl: settings.hubBaseUrl ?? '', - ...(settings.model ? { model: settings.model } : {}) - } - } - } - case 'cloud-key': { - if (!settings.apiKey) return null - const type = settings.cloudProvider ?? 'anthropic' - return { - type, - options: { apiKey: settings.apiKey, ...(settings.model ? { model: settings.model } : {}) } - } - } - case 'local-server': { - const baseUrl = settings.localBaseUrl ?? baseUrlFromDetail(detection.detail) - const type = /lm studio/i.test(detection.detail ?? '') ? 'lmstudio' : 'ollama' - return { - type, - options: { - ...(baseUrl ? { baseUrl } : {}), - ...(settings.model ? { model: settings.model } : {}) - } - } - } - case 'bridge': { - // The bridge daemon exposes an OpenAI-compatible endpoint on loopback and - // now requires the pairing code as `Authorization: Bearer` — without it the - // daemon answers 401, so treat a missing code as "not configured yet". - const baseUrl = baseUrlFromDetail(detection.detail) - if (!baseUrl || !settings.bridgeToken) return null - return { - type: 'openai-compatible', - options: { - baseUrl, - apiKey: settings.bridgeToken, - ...(settings.model ? { model: settings.model } : {}) - } - } - } - default: - // webllm / prompt-api are constructed directly with an injected engine. - return null - } -} - -/** Extract the `(http://host:port)` base URL embedded in a detection detail. */ -export function baseUrlFromDetail(detail: string | undefined): string | undefined { - if (!detail) return undefined - const match = detail.match(/\((https?:\/\/[^)]+)\)/) - if (match) return match[1] - return /^https?:\/\//.test(detail) ? detail : undefined -} - -// ─── Managed model catalog (the model picker) ─────────────────────────────────── - -/** One selectable managed model, as `GET /ai/models` returns it. */ -export interface ManagedModel { - id: string - name: string - family: string - inUsdPerM: number | null - outUsdPerM: number | null - contextLength: number | null - modality: string | null -} - -export interface ManagedModelsResult { - models: ManagedModel[] - defaultModel: string | null -} - -const asNumberOrNull = (value: unknown): number | null => - typeof value === 'number' && Number.isFinite(value) ? value : null - -/** Parse a `GET /ai/models` body into a typed, defensively-narrowed result. */ -export function parseModelsResponse(data: unknown): ManagedModelsResult { - if (!data || typeof data !== 'object') return { models: [], defaultModel: null } - const record = data as Record - const raw = Array.isArray(record.models) ? record.models : [] - const models: ManagedModel[] = raw.flatMap((entry) => { - if (!entry || typeof entry !== 'object') return [] - const m = entry as Record - if (typeof m.id !== 'string') return [] - return [ - { - id: m.id, - name: typeof m.name === 'string' ? m.name : m.id, - family: typeof m.family === 'string' ? m.family : (m.id.split('/')[0] ?? m.id), - inUsdPerM: asNumberOrNull(m.inUsdPerM), - outUsdPerM: asNumberOrNull(m.outUsdPerM), - contextLength: asNumberOrNull(m.contextLength), - modality: typeof m.modality === 'string' ? m.modality : null - } - ] - }) - return { - models, - defaultModel: typeof record.defaultModel === 'string' ? record.defaultModel : null - } -} - -/** Fetch the plan-gated managed model catalog; `[]` on any error (the picker hides). */ -export async function fetchManagedModels( - baseUrl: string, - fetchImpl: typeof fetch = fetch -): Promise { - try { - const res = await fetchImpl(`${baseUrl}/ai/models`, { credentials: 'include' }) - if (!res.ok) return { models: [], defaultModel: null } - return parseModelsResponse(await res.json()) - } catch { - return { models: [], defaultModel: null } - } -} - -/** A compact picker label: name + "$in/$out per Mtok" + context when known. */ -export function formatModelOption(model: ManagedModel): string { - const price = - model.inUsdPerM !== null && model.outUsdPerM !== null - ? ` · $${trimPrice(model.inUsdPerM)}/$${trimPrice(model.outUsdPerM)} per Mtok` - : '' - const context = model.contextLength ? ` · ${Math.round(model.contextLength / 1000)}k ctx` : '' - return `${model.name}${price}${context}` -} - -const trimPrice = (usdPerM: number): string => - usdPerM >= 1 ? usdPerM.toFixed(2).replace(/\.00$/, '') : usdPerM.toFixed(2) - -/** Group models by family for an ``-style picker, families sorted. */ -export function groupModelsByFamily(models: readonly ManagedModel[]): [string, ManagedModel[]][] { - const groups = new Map() - for (const model of models) { - const list = groups.get(model.family) ?? [] - list.push(model) - groups.set(model.family, list) - } - return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)) -} - -// ─── Bridge status (which agent the local bridge is driving) ──────────────────── - -export interface BridgeAgentOption { - id: string - label: string -} - -/** Coding agents the local bridge can drive (for the in-panel picker). */ -export const KNOWN_BRIDGE_AGENTS: readonly BridgeAgentOption[] = [ - { id: 'claude', label: 'Claude Code' }, - { id: 'codex', label: 'Codex' }, - { id: 'gemini', label: 'Gemini CLI' }, - { id: 'opencode', label: 'OpenCode' } -] - -export interface BridgeHealth { - ok: boolean - agent?: string - version?: string -} - -/** Parse a bridge daemon `/health` body (`bridgeHealth()` output). */ -export function parseBridgeHealth(data: unknown): BridgeHealth { - if (!data || typeof data !== 'object') return { ok: false } - const record = data as Record - return { - ok: record.ok === true, - ...(typeof record.agent === 'string' ? { agent: record.agent } : {}), - ...(typeof record.version === 'string' ? { version: record.version } : {}) - } -} - -// ─── OpenRouter PKCE connect (exploration 0391, Phase 3) ──────────────────────── -// -// The one "use my existing account" flow a provider explicitly designed for -// third-party apps: the user authorizes on openrouter.ai, we exchange the -// callback code (+ PKCE verifier) for a USER-SCOPED key that bills their own -// OpenRouter balance. No key copy-paste, no xNet billing relationship. This -// is the no-daemon fallback tier — the bridge (their Claude/Codex -// subscription) stays the daily-driver path. - -/** localStorage key holding the in-flight PKCE verifier (cleared on finish). */ -export const OPENROUTER_VERIFIER_KEY = 'xnet:ai-openrouter-verifier' - -const base64Url = (bytes: Uint8Array): string => - btoa(String.fromCharCode(...bytes)) - .replaceAll('+', '-') - .replaceAll('/', '_') - .replace(/=+$/, '') - -/** A fresh high-entropy PKCE code verifier (RFC 7636 §4.1). */ -export function createPkceVerifier( - getRandomValues: (bytes: Uint8Array) => Uint8Array = (bytes) => crypto.getRandomValues(bytes) -): string { - return base64Url(getRandomValues(new Uint8Array(32))) -} - -/** S256 code challenge for a verifier (RFC 7636 §4.2). */ -export async function pkceChallengeS256( - verifier: string, - subtle: SubtleCrypto = crypto.subtle -): Promise { - const digest = await subtle.digest('SHA-256', new TextEncoder().encode(verifier)) - return base64Url(new Uint8Array(digest)) -} - -/** The openrouter.ai authorization URL for a callback + challenge. */ -export function openRouterAuthUrl(callbackUrl: string, challenge: string): string { - const url = new URL('https://openrouter.ai/auth') - url.searchParams.set('callback_url', callbackUrl) - url.searchParams.set('code_challenge', challenge) - url.searchParams.set('code_challenge_method', 'S256') - return url.toString() -} - -/** Exchange the callback `code` for a user-scoped key; null on any failure. */ -export async function exchangeOpenRouterCode( - code: string, - verifier: string, - fetchImpl: typeof fetch = fetch -): Promise { - try { - const res = await fetchImpl('https://openrouter.ai/api/v1/auth/keys', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - code, - code_verifier: verifier, - code_challenge_method: 'S256' - }) - }) - if (!res.ok) return null - const data = (await res.json()) as { key?: unknown } - return typeof data.key === 'string' && data.key ? data.key : null - } catch { - return null - } -} - -/** The `?code=` an OpenRouter callback landed with, if any. */ -export function openRouterCallbackCode(search: string): string | null { - const code = new URLSearchParams(search).get('code') - return code && code.length > 0 ? code : null -} - -// ─── Chat runtime event handling (extracted so it stays pure + tested) ────────── - -export interface RuntimeEventLike { - type: string - threadId?: string - payload?: unknown -} - -/** The state change a runtime event implies, or null if it's not interesting. */ -/** What the assistant is doing with a tool, for the activity line. */ -export interface ChatToolActivity { - kind: 'call' | 'result' - tool: string - denied?: boolean -} - -/** The state change a runtime event implies, or null if it's not interesting. */ -export interface ChatEventEffect { - delta?: string - settled?: boolean - error?: string - activity?: ChatToolActivity -} - -export function reduceRuntimeEvent(event: RuntimeEventLike): ChatEventEffect | null { - if (event.type === 'model.delta') { - const text = (event.payload as { text?: string } | undefined)?.text - return text ? { delta: text } : null - } - if (event.type === 'tool.call') { - const call = (event.payload as { toolCall?: { name?: string } } | undefined)?.toolCall - return call?.name ? { activity: { kind: 'call', tool: call.name } } : null - } - if (event.type === 'tool.result') { - const payload = event.payload as { toolCall?: { name?: string }; denied?: boolean } | undefined - return payload?.toolCall?.name - ? { activity: { kind: 'result', tool: payload.toolCall.name, denied: payload.denied } } - : null - } - // NOT `model.completed`: with the tool loop (0394) a single run makes several - // model round trips, so that fires mid-turn. Settling on it would end the - // stream early and drop everything the assistant said after its first tool - // call. `run.completed` is the only event that means the turn is over. - if (event.type === 'run.completed') { - return { settled: true } - } - if (event.type === 'run.failed') { - const message = (event.payload as { error?: string } | undefined)?.error - return { settled: true, error: message ?? 'run failed' } - } - if (event.type === 'run.cancelled') { - return { settled: true } - } - return null -} - -export interface ChatEventHandlers { - onDelta: (text: string) => void - onSettled: () => void - onError: (message: string) => void - /** Optional: surface tool activity while the turn is still running. */ - onActivity?: (activity: ChatToolActivity) => void -} - -/** Apply a runtime event to the chat handlers, filtered to the active thread. */ -export function applyRuntimeEvent( - event: RuntimeEventLike, - activeThreadId: string | null, - handlers: ChatEventHandlers -): void { - if (event.threadId && event.threadId !== activeThreadId) return - const effect = reduceRuntimeEvent(event) - if (!effect) return - if (effect.delta) handlers.onDelta(effect.delta) - if (effect.activity) handlers.onActivity?.(effect.activity) - if (effect.settled) handlers.onSettled() - if (effect.error) handlers.onError(effect.error) -} - -/** Whether a message can be sent right now. */ -export function canSendMessage(content: string, streaming: boolean, hasRuntime: boolean): boolean { - return content.length > 0 && !streaming && hasRuntime -} - -export function errorMessage(err: unknown): string { - const raw = err instanceof Error ? err.message : String(err) - // A browser CORS block and an unreachable local server both surface as an - // opaque "Failed to fetch" / "Load failed" / "NetworkError". Turn that into - // the actionable next step instead of a dead-end stack message. - if (/failed to fetch|networkerror|load failed|\bcors\b/i.test(raw)) { - return ( - 'Could not reach the model. For a cloud key this is usually a CORS block; ' + - 'for a local model, allow this origin (set OLLAMA_ORIGINS or enable the LM Studio CORS toggle).' - ) - } - return raw -} +export * from '@xnetjs/workbench/ai' diff --git a/apps/web/src/workbench/views/ai-chat-persistence.ts b/apps/web/src/workbench/views/ai-chat-persistence.ts index 8fac19a35..ff4ff6085 100644 --- a/apps/web/src/workbench/views/ai-chat-persistence.ts +++ b/apps/web/src/workbench/views/ai-chat-persistence.ts @@ -1,89 +1,5 @@ /** - * Persist AI conversations as workspace nodes (exploration 0391, Phase 2). - * - * Every AI thread becomes a real Channel node and each turn a ChatMessage — - * the same schemas the comms surface uses — so a research conversation is - * searchable (FTS-indexed like everything else), linkable, syncable, and - * reopenable instead of evaporating with the panel. No new schema: reusing - * Channel/ChatMessage keeps seed coverage and the comms UI's rendering free. - * - * Persistence is deliberately fire-and-forget from the panel's perspective: - * a storage hiccup must never break the live chat (the transcript still - * lives in panel state), so every step collapses failures to a warning. + * Shim (0406): canonical module lives in @xnetjs/workbench/ai. New code + * imports the package directly. */ - -import type { DefinedSchema, InferCreateProps, PropertyBuilder } from '@xnetjs/data' -import { ChannelSchema, ChatMessageSchema } from '@xnetjs/data' - -/** The minimal typed-create surface we need (a `DataBridge` satisfies it). */ -export interface AiChatPersistenceStore { - create

>( - schema: DefinedSchema

, - data: InferCreateProps

, - id?: string - ): Promise -} - -/** Channel name for a conversation, derived from its opening message. */ -export function aiChannelName(firstUserMessage: string): string { - const compact = firstUserMessage.replace(/\s+/g, ' ').trim() - const clipped = compact.length > 48 ? `${compact.slice(0, 47)}…` : compact - return `AI · ${clipped || 'conversation'}` -} - -export interface AiConversationLog { - /** The Channel node id backing this conversation (set after the first turn). */ - readonly channelId: string | null - /** Record the user's message, creating the channel on the first call. */ - logUserMessage(content: string, connectorLabel: string): Promise - /** Record the assistant's settled reply. */ - logAssistantReply(content: string): Promise -} - -/** - * A per-conversation logger. Writes are serialized on an internal chain so - * the channel exists before its first message even when callers don't await. - */ -export function createAiConversationLog( - store: AiChatPersistenceStore, - options: { warn?: (message: string, error: unknown) => void } = {} -): AiConversationLog { - const warn = - options.warn ?? - ((message: string, error: unknown) => console.warn(`[ai-chat] ${message}`, error)) - let channelId: string | null = null - let chain: Promise = Promise.resolve() - - const enqueue = (step: () => Promise): Promise => { - chain = chain.then(step).catch((error) => { - warn('failed to persist conversation turn', error) - }) - return chain - } - - return { - get channelId() { - return channelId - }, - logUserMessage(content, connectorLabel) { - return enqueue(async () => { - if (!channelId) { - const node = (await store.create(ChannelSchema, { - kind: 'channel', - name: aiChannelName(content), - topic: `AI conversation — assistant replies via ${connectorLabel}` - })) as { id?: string } | null - if (!node?.id) throw new Error('channel create returned no id') - channelId = node.id - } - await store.create(ChatMessageSchema, { channel: channelId, content }) - }) - }, - logAssistantReply(content) { - return enqueue(async () => { - if (!channelId || !content.trim()) return - await store.create(ChatMessageSchema, { channel: channelId, content }) - }) - } - } -} +export * from '@xnetjs/workbench/ai' diff --git a/apps/web/src/workbench/views/ai-chat-tools.ts b/apps/web/src/workbench/views/ai-chat-tools.ts index f4c4e639a..ff4ff6085 100644 --- a/apps/web/src/workbench/views/ai-chat-tools.ts +++ b/apps/web/src/workbench/views/ai-chat-tools.ts @@ -1,78 +1,5 @@ /** - * Read-only tools for the in-app assistant (exploration 0394, Phase 1). - * - * The 28 `xnet_*` tools already reached MCP clients, the CLI, and the bridge; - * the in-app chat was the one consumer that never got them, so it could only - * answer from whatever context happened to be injected. This selects the - * subset it may call and turns it into provider tool specs. - * - * Phase 1 is deliberately read-only. Nothing here can write, propose, or - * fetch: the plan→approve→apply ceremony that makes writes safe has no in-chat - * surface yet, and shipping write tools before the consent UI would be exactly - * the over-promise the panel's capability badge used to warn about. + * Shim (0406): canonical module lives in @xnetjs/workbench/ai. New code + * imports the package directly. */ - -import type { AiSurfaceService, AIToolSpec, ToolCallingFidelity } from '@xnetjs/plugins' - -/** - * The Phase 1 allow-list. Kept explicit rather than derived from `risk: 'low'` - * alone so that adding a low-risk tool to the registry cannot silently widen - * what the in-app assistant can reach — the two conditions are ANDed below. - */ -export const READ_ONLY_TOOL_NAMES = [ - 'xnet_search', - 'xnet_graph_expand', - 'xnet_read_page_markdown', - 'xnet_database_describe', - 'xnet_database_query', - 'xnet_database_sample', - 'xnet_canvas_list', - 'xnet_canvas_read_viewport', - 'xnet_canvas_search', - 'xnet_get_audit_log' -] as const - -const ALLOWED = new Set(READ_ONLY_TOOL_NAMES) - -/** - * Whether a tier may be given tools at all. - * - * Only `reliable` tool-callers qualify. A `weak` tier (in-tab WebLLM) emits - * malformed or hallucinated calls often enough that handing it tools produces - * confident nonsense rather than grounded answers, and `none` cannot call them - * at all — both are better served by the injected context pack. This is the - * same fidelity signal `writeModeFor()` uses to decide propose-vs-apply. - */ -export function toolsEnabledFor(fidelity: ToolCallingFidelity | undefined): boolean { - return fidelity === 'reliable' -} - -/** - * The read-only tool specs to advertise, in registry order. Returns `[]` when - * the tier can't be trusted with tools, so the caller can pass the result - * straight through without branching. - */ -export function readOnlyToolSpecs( - surface: Pick | null, - fidelity: ToolCallingFidelity | undefined -): AIToolSpec[] { - if (!surface || !toolsEnabledFor(fidelity)) return [] - return surface - .getTools() - .filter((tool) => ALLOWED.has(tool.name) && tool.risk === 'low') - .map((tool) => ({ - name: tool.name, - description: tool.description, - inputSchema: { ...tool.inputSchema } - })) -} - -/** Standing instructions describing the tools, appended when they're enabled. */ -export const AI_TOOLS_PROMPT = [ - 'You can call read-only tools to look things up in the workspace yourself —', - 'search it, read a page, describe or query a database, expand the graph around', - 'a node. Prefer calling a tool over guessing, and prefer it over saying you', - 'lack access. Cite what you found by title or id. These tools only read: you', - 'cannot create, edit, or delete anything, so when asked to make a change,', - 'describe precisely what you would change and ask the user to apply it.' -].join(' ') +export * from '@xnetjs/workbench/ai' diff --git a/apps/web/src/workbench/views/ai-context.ts b/apps/web/src/workbench/views/ai-context.ts index edaf43f12..ff4ff6085 100644 --- a/apps/web/src/workbench/views/ai-context.ts +++ b/apps/web/src/workbench/views/ai-context.ts @@ -1,52 +1,5 @@ /** - * Workspace grounding for the AI chat panel (exploration 0192, Phase 1). - * - * Turns a read-only {@link AiContextPack} (from `AiSurfaceService.createContextPack`) - * into the system messages the runtime injects ahead of the conversation, so the - * assistant can answer about the user's actual pages/databases/nodes instead of - * guessing. Pure + tested — no store, no network. + * Shim (0406): canonical module lives in @xnetjs/workbench/ai. New code + * imports the package directly. */ - -import type { AiContextPack, AIMessage } from '@xnetjs/plugins' - -/** Standing instructions for the in-app assistant. */ -export const AI_SYSTEM_PROMPT = [ - "You are xNet, a helpful assistant embedded in the user's local-first workspace.", - 'You may be given read-only "Workspace context" gathered from the user\'s own pages,', - 'databases, and nodes. Ground your answers in that context and cite the relevant', - 'item (by title or id) when you use it. If the context does not contain the answer,', - 'say so plainly rather than inventing details. This in-app chat is read-only: you can', - 'answer and cite context but not edit the workspace directly, so when asked to make', - 'changes, explain what you would change and ask the user to apply it.' -].join(' ') - -/** Cap per-resource text so a few large pages can't blow the context window. */ -const MAX_RESOURCE_CHARS = 2000 - -/** - * Format a context pack into the (zero or one) system messages to inject before - * the conversation history. Returns `[]` when the pack has no resources, so a - * turn with no relevant context adds nothing. - */ -export function formatContextMessages(pack: AiContextPack | null | undefined): AIMessage[] { - const resources = pack?.resources ?? [] - if (resources.length === 0) return [] - - const blocks = resources.map((resource) => { - const { kind, id } = resource.citation - const text = - resource.text.length > MAX_RESOURCE_CHARS - ? `${resource.text.slice(0, MAX_RESOURCE_CHARS)}…` - : resource.text - return `### ${kind} · ${id}\n${text.trim()}` - }) - - return [ - { - role: 'system', - content: - 'Workspace context (read-only, may be incomplete — cite items you use):\n\n' + - blocks.join('\n\n') - } - ] -} +export * from '@xnetjs/workbench/ai' diff --git a/apps/web/src/workbench/views/ai-graph-retriever.ts b/apps/web/src/workbench/views/ai-graph-retriever.ts index 998564b82..ff4ff6085 100644 --- a/apps/web/src/workbench/views/ai-graph-retriever.ts +++ b/apps/web/src/workbench/views/ai-graph-retriever.ts @@ -1,215 +1,5 @@ /** - * Graph-aware context retriever for the AI chat (exploration 0211 — live wiring). - * - * This is the app-side glue that injects `@xnetjs/brain`'s `retrieve()` into the - * `AiSurfaceService` via its `retrieveContext` seam. Instead of the flat keyword - * scan the context pack used before, the assistant now gets a graph-walked, - * budgeted slice: keyword entry search over the local NodeStore, then bounded - * expansion along typed relations (resolved from the schema registry), with each - * hit carrying a readable provenance path. - * - * Deliberately uses **no embedding model** — entry search is keyword-only — so it - * adds zero boot weight and no heavy bundle dependency (the 0204 cold-start - * constraint). The vector tier can later swap in behind the same seam without - * touching this call site. + * Shim (0406): canonical module lives in @xnetjs/workbench/ai. New code + * imports the package directly. */ -import type { AiContextRetriever } from '@xnetjs/plugins' -import { - DEFAULT_HOP_DECAY, - retrieve, - schemaRelationFields, - type EntryHit, - type GraphAccess, - type GraphEdge, - type NodeText, - type RetrievalBudget -} from '@xnetjs/brain' -import { schemaRegistry, type SchemaIRI } from '@xnetjs/data' - -/** The minimal node shape the retriever reads (a `NodeState` satisfies it). */ -export interface GraphRetrieverNode { - id: string - schemaId: string - properties: Record - deleted: boolean -} - -/** The minimal NodeStore surface the retriever reads. */ -export interface GraphRetrieverStore { - get(id: string): Promise - list(options?: { limit?: number }): Promise - /** - * Cross-schema FTS5 search (`NodeStore.searchText`, exploration 0391). - * `null`/absent means no FTS in this storage — fall back to scanning. - */ - searchText?(query: string, limit: number): Promise | null> -} - -/** Resolve the relation-valued property names for a schema. */ -export type RelationFieldsLookup = (schemaId: string) => Promise - -export interface GraphContextRetrieverOptions { - /** Relation-field resolver; defaults to the global client schema registry. */ - relationFieldsOf?: RelationFieldsLookup - /** Override the retrieval budget. */ - budget?: Partial - /** - * Override the entry search (the seam the semantic/vector tier swaps in behind, - * exploration 0211). Defaults to keyword search over the local store. - */ - entrySearch?: (query: string, k: number) => Promise -} - -const TEXT_KEYS = [ - 'title', - 'name', - 'displayName', - 'label', - 'subject', - 'summary', - 'description', - 'text', - 'body', - 'content', - 'bio', - 'caption' -] as const - -const SCAN_LIMIT = 500 -const SNIPPET_MAX = 600 -const DEFAULT_BUDGET: RetrievalBudget = { - maxTokens: 24_000, - maxHops: 1, - maxEntries: 12, - maxNodes: 48, - hopDecay: DEFAULT_HOP_DECAY -} - -/** Title (first text-bearing property) + joined body of a node's text. */ -export function nodeTextParts(node: GraphRetrieverNode): { title: string; body: string } { - const parts: string[] = [] - for (const key of TEXT_KEYS) { - const value = node.properties[key] - if (typeof value === 'string' && value.trim().length > 0) parts.push(value.trim()) - } - return { title: parts[0]?.slice(0, 200) ?? node.id, body: parts.join('\n') } -} - -/** Default relation-field resolver backed by the global schema registry (memoized). */ -function registryRelationFields(): RelationFieldsLookup { - const cache = new Map() - return async (schemaId) => { - const cached = cache.get(schemaId) - if (cached) return cached - const defined = await schemaRegistry.get(schemaId as SchemaIRI) - const fields = defined ? schemaRelationFields(defined) : [] - cache.set(schemaId, fields) - return fields - } -} - -/** - * Keyword entry search. Prefers the indexed FTS5 path (`store.searchText`, - * BM25-ranked over `nodes_fts` — exploration 0379's fix, wired in 0391); falls - * back to the title-boosted substring scan when the storage has no FTS - * (memory adapter, sql.js). - */ -export function keywordEntrySearch( - store: GraphRetrieverStore -): (query: string, k: number) => Promise { - return async (query, k) => { - const needle = query.trim().toLocaleLowerCase() - if (!needle) return [] - if (store.searchText) { - const matches = await store.searchText(query, k).catch(() => null) - if (matches !== null && matches !== undefined) { - // BM25 rank: more negative = better. Negate so bigger score wins, - // matching EntryHit's convention. - return matches.map((match) => ({ - nodeId: match.nodeId, - score: -match.rank, - source: 'keyword' as const - })) - } - } - const nodes = await store.list({ limit: SCAN_LIMIT }) - const hits: EntryHit[] = [] - for (const node of nodes) { - if (node.deleted) continue - const { title, body } = nodeTextParts(node) - const idx = `${title}\n${body}`.toLocaleLowerCase().indexOf(needle) - if (idx === -1) continue - const titleMatch = title.toLocaleLowerCase().includes(needle) - hits.push({ - nodeId: node.id, - score: (titleMatch ? 10 : 1) + Math.max(0, 5 - idx / 100), - source: 'keyword' - }) - } - hits.sort((a, b) => b.score - a.score) - return hits.slice(0, k) - } -} - -/** Graph access that reads outbound relation edges, schema-resolved + memoized. */ -function schemaGraphAccess( - store: GraphRetrieverStore, - relationFieldsOf: RelationFieldsLookup -): GraphAccess { - return { - async neighbors(nodeId) { - const node = await store.get(nodeId) - if (!node || node.deleted) return [] - const edges: GraphEdge[] = [] - for (const field of await relationFieldsOf(node.schemaId)) { - const value = node.properties[field] - const targets = Array.isArray(value) ? value : [value] - for (const target of targets) { - if (typeof target === 'string' && target.length > 0) { - edges.push({ nodeId: target, relation: field, direction: 'outbound' }) - } - } - } - return edges - } - } -} - -/** Load a node's title/snippet for the retrieved context. */ -function nodeTextLoader(store: GraphRetrieverStore): (id: string) => Promise { - return async (id) => { - const node = await store.get(id) - if (!node || node.deleted) return null - const { title, body } = nodeTextParts(node) - return { - title, - snippet: body.replace(/\s+/g, ' ').trim().slice(0, SNIPPET_MAX), - schemaId: node.schemaId - } - } -} - -/** - * Build a graph-aware `AiContextRetriever` over the local NodeStore. Wire it into - * `createAiSurfaceService({ store, schemas, retrieveContext })`. - */ -export function createGraphContextRetriever( - store: GraphRetrieverStore, - options: GraphContextRetrieverOptions = {} -): AiContextRetriever { - const relationFieldsOf = options.relationFieldsOf ?? registryRelationFields() - const graph = schemaGraphAccess(store, relationFieldsOf) - const loadText = nodeTextLoader(store) - const entrySearch = options.entrySearch ?? keywordEntrySearch(store) - - return async (query, { limit }) => { - const budget: RetrievalBudget = { - ...DEFAULT_BUDGET, - maxEntries: Math.max(limit, 4), - maxNodes: Math.max(limit * 4, 24), - ...options.budget - } - const result = await retrieve(query, budget, { entrySearch, graph, loadText }) - return result.items.map((item) => ({ nodeId: item.nodeId, pathLabel: item.pathLabel })) - } -} +export * from '@xnetjs/workbench/ai' diff --git a/apps/web/src/workbench/views/ai-schemas.ts b/apps/web/src/workbench/views/ai-schemas.ts index 3faacb690..ff4ff6085 100644 --- a/apps/web/src/workbench/views/ai-schemas.ts +++ b/apps/web/src/workbench/views/ai-schemas.ts @@ -1,42 +1,5 @@ /** - * Adapt the client `schemaRegistry` (@xnetjs/data) to the `SchemaRegistryAPI` - * shape the AiSurfaceService expects (exploration 0192, Phase 1). - * - * The client's `DefinedSchema` keeps its metadata under `.schema` and exposes - * properties as an array; the surface wants a flat `{ iri, name, properties }` - * with properties keyed by name. The mapping is pure + tested; the registry - * wiring is a thin wrapper over the global singleton. + * Shim (0406): canonical module lives in @xnetjs/workbench/ai. New code + * imports the package directly. */ - -import type { SchemaData, SchemaRegistryAPI } from '@xnetjs/plugins' -import { schemaRegistry, type SchemaIRI } from '@xnetjs/data' - -/** The minimal shape of a client schema we read (a `DefinedSchema` satisfies it). */ -export interface DefinedSchemaLike { - schema: { - '@id': string - name: string - properties: Array<{ name: string }> - } -} - -/** Flatten a client schema into the surface's `SchemaData` (properties by name). */ -export function toSchemaData(defined: DefinedSchemaLike): SchemaData { - const { schema } = defined - return { - iri: schema['@id'], - name: schema.name, - properties: Object.fromEntries(schema.properties.map((property) => [property.name, property])) - } -} - -/** A `SchemaRegistryAPI` backed by the global client schema registry. */ -export function schemaRegistryApi(): SchemaRegistryAPI { - return { - getAllIRIs: () => schemaRegistry.getAllIRIs(), - get: async (iri) => { - const defined = await schemaRegistry.get(iri as SchemaIRI) - return defined ? toSchemaData(defined) : null - } - } -} +export * from '@xnetjs/workbench/ai' diff --git a/apps/web/src/workbench/views/ai-vector-search.ts b/apps/web/src/workbench/views/ai-vector-search.ts index 1263741d4..ff4ff6085 100644 --- a/apps/web/src/workbench/views/ai-vector-search.ts +++ b/apps/web/src/workbench/views/ai-vector-search.ts @@ -1,165 +1,5 @@ /** - * Lazy, opt-in semantic (vector) entry search for the AI chat (exploration 0211). - * - * Wakes the dormant `@xnetjs/vectors` engine BEHIND the `createGraphContextRetriever` - * seam: when enabled, entry search fuses on-device vector similarity with the - * keyword scan (Reciprocal Rank Fusion), so the assistant finds things by meaning, - * not just literal text. The graph-walk + token budget then proceed unchanged. - * - * Safety first (the 0204 cold-start constraint): - * - The heavy `@xenova` model + `usearch` are pulled in only through a **dynamic - * import**, and only on the FIRST search after the user opts in — zero boot or - * bundle cost when the flag is off. - * - Until the index is warm (model loading / backfilling) and on ANY failure, it - * transparently falls back to keyword search. Enabling it can only ever make - * results as good or better — never worse, never broken. - * - With a blob store it restores/persists the index instead of re-embedding the - * graph every session (the `@xnetjs/brain` persist layer). + * Shim (0406): canonical module lives in @xnetjs/workbench/ai. New code + * imports the package directly. */ -import { loadVectorTier, saveVectorTier, type BlobStore, type EntryHit } from '@xnetjs/brain' -import { keywordEntrySearch, nodeTextParts, type GraphRetrieverStore } from './ai-graph-retriever' - -/** The semantic index surface we use (`@xnetjs/vectors` `SemanticSearch` satisfies it). */ -export interface SemanticIndexLike { - initialize(): Promise - indexDocument(id: string, content: string): Promise - search( - query: string, - options?: { maxResults?: number; minScore?: number } - ): Promise> - serialize(): unknown - restore(data: unknown): void -} - -/** The vectors engine loader (injected in tests; defaults to a dynamic import). */ -export type VectorEngineLoader = () => Promise<{ - createSemanticSearch: (config: { useMockModel?: boolean }) => SemanticIndexLike -}> - -export interface VectorEntrySearchOptions { - store: GraphRetrieverStore - /** Deterministic mock model (tests). Default false → real on-device `@xenova`. */ - useMockModel?: boolean - /** Optional blob store; when present, restore/persist instead of re-embedding. */ - storage?: BlobStore - /** Max nodes to embed on a cold backfill. */ - maxBackfill?: number - /** Vector weight for RRF fusion (0–1). Default 0.5. */ - vectorWeight?: number - /** Loader for the vectors engine; defaults to `() => import('@xnetjs/vectors')`. */ - loadEngine?: VectorEngineLoader - /** Init/backfill timeout in ms. */ - timeoutMs?: number -} - -export interface VectorEntrySearch { - /** Entry search: hybrid once the index is warm, keyword until then / on failure. */ - search(query: string, k: number): Promise - /** True once the semantic index is built and serving. */ - ready(): boolean -} - -const DEFAULT_TIMEOUT_MS = 30_000 -const RRF_K = 60 - -function withTimeout(promise: Promise, ms: number): Promise { - return Promise.race([ - promise, - new Promise((_, reject) => setTimeout(() => reject(new Error('vector init timeout')), ms)) - ]) -} - -/** Embed every text-bearing node in the store into the index (cold backfill). */ -async function backfill( - index: SemanticIndexLike, - store: GraphRetrieverStore, - maxBackfill: number -): Promise { - const nodes = await store.list({ limit: maxBackfill }) - for (const node of nodes) { - if (node.deleted) continue - const { body } = nodeTextParts(node) - if (body.length > 0) await index.indexDocument(node.id, body) - } -} - -/** Fuse vector + keyword hits via Reciprocal Rank Fusion. */ -async function hybridSearch( - index: SemanticIndexLike, - keyword: (query: string, k: number) => Promise, - query: string, - k: number, - vectorWeight: number -): Promise { - const keywordWeight = 1 - vectorWeight - const [vectorHits, keywordHits] = await Promise.all([ - index.search(query, { maxResults: k * 2 }), - keyword(query, k * 2) - ]) - - const ranks = new Map() - vectorHits.forEach((hit, i) => ranks.set(hit.id, { ...ranks.get(hit.id), v: i + 1 })) - keywordHits.forEach((hit, i) => ranks.set(hit.nodeId, { ...ranks.get(hit.nodeId), kw: i + 1 })) - - const fused: EntryHit[] = [] - for (const [nodeId, rank] of ranks.entries()) { - const score = - (rank.v ? vectorWeight / (RRF_K + rank.v) : 0) + - (rank.kw ? keywordWeight / (RRF_K + rank.kw) : 0) - const source: EntryHit['source'] = rank.v && rank.kw ? 'hybrid' : rank.v ? 'vector' : 'keyword' - fused.push({ nodeId, score, source }) - } - fused.sort((a, b) => b.score - a.score) - return fused.slice(0, k) -} - -/** - * Build a lazy, fallback-safe semantic entry search. Pass `.search` as the - * `entrySearch` option of `createGraphContextRetriever`. - */ -export function createVectorEntrySearch(options: VectorEntrySearchOptions): VectorEntrySearch { - const { - store, - useMockModel = false, - storage, - maxBackfill = 2000, - vectorWeight = 0.5, - timeoutMs = DEFAULT_TIMEOUT_MS - } = options - const keyword = keywordEntrySearch(store) - const loadEngine: VectorEngineLoader = options.loadEngine ?? (() => import('@xnetjs/vectors')) - - let state: 'idle' | 'loading' | 'ready' | 'failed' = 'idle' - let index: SemanticIndexLike | null = null - - async function init(): Promise { - state = 'loading' - try { - const engine = await withTimeout(loadEngine(), timeoutMs) - const search = engine.createSemanticSearch({ useMockModel }) - await withTimeout(search.initialize(), timeoutMs) - const restored = storage ? await loadVectorTier(search, storage) : false - if (!restored) await withTimeout(backfill(search, store, maxBackfill), timeoutMs) - index = search - state = 'ready' - if (storage) void saveVectorTier(search, storage).catch(() => {}) - } catch { - // Any failure (model load, WASM, network, timeout) → stay on keyword forever. - state = 'failed' - } - } - - return { - ready: () => state === 'ready', - async search(query, k) { - // Kick off the (idempotent) lazy build; keyword serves until it's ready. - if (state === 'idle') void init() - if (state !== 'ready' || !index) return keyword(query, k) - try { - return await hybridSearch(index, keyword, query, k, vectorWeight) - } catch { - return keyword(query, k) - } - } - } -} +export * from '@xnetjs/workbench/ai' diff --git a/apps/web/src/workbench/views/ai-vector-storage.ts b/apps/web/src/workbench/views/ai-vector-storage.ts index 87152bec0..ff4ff6085 100644 --- a/apps/web/src/workbench/views/ai-vector-storage.ts +++ b/apps/web/src/workbench/views/ai-vector-storage.ts @@ -1,66 +1,5 @@ /** - * IndexedDB-backed blob store for the AI chat's semantic vector tier (0211). - * - * Lets `createVectorEntrySearch` restore the embedding index across sessions - * instead of re-embedding the whole graph every time. Thin glue over IndexedDB - * (durable, holds the ~tens-of-KB serialized index, no size cap like - * localStorage). Returns `undefined` where IndexedDB is unavailable, in which - * case the tier simply re-backfills — still correct, just not persisted. - * - * Note: IndexedDB round-trips bytes through structured clone, which can hand back - * a cross-realm `Uint8Array`; the `@xnetjs/brain` persist layer is realm-robust - * for exactly this reason. + * Shim (0406): canonical module lives in @xnetjs/workbench/ai. New code + * imports the package directly. */ -import type { BlobStore } from '@xnetjs/brain' - -const DB_NAME = 'xnet-ai-vectors' -const STORE_NAME = 'tier' - -function openDb(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, 1) - request.onupgradeneeded = () => request.result.createObjectStore(STORE_NAME) - request.onsuccess = () => resolve(request.result) - request.onerror = () => reject(request.error) - }) -} - -function runRequest( - makeRequest: (store: IDBObjectStore) => IDBRequest, - mode: IDBTransactionMode -) { - return async (): Promise => { - const db = await openDb() - try { - return await new Promise((resolve, reject) => { - const request = makeRequest(db.transaction(STORE_NAME, mode).objectStore(STORE_NAME)) - request.onsuccess = () => resolve(request.result) - request.onerror = () => reject(request.error) - }) - } finally { - db.close() - } - } -} - -/** - * Create an IndexedDB-backed `BlobStore`, or `undefined` when IndexedDB is - * unavailable (SSR, locked-down environments) — the caller treats that as - * "no persistence" and falls back to re-embedding. - */ -export function createVectorBlobStore(): BlobStore | undefined { - if (typeof indexedDB === 'undefined') return undefined - return { - async getBlob(key) { - const value = await runRequest((store) => store.get(key), 'readonly')() - return value instanceof Uint8Array - ? value - : value == null - ? null - : new Uint8Array(value as ArrayBuffer) - }, - setBlob(key, data) { - return runRequest((store) => store.put(data, key), 'readwrite')().then(() => undefined) - } - } -} +export * from '@xnetjs/workbench/ai' diff --git a/apps/web/src/workbench/views/ai-webllm-engine.ts b/apps/web/src/workbench/views/ai-webllm-engine.ts index 8c14727a0..ff4ff6085 100644 --- a/apps/web/src/workbench/views/ai-webllm-engine.ts +++ b/apps/web/src/workbench/views/ai-webllm-engine.ts @@ -1,77 +1,5 @@ /** - * In-tab WebLLM engine (exploration 0252, finishing 0174 tier A). - * - * Builds a real `@mlc-ai/web-llm` engine and wraps it in the dependency-free - * `WebLLMProvider` from `@xnetjs/plugins`. The heavy library is imported lazily - * — only when the user actually loads the in-browser model — so it never lands - * in the main bundle (mirrors how `@xnetjs/vectors` lazy-loads its embedding - * model). The model weights download once and are cached by the browser, so the - * tier then runs offline. Nothing leaves the device. - * - * This is the engine-injection path the panel passes as `hasWebLLMEngine`, - * which is what flips the `webllm` connector from "detectable" to "usable". + * Shim (0406): canonical module lives in @xnetjs/workbench/ai. New code + * imports the package directly. */ - -import { createWebLLMProvider, type WebLLMEngineLike, type WebLLMProvider } from '@xnetjs/plugins' - -// `@mlc-ai/web-llm` ships no node-safe entry and has its own heavy types; load -// it lazily and structurally, like the embedding model in @xnetjs/vectors. -let webllmModule: any = null - -async function getWebLLM(): Promise<{ - CreateMLCEngine: (model: string, options: unknown) => Promise -}> { - if (!webllmModule) webllmModule = await import('@mlc-ai/web-llm') - return webllmModule -} - -/** - * Default in-tab model: a small (~1 GB) instruct model so the first-run - * download stays friendly. Cached after the first load. Bump to a 3B for more - * quality once the download cost is acceptable. - */ -export const DEFAULT_WEBLLM_MODEL = 'Llama-3.2-1B-Instruct-q4f16_1-MLC' - -/** First-run load/download progress, as `@mlc-ai/web-llm` reports it. */ -export interface WebLLMProgress { - /** Fraction in [0, 1]. */ - fraction: number - /** Human-readable status (e.g. "Fetching param cache[12/38]"). */ - text: string -} - -export interface BuildWebLLMOptions { - /** Model id from the WebLLM prebuilt catalog. Default {@link DEFAULT_WEBLLM_MODEL}. */ - model?: string - /** Called as the model downloads/initialises, for a progress bar. */ - onProgress?: (progress: WebLLMProgress) => void -} - -/** - * Create an in-tab WebLLM provider, downloading + initialising the model on - * first use. Rejects if WebGPU is unavailable or the download fails (the caller - * surfaces that as the composer's error). Must be reachable from a user gesture - * so the multi-hundred-MB download isn't a surprise. - */ -export async function buildWebLLMProvider( - options: BuildWebLLMOptions = {} -): Promise { - const model = options.model ?? DEFAULT_WEBLLM_MODEL - const { CreateMLCEngine } = await getWebLLM() - let engine: WebLLMEngineLike - try { - engine = await CreateMLCEngine(model, { - initProgressCallback: (report: { progress: number; text: string }) => - options.onProgress?.({ fraction: report.progress, text: report.text }) - }) - } catch (cause) { - // The generic "Failed to fetch" from a blocked/aborted weight download would - // otherwise be rewritten as a cloud-key CORS hint by `errorMessage` — wrong - // for an in-tab model. Give a WebLLM-specific message the panel shows as-is. - throw new Error( - `Couldn't load the in-browser model (${model}). Check your connection — the model weights download from the Hugging Face CDN on first run.`, - { cause } - ) - } - return createWebLLMProvider({ engine, model }) -} +export * from '@xnetjs/workbench/ai' diff --git a/assistant-panel.md b/assistant-panel.md new file mode 100644 index 000000000..c1237b410 --- /dev/null +++ b/assistant-panel.md @@ -0,0 +1,48 @@ +- generic [ref=e3]: + - generic [ref=e5]: + - banner [ref=e6]: + - button "Open system menu" [ref=e9] [cursor=pointer] + - main [ref=e14]: + - generic: + - generic: + - button + - textbox "Workspace Canvas" [ref=e19]: cdp-durability-1785201763836 + - generic: + - application "Canvas" + - generic [ref=e141]: + - generic [ref=e142]: + - combobox [ref=e143]: + - option "xNet Cloud (managed, metered) — unavailable" + - option "Local bridge (Claude Code / Codex subscription)" [selected] + - option "Cloud API key (Anthropic / OpenAI / OpenRouter) — unavailable" + - option "Local model (Ollama / LM Studio)" + - option "In-browser model (WebLLM, WebGPU)" + - option "Chrome built-in AI (Gemini Nano) — unavailable" + - generic "This assistant can search and read your workspace itself. It cannot make changes." [ref=e144]: searches workspace + - generic [ref=e145]: + - generic [ref=e147]: Running claude · v3.0.0 + - combobox "Bridge agent" [ref=e148]: + - option "Claude Code" [selected] + - option "Codex" + - option "Gemini CLI" + - option "OpenCode" + - generic [ref=e149]: + - textbox "Bridge pairing code" [ref=e150]: dJZdmC4OJDLFIw2g5Y_FiDG7INHCJeSC + - paragraph [ref=e151]: + - text: Paste the code + - code [ref=e152]: xnet bridge serve + - text: prints. Sent only to your local bridge — never to our servers. + - generic [ref=e153]: + - paragraph [ref=e157]: Ask about your workspace, using your own model or API key. + - paragraph [ref=e158]: The assistant reads your pages and data for context; your model runs locally or on your own key — never on our servers. + - generic [ref=e159] [cursor=pointer]: + - checkbox "Semantic search (beta) — find context by meaning, on device" [ref=e160] + - generic [ref=e161]: Semantic search (beta) — find context by meaning, on device + - generic [ref=e162]: + - textbox "Message…" [ref=e163] + - button "Send" [disabled] [ref=e164] + - generic [ref=e75]: + - button "Canvas" [ref=e168] [cursor=pointer] + - button "Command palette (Mod+Shift+P)" [ref=e112] [cursor=pointer]: + - generic [ref=e116]: Command palette + - generic "Toggle DevTools (⌘⇧D)" [ref=e135] diff --git a/docs/explorations/0406_[-]_ONE_SHELL_TWO_SURFACES_ENDING_THE_DESKTOP_WEB_UI_FORK.md b/docs/explorations/0406_[-]_ONE_SHELL_TWO_SURFACES_ENDING_THE_DESKTOP_WEB_UI_FORK.md index a667afc07..4efd87bdd 100644 --- a/docs/explorations/0406_[-]_ONE_SHELL_TWO_SURFACES_ENDING_THE_DESKTOP_WEB_UI_FORK.md +++ b/docs/explorations/0406_[-]_ONE_SHELL_TWO_SURFACES_ENDING_THE_DESKTOP_WEB_UI_FORK.md @@ -558,7 +558,7 @@ export class ShellErrorBoundary extends React.Component { ### Phase 5 — the agent gets a face -- [ ] Mount `AiChatPanel` on desktop, wired to `window.xnetAgentBridge` (#638) +- [x] Mount `AiChatPanel` on desktop, wired to `window.xnetAgentBridge` (#638) - [ ] Confirm a chat turn creates a node in the desktop store from the panel ### Phase 6 — make re-divergence a red build diff --git a/packages/workbench/package.json b/packages/workbench/package.json index 2bd52a329..15bc2e6a1 100644 --- a/packages/workbench/package.json +++ b/packages/workbench/package.json @@ -9,6 +9,10 @@ ".": { "import": "./src/index.ts", "types": "./src/index.ts" + }, + "./ai": { + "import": "./src/views/ai.ts", + "types": "./src/views/ai.ts" } }, "scripts": { @@ -20,7 +24,12 @@ "dependencies": { "@xnetjs/plugins": "workspace:*", "lucide-react": "^0.453.0", - "zustand": "^5.0.14" + "zustand": "^5.0.14", + "@xnetjs/brain": "workspace:*", + "@xnetjs/data": "workspace:*", + "@xnetjs/react": "workspace:*", + "@xnetjs/vectors": "workspace:*", + "@mlc-ai/web-llm": "^0.2.84" }, "peerDependencies": { "react": "^18.0.0" diff --git a/apps/web/src/workbench/views/AiChatPanel.test.tsx b/packages/workbench/src/views/AiChatPanel.test.tsx similarity index 100% rename from apps/web/src/workbench/views/AiChatPanel.test.tsx rename to packages/workbench/src/views/AiChatPanel.test.tsx diff --git a/apps/web/src/workbench/views/AiChatPanel.tsx b/packages/workbench/src/views/AiChatPanel.tsx similarity index 100% rename from apps/web/src/workbench/views/AiChatPanel.tsx rename to packages/workbench/src/views/AiChatPanel.tsx diff --git a/apps/web/src/workbench/views/ai-chat-connector.test.ts b/packages/workbench/src/views/ai-chat-connector.test.ts similarity index 100% rename from apps/web/src/workbench/views/ai-chat-connector.test.ts rename to packages/workbench/src/views/ai-chat-connector.test.ts diff --git a/packages/workbench/src/views/ai-chat-connector.ts b/packages/workbench/src/views/ai-chat-connector.ts new file mode 100644 index 000000000..1174b80dc --- /dev/null +++ b/packages/workbench/src/views/ai-chat-connector.ts @@ -0,0 +1,440 @@ +/** + * Maps a detected model connector + local settings to an AIProviderConfig + * (exploration 0174). Pure, so it is unit-tested without a browser. + * + * The cloud-key and local-server / bridge tiers resolve to a `createAIProvider` + * config. The in-tab tiers (webllm, prompt-api) need an injected engine/session + * and are constructed directly in the panel, so they return null here. + */ + +import type { AIProviderConfig, ConnectorDetection, ConnectorTier } from '@xnetjs/plugins' + +export type CloudProvider = 'anthropic' | 'openai' | 'openrouter' + +export interface AiChatSettings { + /** BYO cloud API key (stored locally, never sent to the hub). */ + apiKey?: string + /** Which cloud provider the key is for. */ + cloudProvider?: CloudProvider + /** Optional model id override. */ + model?: string + /** Base URL override for the local-server tier. */ + localBaseUrl?: string + /** Hub base URL for the managed tier (default `''` = same origin). */ + hubBaseUrl?: string + /** + * Pairing code for the local bridge daemon, sent as `Authorization: Bearer`. + * Under Electron it's auto-supplied over IPC; in a plain browser the user + * pastes the code `xnet bridge serve` prints. + */ + bridgeToken?: string +} + +/** localStorage keys (xnet:* convention). */ +export const AI_CHAT_STORAGE_KEYS = { + apiKey: 'xnet:ai-api-key', + cloudProvider: 'xnet:ai-cloud-provider', + model: 'xnet:ai-model', + localBaseUrl: 'xnet:ai-local-base-url', + /** The local-bridge pairing code (survives reload; per-launch tokens re-pair). */ + bridgeToken: 'xnet:ai-bridge-token', + /** The connector tier the user last selected (survives reload). */ + tier: 'xnet:ai-tier', + /** Opt-in: use on-device semantic (vector) entry search (exploration 0211). */ + semanticSearch: 'xnet:ai-semantic-search' +} as const + +/** Connector tiers that resolve to a `createAIProvider` config (vs. in-tab). */ +export const PROVIDER_CONFIG_TIERS: readonly ConnectorTier[] = [ + 'managed', + 'cloud-key', + 'local-server', + 'bridge' +] + +/** + * Tiers the panel can actually instantiate a provider for: the config-backed + * tiers plus the in-tab tiers `prompt-api` (built from an injected session) and + * `webllm` (built from a host-supplied `@mlc-ai/web-llm` engine, see + * `ai-webllm-engine.ts`). `webllm` is now safe to auto-select because the heavy + * model download is gated behind an explicit "load" gesture in the panel rather + * than firing the moment the tier is chosen. + */ +export const USABLE_TIERS: readonly ConnectorTier[] = [ + ...PROVIDER_CONFIG_TIERS, + 'prompt-api', + 'webllm' +] + +/** Whether the panel can build a working provider for this tier right now. */ +export function isUsableTier(tier: ConnectorTier): boolean { + return USABLE_TIERS.includes(tier) +} + +/** + * The most-preferred *available and usable* connector, or null. Mirrors + * `pickBestConnector` but skips tiers the panel can't instantiate (webllm), + * relying on the same preference ordering of the input. + */ +export function pickUsableConnector( + detections: readonly ConnectorDetection[] +): ConnectorDetection | null { + return detections.find((d) => d.available && isUsableTier(d.tier)) ?? null +} + +/** + * Resolve an AIProviderConfig for a connector, or null when the tier needs an + * in-tab engine (webllm / prompt-api) or required settings are missing. + */ +export function providerConfigForConnector( + detection: ConnectorDetection, + settings: AiChatSettings +): AIProviderConfig | null { + switch (detection.tier) { + case 'managed': { + // No key and no base-URL typing: the hub is the origin and injects the + // per-tenant credential. The model comes from the picker / plan default. + return { + type: 'managed', + options: { + baseUrl: settings.hubBaseUrl ?? '', + ...(settings.model ? { model: settings.model } : {}) + } + } + } + case 'cloud-key': { + if (!settings.apiKey) return null + const type = settings.cloudProvider ?? 'anthropic' + return { + type, + options: { apiKey: settings.apiKey, ...(settings.model ? { model: settings.model } : {}) } + } + } + case 'local-server': { + const baseUrl = settings.localBaseUrl ?? baseUrlFromDetail(detection.detail) + const type = /lm studio/i.test(detection.detail ?? '') ? 'lmstudio' : 'ollama' + return { + type, + options: { + ...(baseUrl ? { baseUrl } : {}), + ...(settings.model ? { model: settings.model } : {}) + } + } + } + case 'bridge': { + // The bridge daemon exposes an OpenAI-compatible endpoint on loopback and + // now requires the pairing code as `Authorization: Bearer` — without it the + // daemon answers 401, so treat a missing code as "not configured yet". + const baseUrl = baseUrlFromDetail(detection.detail) + if (!baseUrl || !settings.bridgeToken) return null + return { + type: 'openai-compatible', + options: { + baseUrl, + apiKey: settings.bridgeToken, + ...(settings.model ? { model: settings.model } : {}) + } + } + } + default: + // webllm / prompt-api are constructed directly with an injected engine. + return null + } +} + +/** Extract the `(http://host:port)` base URL embedded in a detection detail. */ +export function baseUrlFromDetail(detail: string | undefined): string | undefined { + if (!detail) return undefined + const match = detail.match(/\((https?:\/\/[^)]+)\)/) + if (match) return match[1] + return /^https?:\/\//.test(detail) ? detail : undefined +} + +// ─── Managed model catalog (the model picker) ─────────────────────────────────── + +/** One selectable managed model, as `GET /ai/models` returns it. */ +export interface ManagedModel { + id: string + name: string + family: string + inUsdPerM: number | null + outUsdPerM: number | null + contextLength: number | null + modality: string | null +} + +export interface ManagedModelsResult { + models: ManagedModel[] + defaultModel: string | null +} + +const asNumberOrNull = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null + +/** Parse a `GET /ai/models` body into a typed, defensively-narrowed result. */ +export function parseModelsResponse(data: unknown): ManagedModelsResult { + if (!data || typeof data !== 'object') return { models: [], defaultModel: null } + const record = data as Record + const raw = Array.isArray(record.models) ? record.models : [] + const models: ManagedModel[] = raw.flatMap((entry) => { + if (!entry || typeof entry !== 'object') return [] + const m = entry as Record + if (typeof m.id !== 'string') return [] + return [ + { + id: m.id, + name: typeof m.name === 'string' ? m.name : m.id, + family: typeof m.family === 'string' ? m.family : (m.id.split('/')[0] ?? m.id), + inUsdPerM: asNumberOrNull(m.inUsdPerM), + outUsdPerM: asNumberOrNull(m.outUsdPerM), + contextLength: asNumberOrNull(m.contextLength), + modality: typeof m.modality === 'string' ? m.modality : null + } + ] + }) + return { + models, + defaultModel: typeof record.defaultModel === 'string' ? record.defaultModel : null + } +} + +/** Fetch the plan-gated managed model catalog; `[]` on any error (the picker hides). */ +export async function fetchManagedModels( + baseUrl: string, + fetchImpl: typeof fetch = fetch +): Promise { + try { + const res = await fetchImpl(`${baseUrl}/ai/models`, { credentials: 'include' }) + if (!res.ok) return { models: [], defaultModel: null } + return parseModelsResponse(await res.json()) + } catch { + return { models: [], defaultModel: null } + } +} + +/** A compact picker label: name + "$in/$out per Mtok" + context when known. */ +export function formatModelOption(model: ManagedModel): string { + const price = + model.inUsdPerM !== null && model.outUsdPerM !== null + ? ` · $${trimPrice(model.inUsdPerM)}/$${trimPrice(model.outUsdPerM)} per Mtok` + : '' + const context = model.contextLength ? ` · ${Math.round(model.contextLength / 1000)}k ctx` : '' + return `${model.name}${price}${context}` +} + +const trimPrice = (usdPerM: number): string => + usdPerM >= 1 ? usdPerM.toFixed(2).replace(/\.00$/, '') : usdPerM.toFixed(2) + +/** Group models by family for an ``-style picker, families sorted. */ +export function groupModelsByFamily(models: readonly ManagedModel[]): [string, ManagedModel[]][] { + const groups = new Map() + for (const model of models) { + const list = groups.get(model.family) ?? [] + list.push(model) + groups.set(model.family, list) + } + return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)) +} + +// ─── Bridge status (which agent the local bridge is driving) ──────────────────── + +export interface BridgeAgentOption { + id: string + label: string +} + +/** Coding agents the local bridge can drive (for the in-panel picker). */ +export const KNOWN_BRIDGE_AGENTS: readonly BridgeAgentOption[] = [ + { id: 'claude', label: 'Claude Code' }, + { id: 'codex', label: 'Codex' }, + { id: 'gemini', label: 'Gemini CLI' }, + { id: 'opencode', label: 'OpenCode' } +] + +export interface BridgeHealth { + ok: boolean + agent?: string + version?: string +} + +/** Parse a bridge daemon `/health` body (`bridgeHealth()` output). */ +export function parseBridgeHealth(data: unknown): BridgeHealth { + if (!data || typeof data !== 'object') return { ok: false } + const record = data as Record + return { + ok: record.ok === true, + ...(typeof record.agent === 'string' ? { agent: record.agent } : {}), + ...(typeof record.version === 'string' ? { version: record.version } : {}) + } +} + +// ─── OpenRouter PKCE connect (exploration 0391, Phase 3) ──────────────────────── +// +// The one "use my existing account" flow a provider explicitly designed for +// third-party apps: the user authorizes on openrouter.ai, we exchange the +// callback code (+ PKCE verifier) for a USER-SCOPED key that bills their own +// OpenRouter balance. No key copy-paste, no xNet billing relationship. This +// is the no-daemon fallback tier — the bridge (their Claude/Codex +// subscription) stays the daily-driver path. + +/** localStorage key holding the in-flight PKCE verifier (cleared on finish). */ +export const OPENROUTER_VERIFIER_KEY = 'xnet:ai-openrouter-verifier' + +const base64Url = (bytes: Uint8Array): string => + btoa(String.fromCharCode(...bytes)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, '') + +/** A fresh high-entropy PKCE code verifier (RFC 7636 §4.1). */ +export function createPkceVerifier( + getRandomValues: (bytes: Uint8Array) => Uint8Array = (bytes) => crypto.getRandomValues(bytes) +): string { + return base64Url(getRandomValues(new Uint8Array(32))) +} + +/** S256 code challenge for a verifier (RFC 7636 §4.2). */ +export async function pkceChallengeS256( + verifier: string, + subtle: SubtleCrypto = crypto.subtle +): Promise { + const digest = await subtle.digest('SHA-256', new TextEncoder().encode(verifier)) + return base64Url(new Uint8Array(digest)) +} + +/** The openrouter.ai authorization URL for a callback + challenge. */ +export function openRouterAuthUrl(callbackUrl: string, challenge: string): string { + const url = new URL('https://openrouter.ai/auth') + url.searchParams.set('callback_url', callbackUrl) + url.searchParams.set('code_challenge', challenge) + url.searchParams.set('code_challenge_method', 'S256') + return url.toString() +} + +/** Exchange the callback `code` for a user-scoped key; null on any failure. */ +export async function exchangeOpenRouterCode( + code: string, + verifier: string, + fetchImpl: typeof fetch = fetch +): Promise { + try { + const res = await fetchImpl('https://openrouter.ai/api/v1/auth/keys', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + code, + code_verifier: verifier, + code_challenge_method: 'S256' + }) + }) + if (!res.ok) return null + const data = (await res.json()) as { key?: unknown } + return typeof data.key === 'string' && data.key ? data.key : null + } catch { + return null + } +} + +/** The `?code=` an OpenRouter callback landed with, if any. */ +export function openRouterCallbackCode(search: string): string | null { + const code = new URLSearchParams(search).get('code') + return code && code.length > 0 ? code : null +} + +// ─── Chat runtime event handling (extracted so it stays pure + tested) ────────── + +export interface RuntimeEventLike { + type: string + threadId?: string + payload?: unknown +} + +/** The state change a runtime event implies, or null if it's not interesting. */ +/** What the assistant is doing with a tool, for the activity line. */ +export interface ChatToolActivity { + kind: 'call' | 'result' + tool: string + denied?: boolean +} + +/** The state change a runtime event implies, or null if it's not interesting. */ +export interface ChatEventEffect { + delta?: string + settled?: boolean + error?: string + activity?: ChatToolActivity +} + +export function reduceRuntimeEvent(event: RuntimeEventLike): ChatEventEffect | null { + if (event.type === 'model.delta') { + const text = (event.payload as { text?: string } | undefined)?.text + return text ? { delta: text } : null + } + if (event.type === 'tool.call') { + const call = (event.payload as { toolCall?: { name?: string } } | undefined)?.toolCall + return call?.name ? { activity: { kind: 'call', tool: call.name } } : null + } + if (event.type === 'tool.result') { + const payload = event.payload as { toolCall?: { name?: string }; denied?: boolean } | undefined + return payload?.toolCall?.name + ? { activity: { kind: 'result', tool: payload.toolCall.name, denied: payload.denied } } + : null + } + // NOT `model.completed`: with the tool loop (0394) a single run makes several + // model round trips, so that fires mid-turn. Settling on it would end the + // stream early and drop everything the assistant said after its first tool + // call. `run.completed` is the only event that means the turn is over. + if (event.type === 'run.completed') { + return { settled: true } + } + if (event.type === 'run.failed') { + const message = (event.payload as { error?: string } | undefined)?.error + return { settled: true, error: message ?? 'run failed' } + } + if (event.type === 'run.cancelled') { + return { settled: true } + } + return null +} + +export interface ChatEventHandlers { + onDelta: (text: string) => void + onSettled: () => void + onError: (message: string) => void + /** Optional: surface tool activity while the turn is still running. */ + onActivity?: (activity: ChatToolActivity) => void +} + +/** Apply a runtime event to the chat handlers, filtered to the active thread. */ +export function applyRuntimeEvent( + event: RuntimeEventLike, + activeThreadId: string | null, + handlers: ChatEventHandlers +): void { + if (event.threadId && event.threadId !== activeThreadId) return + const effect = reduceRuntimeEvent(event) + if (!effect) return + if (effect.delta) handlers.onDelta(effect.delta) + if (effect.activity) handlers.onActivity?.(effect.activity) + if (effect.settled) handlers.onSettled() + if (effect.error) handlers.onError(effect.error) +} + +/** Whether a message can be sent right now. */ +export function canSendMessage(content: string, streaming: boolean, hasRuntime: boolean): boolean { + return content.length > 0 && !streaming && hasRuntime +} + +export function errorMessage(err: unknown): string { + const raw = err instanceof Error ? err.message : String(err) + // A browser CORS block and an unreachable local server both surface as an + // opaque "Failed to fetch" / "Load failed" / "NetworkError". Turn that into + // the actionable next step instead of a dead-end stack message. + if (/failed to fetch|networkerror|load failed|\bcors\b/i.test(raw)) { + return ( + 'Could not reach the model. For a cloud key this is usually a CORS block; ' + + 'for a local model, allow this origin (set OLLAMA_ORIGINS or enable the LM Studio CORS toggle).' + ) + } + return raw +} diff --git a/apps/web/src/workbench/views/ai-chat-persistence.test.ts b/packages/workbench/src/views/ai-chat-persistence.test.ts similarity index 100% rename from apps/web/src/workbench/views/ai-chat-persistence.test.ts rename to packages/workbench/src/views/ai-chat-persistence.test.ts diff --git a/packages/workbench/src/views/ai-chat-persistence.ts b/packages/workbench/src/views/ai-chat-persistence.ts new file mode 100644 index 000000000..8fac19a35 --- /dev/null +++ b/packages/workbench/src/views/ai-chat-persistence.ts @@ -0,0 +1,89 @@ +/** + * Persist AI conversations as workspace nodes (exploration 0391, Phase 2). + * + * Every AI thread becomes a real Channel node and each turn a ChatMessage — + * the same schemas the comms surface uses — so a research conversation is + * searchable (FTS-indexed like everything else), linkable, syncable, and + * reopenable instead of evaporating with the panel. No new schema: reusing + * Channel/ChatMessage keeps seed coverage and the comms UI's rendering free. + * + * Persistence is deliberately fire-and-forget from the panel's perspective: + * a storage hiccup must never break the live chat (the transcript still + * lives in panel state), so every step collapses failures to a warning. + */ + +import type { DefinedSchema, InferCreateProps, PropertyBuilder } from '@xnetjs/data' +import { ChannelSchema, ChatMessageSchema } from '@xnetjs/data' + +/** The minimal typed-create surface we need (a `DataBridge` satisfies it). */ +export interface AiChatPersistenceStore { + create

>( + schema: DefinedSchema

, + data: InferCreateProps

, + id?: string + ): Promise +} + +/** Channel name for a conversation, derived from its opening message. */ +export function aiChannelName(firstUserMessage: string): string { + const compact = firstUserMessage.replace(/\s+/g, ' ').trim() + const clipped = compact.length > 48 ? `${compact.slice(0, 47)}…` : compact + return `AI · ${clipped || 'conversation'}` +} + +export interface AiConversationLog { + /** The Channel node id backing this conversation (set after the first turn). */ + readonly channelId: string | null + /** Record the user's message, creating the channel on the first call. */ + logUserMessage(content: string, connectorLabel: string): Promise + /** Record the assistant's settled reply. */ + logAssistantReply(content: string): Promise +} + +/** + * A per-conversation logger. Writes are serialized on an internal chain so + * the channel exists before its first message even when callers don't await. + */ +export function createAiConversationLog( + store: AiChatPersistenceStore, + options: { warn?: (message: string, error: unknown) => void } = {} +): AiConversationLog { + const warn = + options.warn ?? + ((message: string, error: unknown) => console.warn(`[ai-chat] ${message}`, error)) + let channelId: string | null = null + let chain: Promise = Promise.resolve() + + const enqueue = (step: () => Promise): Promise => { + chain = chain.then(step).catch((error) => { + warn('failed to persist conversation turn', error) + }) + return chain + } + + return { + get channelId() { + return channelId + }, + logUserMessage(content, connectorLabel) { + return enqueue(async () => { + if (!channelId) { + const node = (await store.create(ChannelSchema, { + kind: 'channel', + name: aiChannelName(content), + topic: `AI conversation — assistant replies via ${connectorLabel}` + })) as { id?: string } | null + if (!node?.id) throw new Error('channel create returned no id') + channelId = node.id + } + await store.create(ChatMessageSchema, { channel: channelId, content }) + }) + }, + logAssistantReply(content) { + return enqueue(async () => { + if (!channelId || !content.trim()) return + await store.create(ChatMessageSchema, { channel: channelId, content }) + }) + } + } +} diff --git a/apps/web/src/workbench/views/ai-chat-tools.test.ts b/packages/workbench/src/views/ai-chat-tools.test.ts similarity index 100% rename from apps/web/src/workbench/views/ai-chat-tools.test.ts rename to packages/workbench/src/views/ai-chat-tools.test.ts diff --git a/packages/workbench/src/views/ai-chat-tools.ts b/packages/workbench/src/views/ai-chat-tools.ts new file mode 100644 index 000000000..f4c4e639a --- /dev/null +++ b/packages/workbench/src/views/ai-chat-tools.ts @@ -0,0 +1,78 @@ +/** + * Read-only tools for the in-app assistant (exploration 0394, Phase 1). + * + * The 28 `xnet_*` tools already reached MCP clients, the CLI, and the bridge; + * the in-app chat was the one consumer that never got them, so it could only + * answer from whatever context happened to be injected. This selects the + * subset it may call and turns it into provider tool specs. + * + * Phase 1 is deliberately read-only. Nothing here can write, propose, or + * fetch: the plan→approve→apply ceremony that makes writes safe has no in-chat + * surface yet, and shipping write tools before the consent UI would be exactly + * the over-promise the panel's capability badge used to warn about. + */ + +import type { AiSurfaceService, AIToolSpec, ToolCallingFidelity } from '@xnetjs/plugins' + +/** + * The Phase 1 allow-list. Kept explicit rather than derived from `risk: 'low'` + * alone so that adding a low-risk tool to the registry cannot silently widen + * what the in-app assistant can reach — the two conditions are ANDed below. + */ +export const READ_ONLY_TOOL_NAMES = [ + 'xnet_search', + 'xnet_graph_expand', + 'xnet_read_page_markdown', + 'xnet_database_describe', + 'xnet_database_query', + 'xnet_database_sample', + 'xnet_canvas_list', + 'xnet_canvas_read_viewport', + 'xnet_canvas_search', + 'xnet_get_audit_log' +] as const + +const ALLOWED = new Set(READ_ONLY_TOOL_NAMES) + +/** + * Whether a tier may be given tools at all. + * + * Only `reliable` tool-callers qualify. A `weak` tier (in-tab WebLLM) emits + * malformed or hallucinated calls often enough that handing it tools produces + * confident nonsense rather than grounded answers, and `none` cannot call them + * at all — both are better served by the injected context pack. This is the + * same fidelity signal `writeModeFor()` uses to decide propose-vs-apply. + */ +export function toolsEnabledFor(fidelity: ToolCallingFidelity | undefined): boolean { + return fidelity === 'reliable' +} + +/** + * The read-only tool specs to advertise, in registry order. Returns `[]` when + * the tier can't be trusted with tools, so the caller can pass the result + * straight through without branching. + */ +export function readOnlyToolSpecs( + surface: Pick | null, + fidelity: ToolCallingFidelity | undefined +): AIToolSpec[] { + if (!surface || !toolsEnabledFor(fidelity)) return [] + return surface + .getTools() + .filter((tool) => ALLOWED.has(tool.name) && tool.risk === 'low') + .map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: { ...tool.inputSchema } + })) +} + +/** Standing instructions describing the tools, appended when they're enabled. */ +export const AI_TOOLS_PROMPT = [ + 'You can call read-only tools to look things up in the workspace yourself —', + 'search it, read a page, describe or query a database, expand the graph around', + 'a node. Prefer calling a tool over guessing, and prefer it over saying you', + 'lack access. Cite what you found by title or id. These tools only read: you', + 'cannot create, edit, or delete anything, so when asked to make a change,', + 'describe precisely what you would change and ask the user to apply it.' +].join(' ') diff --git a/apps/web/src/workbench/views/ai-context.test.ts b/packages/workbench/src/views/ai-context.test.ts similarity index 100% rename from apps/web/src/workbench/views/ai-context.test.ts rename to packages/workbench/src/views/ai-context.test.ts diff --git a/packages/workbench/src/views/ai-context.ts b/packages/workbench/src/views/ai-context.ts new file mode 100644 index 000000000..edaf43f12 --- /dev/null +++ b/packages/workbench/src/views/ai-context.ts @@ -0,0 +1,52 @@ +/** + * Workspace grounding for the AI chat panel (exploration 0192, Phase 1). + * + * Turns a read-only {@link AiContextPack} (from `AiSurfaceService.createContextPack`) + * into the system messages the runtime injects ahead of the conversation, so the + * assistant can answer about the user's actual pages/databases/nodes instead of + * guessing. Pure + tested — no store, no network. + */ + +import type { AiContextPack, AIMessage } from '@xnetjs/plugins' + +/** Standing instructions for the in-app assistant. */ +export const AI_SYSTEM_PROMPT = [ + "You are xNet, a helpful assistant embedded in the user's local-first workspace.", + 'You may be given read-only "Workspace context" gathered from the user\'s own pages,', + 'databases, and nodes. Ground your answers in that context and cite the relevant', + 'item (by title or id) when you use it. If the context does not contain the answer,', + 'say so plainly rather than inventing details. This in-app chat is read-only: you can', + 'answer and cite context but not edit the workspace directly, so when asked to make', + 'changes, explain what you would change and ask the user to apply it.' +].join(' ') + +/** Cap per-resource text so a few large pages can't blow the context window. */ +const MAX_RESOURCE_CHARS = 2000 + +/** + * Format a context pack into the (zero or one) system messages to inject before + * the conversation history. Returns `[]` when the pack has no resources, so a + * turn with no relevant context adds nothing. + */ +export function formatContextMessages(pack: AiContextPack | null | undefined): AIMessage[] { + const resources = pack?.resources ?? [] + if (resources.length === 0) return [] + + const blocks = resources.map((resource) => { + const { kind, id } = resource.citation + const text = + resource.text.length > MAX_RESOURCE_CHARS + ? `${resource.text.slice(0, MAX_RESOURCE_CHARS)}…` + : resource.text + return `### ${kind} · ${id}\n${text.trim()}` + }) + + return [ + { + role: 'system', + content: + 'Workspace context (read-only, may be incomplete — cite items you use):\n\n' + + blocks.join('\n\n') + } + ] +} diff --git a/apps/web/src/workbench/views/ai-graph-retriever.test.ts b/packages/workbench/src/views/ai-graph-retriever.test.ts similarity index 100% rename from apps/web/src/workbench/views/ai-graph-retriever.test.ts rename to packages/workbench/src/views/ai-graph-retriever.test.ts diff --git a/packages/workbench/src/views/ai-graph-retriever.ts b/packages/workbench/src/views/ai-graph-retriever.ts new file mode 100644 index 000000000..998564b82 --- /dev/null +++ b/packages/workbench/src/views/ai-graph-retriever.ts @@ -0,0 +1,215 @@ +/** + * Graph-aware context retriever for the AI chat (exploration 0211 — live wiring). + * + * This is the app-side glue that injects `@xnetjs/brain`'s `retrieve()` into the + * `AiSurfaceService` via its `retrieveContext` seam. Instead of the flat keyword + * scan the context pack used before, the assistant now gets a graph-walked, + * budgeted slice: keyword entry search over the local NodeStore, then bounded + * expansion along typed relations (resolved from the schema registry), with each + * hit carrying a readable provenance path. + * + * Deliberately uses **no embedding model** — entry search is keyword-only — so it + * adds zero boot weight and no heavy bundle dependency (the 0204 cold-start + * constraint). The vector tier can later swap in behind the same seam without + * touching this call site. + */ +import type { AiContextRetriever } from '@xnetjs/plugins' +import { + DEFAULT_HOP_DECAY, + retrieve, + schemaRelationFields, + type EntryHit, + type GraphAccess, + type GraphEdge, + type NodeText, + type RetrievalBudget +} from '@xnetjs/brain' +import { schemaRegistry, type SchemaIRI } from '@xnetjs/data' + +/** The minimal node shape the retriever reads (a `NodeState` satisfies it). */ +export interface GraphRetrieverNode { + id: string + schemaId: string + properties: Record + deleted: boolean +} + +/** The minimal NodeStore surface the retriever reads. */ +export interface GraphRetrieverStore { + get(id: string): Promise + list(options?: { limit?: number }): Promise + /** + * Cross-schema FTS5 search (`NodeStore.searchText`, exploration 0391). + * `null`/absent means no FTS in this storage — fall back to scanning. + */ + searchText?(query: string, limit: number): Promise | null> +} + +/** Resolve the relation-valued property names for a schema. */ +export type RelationFieldsLookup = (schemaId: string) => Promise + +export interface GraphContextRetrieverOptions { + /** Relation-field resolver; defaults to the global client schema registry. */ + relationFieldsOf?: RelationFieldsLookup + /** Override the retrieval budget. */ + budget?: Partial + /** + * Override the entry search (the seam the semantic/vector tier swaps in behind, + * exploration 0211). Defaults to keyword search over the local store. + */ + entrySearch?: (query: string, k: number) => Promise +} + +const TEXT_KEYS = [ + 'title', + 'name', + 'displayName', + 'label', + 'subject', + 'summary', + 'description', + 'text', + 'body', + 'content', + 'bio', + 'caption' +] as const + +const SCAN_LIMIT = 500 +const SNIPPET_MAX = 600 +const DEFAULT_BUDGET: RetrievalBudget = { + maxTokens: 24_000, + maxHops: 1, + maxEntries: 12, + maxNodes: 48, + hopDecay: DEFAULT_HOP_DECAY +} + +/** Title (first text-bearing property) + joined body of a node's text. */ +export function nodeTextParts(node: GraphRetrieverNode): { title: string; body: string } { + const parts: string[] = [] + for (const key of TEXT_KEYS) { + const value = node.properties[key] + if (typeof value === 'string' && value.trim().length > 0) parts.push(value.trim()) + } + return { title: parts[0]?.slice(0, 200) ?? node.id, body: parts.join('\n') } +} + +/** Default relation-field resolver backed by the global schema registry (memoized). */ +function registryRelationFields(): RelationFieldsLookup { + const cache = new Map() + return async (schemaId) => { + const cached = cache.get(schemaId) + if (cached) return cached + const defined = await schemaRegistry.get(schemaId as SchemaIRI) + const fields = defined ? schemaRelationFields(defined) : [] + cache.set(schemaId, fields) + return fields + } +} + +/** + * Keyword entry search. Prefers the indexed FTS5 path (`store.searchText`, + * BM25-ranked over `nodes_fts` — exploration 0379's fix, wired in 0391); falls + * back to the title-boosted substring scan when the storage has no FTS + * (memory adapter, sql.js). + */ +export function keywordEntrySearch( + store: GraphRetrieverStore +): (query: string, k: number) => Promise { + return async (query, k) => { + const needle = query.trim().toLocaleLowerCase() + if (!needle) return [] + if (store.searchText) { + const matches = await store.searchText(query, k).catch(() => null) + if (matches !== null && matches !== undefined) { + // BM25 rank: more negative = better. Negate so bigger score wins, + // matching EntryHit's convention. + return matches.map((match) => ({ + nodeId: match.nodeId, + score: -match.rank, + source: 'keyword' as const + })) + } + } + const nodes = await store.list({ limit: SCAN_LIMIT }) + const hits: EntryHit[] = [] + for (const node of nodes) { + if (node.deleted) continue + const { title, body } = nodeTextParts(node) + const idx = `${title}\n${body}`.toLocaleLowerCase().indexOf(needle) + if (idx === -1) continue + const titleMatch = title.toLocaleLowerCase().includes(needle) + hits.push({ + nodeId: node.id, + score: (titleMatch ? 10 : 1) + Math.max(0, 5 - idx / 100), + source: 'keyword' + }) + } + hits.sort((a, b) => b.score - a.score) + return hits.slice(0, k) + } +} + +/** Graph access that reads outbound relation edges, schema-resolved + memoized. */ +function schemaGraphAccess( + store: GraphRetrieverStore, + relationFieldsOf: RelationFieldsLookup +): GraphAccess { + return { + async neighbors(nodeId) { + const node = await store.get(nodeId) + if (!node || node.deleted) return [] + const edges: GraphEdge[] = [] + for (const field of await relationFieldsOf(node.schemaId)) { + const value = node.properties[field] + const targets = Array.isArray(value) ? value : [value] + for (const target of targets) { + if (typeof target === 'string' && target.length > 0) { + edges.push({ nodeId: target, relation: field, direction: 'outbound' }) + } + } + } + return edges + } + } +} + +/** Load a node's title/snippet for the retrieved context. */ +function nodeTextLoader(store: GraphRetrieverStore): (id: string) => Promise { + return async (id) => { + const node = await store.get(id) + if (!node || node.deleted) return null + const { title, body } = nodeTextParts(node) + return { + title, + snippet: body.replace(/\s+/g, ' ').trim().slice(0, SNIPPET_MAX), + schemaId: node.schemaId + } + } +} + +/** + * Build a graph-aware `AiContextRetriever` over the local NodeStore. Wire it into + * `createAiSurfaceService({ store, schemas, retrieveContext })`. + */ +export function createGraphContextRetriever( + store: GraphRetrieverStore, + options: GraphContextRetrieverOptions = {} +): AiContextRetriever { + const relationFieldsOf = options.relationFieldsOf ?? registryRelationFields() + const graph = schemaGraphAccess(store, relationFieldsOf) + const loadText = nodeTextLoader(store) + const entrySearch = options.entrySearch ?? keywordEntrySearch(store) + + return async (query, { limit }) => { + const budget: RetrievalBudget = { + ...DEFAULT_BUDGET, + maxEntries: Math.max(limit, 4), + maxNodes: Math.max(limit * 4, 24), + ...options.budget + } + const result = await retrieve(query, budget, { entrySearch, graph, loadText }) + return result.items.map((item) => ({ nodeId: item.nodeId, pathLabel: item.pathLabel })) + } +} diff --git a/apps/web/src/workbench/views/ai-schemas.test.ts b/packages/workbench/src/views/ai-schemas.test.ts similarity index 100% rename from apps/web/src/workbench/views/ai-schemas.test.ts rename to packages/workbench/src/views/ai-schemas.test.ts diff --git a/packages/workbench/src/views/ai-schemas.ts b/packages/workbench/src/views/ai-schemas.ts new file mode 100644 index 000000000..3faacb690 --- /dev/null +++ b/packages/workbench/src/views/ai-schemas.ts @@ -0,0 +1,42 @@ +/** + * Adapt the client `schemaRegistry` (@xnetjs/data) to the `SchemaRegistryAPI` + * shape the AiSurfaceService expects (exploration 0192, Phase 1). + * + * The client's `DefinedSchema` keeps its metadata under `.schema` and exposes + * properties as an array; the surface wants a flat `{ iri, name, properties }` + * with properties keyed by name. The mapping is pure + tested; the registry + * wiring is a thin wrapper over the global singleton. + */ + +import type { SchemaData, SchemaRegistryAPI } from '@xnetjs/plugins' +import { schemaRegistry, type SchemaIRI } from '@xnetjs/data' + +/** The minimal shape of a client schema we read (a `DefinedSchema` satisfies it). */ +export interface DefinedSchemaLike { + schema: { + '@id': string + name: string + properties: Array<{ name: string }> + } +} + +/** Flatten a client schema into the surface's `SchemaData` (properties by name). */ +export function toSchemaData(defined: DefinedSchemaLike): SchemaData { + const { schema } = defined + return { + iri: schema['@id'], + name: schema.name, + properties: Object.fromEntries(schema.properties.map((property) => [property.name, property])) + } +} + +/** A `SchemaRegistryAPI` backed by the global client schema registry. */ +export function schemaRegistryApi(): SchemaRegistryAPI { + return { + getAllIRIs: () => schemaRegistry.getAllIRIs(), + get: async (iri) => { + const defined = await schemaRegistry.get(iri as SchemaIRI) + return defined ? toSchemaData(defined) : null + } + } +} diff --git a/apps/web/src/workbench/views/ai-vector-search.test.ts b/packages/workbench/src/views/ai-vector-search.test.ts similarity index 100% rename from apps/web/src/workbench/views/ai-vector-search.test.ts rename to packages/workbench/src/views/ai-vector-search.test.ts diff --git a/packages/workbench/src/views/ai-vector-search.ts b/packages/workbench/src/views/ai-vector-search.ts new file mode 100644 index 000000000..1263741d4 --- /dev/null +++ b/packages/workbench/src/views/ai-vector-search.ts @@ -0,0 +1,165 @@ +/** + * Lazy, opt-in semantic (vector) entry search for the AI chat (exploration 0211). + * + * Wakes the dormant `@xnetjs/vectors` engine BEHIND the `createGraphContextRetriever` + * seam: when enabled, entry search fuses on-device vector similarity with the + * keyword scan (Reciprocal Rank Fusion), so the assistant finds things by meaning, + * not just literal text. The graph-walk + token budget then proceed unchanged. + * + * Safety first (the 0204 cold-start constraint): + * - The heavy `@xenova` model + `usearch` are pulled in only through a **dynamic + * import**, and only on the FIRST search after the user opts in — zero boot or + * bundle cost when the flag is off. + * - Until the index is warm (model loading / backfilling) and on ANY failure, it + * transparently falls back to keyword search. Enabling it can only ever make + * results as good or better — never worse, never broken. + * - With a blob store it restores/persists the index instead of re-embedding the + * graph every session (the `@xnetjs/brain` persist layer). + */ +import { loadVectorTier, saveVectorTier, type BlobStore, type EntryHit } from '@xnetjs/brain' +import { keywordEntrySearch, nodeTextParts, type GraphRetrieverStore } from './ai-graph-retriever' + +/** The semantic index surface we use (`@xnetjs/vectors` `SemanticSearch` satisfies it). */ +export interface SemanticIndexLike { + initialize(): Promise + indexDocument(id: string, content: string): Promise + search( + query: string, + options?: { maxResults?: number; minScore?: number } + ): Promise> + serialize(): unknown + restore(data: unknown): void +} + +/** The vectors engine loader (injected in tests; defaults to a dynamic import). */ +export type VectorEngineLoader = () => Promise<{ + createSemanticSearch: (config: { useMockModel?: boolean }) => SemanticIndexLike +}> + +export interface VectorEntrySearchOptions { + store: GraphRetrieverStore + /** Deterministic mock model (tests). Default false → real on-device `@xenova`. */ + useMockModel?: boolean + /** Optional blob store; when present, restore/persist instead of re-embedding. */ + storage?: BlobStore + /** Max nodes to embed on a cold backfill. */ + maxBackfill?: number + /** Vector weight for RRF fusion (0–1). Default 0.5. */ + vectorWeight?: number + /** Loader for the vectors engine; defaults to `() => import('@xnetjs/vectors')`. */ + loadEngine?: VectorEngineLoader + /** Init/backfill timeout in ms. */ + timeoutMs?: number +} + +export interface VectorEntrySearch { + /** Entry search: hybrid once the index is warm, keyword until then / on failure. */ + search(query: string, k: number): Promise + /** True once the semantic index is built and serving. */ + ready(): boolean +} + +const DEFAULT_TIMEOUT_MS = 30_000 +const RRF_K = 60 + +function withTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error('vector init timeout')), ms)) + ]) +} + +/** Embed every text-bearing node in the store into the index (cold backfill). */ +async function backfill( + index: SemanticIndexLike, + store: GraphRetrieverStore, + maxBackfill: number +): Promise { + const nodes = await store.list({ limit: maxBackfill }) + for (const node of nodes) { + if (node.deleted) continue + const { body } = nodeTextParts(node) + if (body.length > 0) await index.indexDocument(node.id, body) + } +} + +/** Fuse vector + keyword hits via Reciprocal Rank Fusion. */ +async function hybridSearch( + index: SemanticIndexLike, + keyword: (query: string, k: number) => Promise, + query: string, + k: number, + vectorWeight: number +): Promise { + const keywordWeight = 1 - vectorWeight + const [vectorHits, keywordHits] = await Promise.all([ + index.search(query, { maxResults: k * 2 }), + keyword(query, k * 2) + ]) + + const ranks = new Map() + vectorHits.forEach((hit, i) => ranks.set(hit.id, { ...ranks.get(hit.id), v: i + 1 })) + keywordHits.forEach((hit, i) => ranks.set(hit.nodeId, { ...ranks.get(hit.nodeId), kw: i + 1 })) + + const fused: EntryHit[] = [] + for (const [nodeId, rank] of ranks.entries()) { + const score = + (rank.v ? vectorWeight / (RRF_K + rank.v) : 0) + + (rank.kw ? keywordWeight / (RRF_K + rank.kw) : 0) + const source: EntryHit['source'] = rank.v && rank.kw ? 'hybrid' : rank.v ? 'vector' : 'keyword' + fused.push({ nodeId, score, source }) + } + fused.sort((a, b) => b.score - a.score) + return fused.slice(0, k) +} + +/** + * Build a lazy, fallback-safe semantic entry search. Pass `.search` as the + * `entrySearch` option of `createGraphContextRetriever`. + */ +export function createVectorEntrySearch(options: VectorEntrySearchOptions): VectorEntrySearch { + const { + store, + useMockModel = false, + storage, + maxBackfill = 2000, + vectorWeight = 0.5, + timeoutMs = DEFAULT_TIMEOUT_MS + } = options + const keyword = keywordEntrySearch(store) + const loadEngine: VectorEngineLoader = options.loadEngine ?? (() => import('@xnetjs/vectors')) + + let state: 'idle' | 'loading' | 'ready' | 'failed' = 'idle' + let index: SemanticIndexLike | null = null + + async function init(): Promise { + state = 'loading' + try { + const engine = await withTimeout(loadEngine(), timeoutMs) + const search = engine.createSemanticSearch({ useMockModel }) + await withTimeout(search.initialize(), timeoutMs) + const restored = storage ? await loadVectorTier(search, storage) : false + if (!restored) await withTimeout(backfill(search, store, maxBackfill), timeoutMs) + index = search + state = 'ready' + if (storage) void saveVectorTier(search, storage).catch(() => {}) + } catch { + // Any failure (model load, WASM, network, timeout) → stay on keyword forever. + state = 'failed' + } + } + + return { + ready: () => state === 'ready', + async search(query, k) { + // Kick off the (idempotent) lazy build; keyword serves until it's ready. + if (state === 'idle') void init() + if (state !== 'ready' || !index) return keyword(query, k) + try { + return await hybridSearch(index, keyword, query, k, vectorWeight) + } catch { + return keyword(query, k) + } + } + } +} diff --git a/packages/workbench/src/views/ai-vector-storage.ts b/packages/workbench/src/views/ai-vector-storage.ts new file mode 100644 index 000000000..87152bec0 --- /dev/null +++ b/packages/workbench/src/views/ai-vector-storage.ts @@ -0,0 +1,66 @@ +/** + * IndexedDB-backed blob store for the AI chat's semantic vector tier (0211). + * + * Lets `createVectorEntrySearch` restore the embedding index across sessions + * instead of re-embedding the whole graph every time. Thin glue over IndexedDB + * (durable, holds the ~tens-of-KB serialized index, no size cap like + * localStorage). Returns `undefined` where IndexedDB is unavailable, in which + * case the tier simply re-backfills — still correct, just not persisted. + * + * Note: IndexedDB round-trips bytes through structured clone, which can hand back + * a cross-realm `Uint8Array`; the `@xnetjs/brain` persist layer is realm-robust + * for exactly this reason. + */ +import type { BlobStore } from '@xnetjs/brain' + +const DB_NAME = 'xnet-ai-vectors' +const STORE_NAME = 'tier' + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1) + request.onupgradeneeded = () => request.result.createObjectStore(STORE_NAME) + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) +} + +function runRequest( + makeRequest: (store: IDBObjectStore) => IDBRequest, + mode: IDBTransactionMode +) { + return async (): Promise => { + const db = await openDb() + try { + return await new Promise((resolve, reject) => { + const request = makeRequest(db.transaction(STORE_NAME, mode).objectStore(STORE_NAME)) + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + } finally { + db.close() + } + } +} + +/** + * Create an IndexedDB-backed `BlobStore`, or `undefined` when IndexedDB is + * unavailable (SSR, locked-down environments) — the caller treats that as + * "no persistence" and falls back to re-embedding. + */ +export function createVectorBlobStore(): BlobStore | undefined { + if (typeof indexedDB === 'undefined') return undefined + return { + async getBlob(key) { + const value = await runRequest((store) => store.get(key), 'readonly')() + return value instanceof Uint8Array + ? value + : value == null + ? null + : new Uint8Array(value as ArrayBuffer) + }, + setBlob(key, data) { + return runRequest((store) => store.put(data, key), 'readwrite')().then(() => undefined) + } + } +} diff --git a/packages/workbench/src/views/ai-webllm-engine.ts b/packages/workbench/src/views/ai-webllm-engine.ts new file mode 100644 index 000000000..8c14727a0 --- /dev/null +++ b/packages/workbench/src/views/ai-webllm-engine.ts @@ -0,0 +1,77 @@ +/** + * In-tab WebLLM engine (exploration 0252, finishing 0174 tier A). + * + * Builds a real `@mlc-ai/web-llm` engine and wraps it in the dependency-free + * `WebLLMProvider` from `@xnetjs/plugins`. The heavy library is imported lazily + * — only when the user actually loads the in-browser model — so it never lands + * in the main bundle (mirrors how `@xnetjs/vectors` lazy-loads its embedding + * model). The model weights download once and are cached by the browser, so the + * tier then runs offline. Nothing leaves the device. + * + * This is the engine-injection path the panel passes as `hasWebLLMEngine`, + * which is what flips the `webllm` connector from "detectable" to "usable". + */ + +import { createWebLLMProvider, type WebLLMEngineLike, type WebLLMProvider } from '@xnetjs/plugins' + +// `@mlc-ai/web-llm` ships no node-safe entry and has its own heavy types; load +// it lazily and structurally, like the embedding model in @xnetjs/vectors. +let webllmModule: any = null + +async function getWebLLM(): Promise<{ + CreateMLCEngine: (model: string, options: unknown) => Promise +}> { + if (!webllmModule) webllmModule = await import('@mlc-ai/web-llm') + return webllmModule +} + +/** + * Default in-tab model: a small (~1 GB) instruct model so the first-run + * download stays friendly. Cached after the first load. Bump to a 3B for more + * quality once the download cost is acceptable. + */ +export const DEFAULT_WEBLLM_MODEL = 'Llama-3.2-1B-Instruct-q4f16_1-MLC' + +/** First-run load/download progress, as `@mlc-ai/web-llm` reports it. */ +export interface WebLLMProgress { + /** Fraction in [0, 1]. */ + fraction: number + /** Human-readable status (e.g. "Fetching param cache[12/38]"). */ + text: string +} + +export interface BuildWebLLMOptions { + /** Model id from the WebLLM prebuilt catalog. Default {@link DEFAULT_WEBLLM_MODEL}. */ + model?: string + /** Called as the model downloads/initialises, for a progress bar. */ + onProgress?: (progress: WebLLMProgress) => void +} + +/** + * Create an in-tab WebLLM provider, downloading + initialising the model on + * first use. Rejects if WebGPU is unavailable or the download fails (the caller + * surfaces that as the composer's error). Must be reachable from a user gesture + * so the multi-hundred-MB download isn't a surprise. + */ +export async function buildWebLLMProvider( + options: BuildWebLLMOptions = {} +): Promise { + const model = options.model ?? DEFAULT_WEBLLM_MODEL + const { CreateMLCEngine } = await getWebLLM() + let engine: WebLLMEngineLike + try { + engine = await CreateMLCEngine(model, { + initProgressCallback: (report: { progress: number; text: string }) => + options.onProgress?.({ fraction: report.progress, text: report.text }) + }) + } catch (cause) { + // The generic "Failed to fetch" from a blocked/aborted weight download would + // otherwise be rewritten as a cloud-key CORS hint by `errorMessage` — wrong + // for an in-tab model. Give a WebLLM-specific message the panel shows as-is. + throw new Error( + `Couldn't load the in-browser model (${model}). Check your connection — the model weights download from the Hugging Face CDN on first run.`, + { cause } + ) + } + return createWebLLMProvider({ engine, model }) +} diff --git a/packages/workbench/src/views/ai.ts b/packages/workbench/src/views/ai.ts new file mode 100644 index 000000000..117ee5b51 --- /dev/null +++ b/packages/workbench/src/views/ai.ts @@ -0,0 +1,22 @@ +/** + * The AI chat cluster (explorations 0174/0192/0391), exported as its own + * subpath — `@xnetjs/workbench/ai` — so hosts opt in: the panel pulls + * @xnetjs/brain and the WebLLM engine plumbing, which the core barrel's + * consumers should not pay for. + * + * The panel is host-aware by construction: it probes `window.xnetAgentBridge` + * (the desktop preload, #638) for the bridge tier and auto-pairs over IPC, + * and falls back to the browser tiers (managed, cloud-key, local-server, + * webllm) everywhere else. + */ + +export { AiChatPanel } from './AiChatPanel' +export * from './ai-chat-connector' +export * from './ai-chat-persistence' +export * from './ai-chat-tools' +export * from './ai-context' +export * from './ai-graph-retriever' +export * from './ai-schemas' +export * from './ai-vector-search' +export * from './ai-vector-storage' +export * from './ai-webllm-engine' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd448491c..dea339936 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -303,6 +303,9 @@ importers: '@xnetjs/views': specifier: workspace:* version: link:../../packages/views + '@xnetjs/workbench': + specifier: workspace:* + version: link:../../packages/workbench better-sqlite3: specifier: ^11.0.0 version: 11.10.0 @@ -2375,9 +2378,24 @@ importers: packages/workbench: dependencies: + '@mlc-ai/web-llm': + specifier: ^0.2.84 + version: 0.2.84 + '@xnetjs/brain': + specifier: workspace:* + version: link:../brain + '@xnetjs/data': + specifier: workspace:* + version: link:../data '@xnetjs/plugins': specifier: workspace:* version: link:../plugins + '@xnetjs/react': + specifier: workspace:* + version: link:../react + '@xnetjs/vectors': + specifier: workspace:* + version: link:../vectors lucide-react: specifier: ^0.453.0 version: 0.453.0(react@18.3.1) diff --git a/site/src/data/changelog/2026-07-28-the-desktop-app-has-an-assistant.json b/site/src/data/changelog/2026-07-28-the-desktop-app-has-an-assistant.json new file mode 100644 index 000000000..a35359ba7 --- /dev/null +++ b/site/src/data/changelog/2026-07-28-the-desktop-app-has-an-assistant.json @@ -0,0 +1,8 @@ +{ + "id": "2026-07-28-the-desktop-app-has-an-assistant", + "date": "July 28, 2026", + "title": "The desktop app has an Assistant", + "summary": "Open Assistant from the system menu or command palette to chat with your workspace on desktop. It finds your Claude Code or Codex subscription automatically and pairs with one click — your model, your machine, and it can search and read your actual pages and data.", + "highlights": [], + "tags": ["ai", "app"] +} diff --git a/vitest.config.ts b/vitest.config.ts index 06bd50fcc..abde5deed 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -88,6 +88,8 @@ const workspaceAliases = { '@xnetjs/unreal': new URL('./packages/unreal/src/index.ts', import.meta.url).pathname, '@xnetjs/vectors': new URL('./packages/vectors/src/index.ts', import.meta.url).pathname, '@xnetjs/views': new URL('./packages/views/src/index.ts', import.meta.url).pathname, + // Subpath alias MUST precede the bare '@xnetjs/workbench' (Vite uses first match). + '@xnetjs/workbench/ai': new URL('./packages/workbench/src/views/ai.ts', import.meta.url).pathname, '@xnetjs/workbench': new URL('./packages/workbench/src/index.ts', import.meta.url).pathname }