@molecule/app-ai-assistant-default

Provider bond · ai-assistant · App (browser) · v1.0.2 · Apache-2.0

Default HTTP/SSE AI assistant panel provider

npm install @molecule/app-ai-assistant-default

npm · Source on GitHub · Implements @molecule/app-ai-assistant

How it works

@molecule/app-ai-assistant-default is a provider bond on the app (browser) side: it implements the ai-assistant core interface (@molecule/app-ai-assistant) with a concrete vendor or library behind it.

Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.

import { setProvider } from '@molecule/app-ai-assistant'
import { provider } from '@molecule/app-ai-assistant-default'

setProvider(provider) // at startup; same-origin base URL

Reference

Default AI assistant provider for molecule.dev.

Uses HTTP/SSE to stream assistant replies from YOUR backend, with built-in panel state management and context awareness.

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Quick Start

import { setProvider } from '@molecule/app-ai-assistant'
import { provider } from '@molecule/app-ai-assistant-default'

setProvider(provider) // at startup; same-origin base URL

Type

provider

Installation

npm install @molecule/app-ai-assistant-default @molecule/app-ai-assistant

API

Interfaces

DefaultAssistantConfig

Configuration specific to the default HTTP/SSE assistant provider.

interface DefaultAssistantConfig {
  /** Base URL for API requests. Defaults to '' (same origin). */
  baseUrl?: string
  /** Custom HTTP headers to include in all requests. */
  headers?: Record<string, string>
}

Classes

DefaultAssistantProvider

Default AI assistant provider using HTTP/SSE for streaming.

Manages an internal panel state store with subscriber notifications, streams assistant responses via SSE, and supports context-enriched messaging.

class DefaultAssistantProvider implements AIAssistantProvider {
  readonly name = 'default'

  private readonly baseUrl: string
  private readonly defaultHeaders: Record<string, string>
  private state: AssistantPanelState
  private listeners: Set<AssistantStateListener> = new Set()
  private abortController: AbortController | null = null
  private messageCounter = 0

  /**
   * Create a new default assistant provider.
   *
   * @param config - Provider-specific configuration (base URL, headers, etc.).
   */
  constructor(config: DefaultAssistantConfig = {}) {
    this.baseUrl = config.baseUrl ?? ''
    this.defaultHeaders = config.headers ?? {}
    this.state = createInitialState()
  }

  /**
   * Open the assistant panel.
   *
   * @param config - Runtime assistant layout and suggestion settings.
   */
  open(config: AIAssistantConfig): void {
    this.setState({
      isOpen: true,
      position: config.position ?? this.state.position,
      suggestions: config.suggestions ?? this.state.suggestions,
    })
  }

  /** Close the assistant panel. */
  close(): void {
    this.abort()
    this.setState({ isOpen: false })
  }

  /**
   * Toggle the assistant panel open/closed.
   *
   * @param config - Runtime assistant layout and suggestion settings when opening.
   */
  toggle(config: AIAssistantConfig): void {
    if (this.state.isOpen) {
      this.close()
    } else {
      this.open(config)
    }
  }

  /**
   * Send a message and stream the response via SSE.
   *
   * @param message - The user's message text
   * @param config - Assistant configuration
   * @param onEvent - Callback for stream events
   */
  async sendMessage(
    message: string,
    config: AIAssistantConfig,
    onEvent: AssistantEventHandler,
  ): Promise<void> {
    this.abort()

    const userMessage: AssistantMessage = {
      id: `msg-${this.messageCounter++}`,
      role: 'user',
      content: message,
      timestamp: Date.now(),
    }

    const assistantMessage: AssistantMessage = {
      id: `msg-${this.messageCounter++}`,
      role: 'assistant',
      content: '',
      timestamp: Date.now(),
      isStreaming: true,
    }

    const messages = this.trimMessages(
      [...this.state.messages, userMessage, assistantMessage],
      config.maxMessages,
    )

    this.setState({ messages, isLoading: true, error: null })

    const controller = new AbortController()
    this.abortController = controller

    const url = `${this.baseUrl}${config.endpoint}`
    const headers: Record<string, string> = {
      'Content-Type': 'application/json',
      ...this.defaultHeaders,
      ...(config.headers ?? {}),
    }

    const body: Record<string, unknown> = { message }
    if (config.systemContext) {
      body.systemContext = config.systemContext
    }
    if (this.state.context.length > 0) {
      body.context = this.state.context
    }

    try {
      const response = await fetch(url, {
        method: 'POST',
        headers,
        body: JSON.stringify(body),
        signal: controller.signal,
      })

      if (!response.ok) {
        const text = await response.text().catch(() => response.statusText)
        const errorEvent: AssistantStreamEvent = {
          type: 'error',
          message: `HTTP ${response.status}: ${text}`,
        }
        onEvent(errorEvent)
        this.finalizeStream(assistantMessage.id, errorEvent.message)
        return
      }

      if (!response.body) {
        const errorEvent: AssistantStreamEvent = {
          type: 'error',
          message: 'No response body',
        }
        onEvent(errorEvent)
        this.finalizeStream(assistantMessage.id, errorEvent.message)
        return
      }

      await this.consumeStream(response.body, assistantMessage.id, onEvent)
    } catch (error: unknown) {
      if (error instanceof DOMException && error.name === 'AbortError') {
        this.markAborted(assistantMessage.id)
        return
      }

      const errorMessage = error instanceof Error ? error.message : 'Unknown error'
      const errorEvent: AssistantStreamEvent = {
        type: 'error',
        message: errorMessage,
      }
      onEvent(errorEvent)
      this.finalizeStream(assistantMessage.id, errorMessage)
    }
  }

  /** Abort the current streaming response. */
  abort(): void {
    if (this.abortController) {
      this.abortController.abort()
      this.abortController = null
    }
  }

  /**
   * Set the current context items.
   *
   * @param context - Array of context items
   */
  setContext(context: AssistantContext[]): void {
    this.setState({ context })
  }

  /** Clear all context items. */
  clearContext(): void {
    this.setState({ context: [] })
  }

  /**
   * Clear conversation history on the backend and locally.
   *
   * @param config - Assistant configuration
   */
  async clearHistory(config: AIAssistantConfig): Promise<void> {
    const url = `${this.baseUrl}${config.endpoint}`
    const headers: Record<string, string> = {
      ...this.defaultHeaders,
      ...(config.headers ?? {}),
    }

    try {
      await fetch(url, { method: 'DELETE', headers })
    } catch (_error) {
      // Best-effort — local state is cleared regardless; a failed DELETE is non-fatal
    }

    this.messageCounter = 0
    this.setState({
      messages: [],
      error: null,
      suggestions: config.suggestions ?? [],
    })
  }

  /**
   * Load conversation history from the backend.
   *
   * @param config - Assistant configuration
   * @returns Array of past messages
   */
  async loadHistory(config: AIAssistantConfig): Promise<AssistantMessage[]> {
    const url = `${this.baseUrl}${config.endpoint}`
    const headers: Record<string, string> = {
      ...this.defaultHeaders,
      ...(config.headers ?? {}),
    }

    try {
      const response = await fetch(url, { method: 'GET', headers })
      if (!response.ok) {
        return []
      }

      const data = (await response.json()) as { messages?: unknown[] }
      const raw = data.messages ?? []

      const messages = raw.map((msg: unknown, index: number): AssistantMessage => {
        const m = msg as Record<string, unknown>
        return {
          id: (m.id as string) ?? `msg-${index}`,
          role: (m.role as AssistantMessage['role']) ?? 'user',
          content: (m.content as string) ?? '',
          timestamp: (m.timestamp as number) ?? Date.now(),
        }
      })

      this.messageCounter = messages.length
      this.setState({ messages })
      return messages
    } catch (_error) {
      // Best-effort — network or parse failure returns an empty history; caller handles gracefully
      return []
    }
  }

  /**
   * Get the current panel state snapshot.
   *
   * @returns The current state
   */
  getState(): AssistantPanelState {
    return { ...this.state }
  }

  /**
   * Subscribe to panel state changes.
   *
   * @param listener - Callback invoked on every state change
   * @returns Unsubscribe function
   */
  subscribe(listener: AssistantStateListener): () => void {
    this.listeners.add(listener)
    return () => {
      this.listeners.delete(listener)
    }
  }

  /**
   * Consume an SSE stream, parsing events and updating state.
   *
   * @param body - The ReadableStream from the fetch response
   * @param assistantMsgId - The ID of the assistant message being streamed
   * @param onEvent - Callback for stream events
   */
  private async consumeStream(
    body: ReadableStream<Uint8Array>,
    assistantMsgId: string,
    onEvent: AssistantEventHandler,
  ): Promise<void> {
    const reader = body.getReader()
    const decoder = new TextDecoder()
    let buffer = ''

    try {
      for (;;) {
        const { done, value } = await reader.read()
        if (done) break

        buffer += decoder.decode(value, { stream: true })
        const lines = buffer.split('\n')
        buffer = lines.pop() ?? ''

        for (const line of lines) {
          if (!line.startsWith('data: ')) continue

          const json = line.slice(6)
// … truncated — the full declaration is in node_modules/@molecule/app-ai-assistant-default/dist/provider.d.ts

Functions

createProvider(config)

Create a new default assistant provider instance.

function createProvider(config?: DefaultAssistantConfig): DefaultAssistantProvider
  • config — Optional provider-specific configuration

Returns: A new DefaultAssistantProvider instance

Constants

provider

Pre-instantiated provider singleton.

const provider: DefaultAssistantProvider

Core Interface

Implements @molecule/app-ai-assistant interface.

Bond Wiring

Setup function to register this provider with the core interface:

import { setProvider } from '@molecule/app-ai-assistant'
import { provider } from '@molecule/app-ai-assistant-default'

export function setupAiAssistantDefault(): void {
  setProvider(provider)
}

Injection Notes

Requirements

Peer dependencies:

  • @molecule/app-ai-assistant ^1.0.1

Runtime Dependencies

  • @molecule/app-ai-assistant

HEADLESS — manages panel state + streaming only; your app renders the panel from getState() / subscribe(). Talks to YOUR backend at config.endpoint (relative path on baseUrl, default same-origin) — it holds no AI key. Your API must implement, on that one endpoint:

  • POST { message, systemContext?, context? } → an SSE stream of data: <AssistantStreamEvent JSON> lines (text / thinking / suggestion / done / error),
  • GET → { messages: [...] } (loadHistory; fails open to [] on any error), and DELETE → clear history (best-effort; local state clears even if it fails). The bare provider export is createProvider() with no options — to set baseUrl/headers, wire setProvider(createProvider({ ... })) instead. sendMessage aborts any previous in-flight stream automatically.

E2E Tests

Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:

  • Opening the panel (open/toggle → getState().isOpen is true) and sending a message through sendMessage renders the user turn immediately, then the assistant reply below it — both present in getState().messages.
  • The reply STREAMS: text events append the assistant message progressively (token by token) while its isStreaming stays true, and isStreaming clears when the done event lands — not one atomic blob at the end.
  • A stop/cancel control mid-stream calls abort() and actually halts the reply: the message stops growing and is marked aborted, not left spinning.
  • A thinking/loading indicator driven by getState().isLoading shows while a response is in flight and clears once it settles — on done AND on abort.
  • A provider failure surfaces getState().error (from the error stream event) as a visible message in the panel — never a blank or perpetually spinning panel.
  • Suggestions from getState().suggestions render as chips, and clicking one sends THAT chip's own action string — the suggestion's wired message, not a generic prompt.
  • Context set via setContext (the selected code / current page) is actually attached to the request so the answer is context-aware; clearContext drops it, and one user's context never bleeds into another user's session.
  • Conversation history persists across turns and reloads — loadHistory rehydrates getState().messages and clearHistory empties the panel — and model output renders through the app's sanitizing markdown renderer, never as raw or executable HTML.