@molecule/app-ai-copilot-default

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

Default HTTP/SSE copilot provider for inline AI suggestions

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

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

How it works

@molecule/app-ai-copilot-default is a provider bond on the app (browser) side: it implements the ai-copilot core interface (@molecule/app-ai-copilot) 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-copilot'
import { provider } from '@molecule/app-ai-copilot-default'

setProvider(provider) // at startup — lazy; same-origin base URL, no config needed
// setProvider(createProvider({ baseUrl, headers })) to customize

Works with: @molecule/app-ai-copilot, @molecule/app-i18n

Live demos

Running apps built with Molecule that install this package — open the app or study the template.

Reference

Default ai-copilot provider for molecule.dev — HTTP/SSE inline AI suggestions from YOUR backend.

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-copilot'
import { provider } from '@molecule/app-ai-copilot-default'

setProvider(provider) // at startup — lazy; same-origin base URL, no config needed
// setProvider(createProvider({ baseUrl, headers })) to customize

Type

provider

Installation

npm install @molecule/app-ai-copilot-default @molecule/app-ai-copilot @molecule/app-i18n

API

Interfaces

DefaultCopilotConfig

Configuration for the default HTTP-based copilot provider.

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

Classes

DefaultCopilotProvider

HTTP/SSE-based copilot provider. Sends document context via POST and reads SSE streams for real-time inline suggestions.

class DefaultCopilotProvider implements AICopilotProvider {
  /** @inheritdoc */
  readonly name = 'default'

  private config: DefaultCopilotConfig
  private abortController: AbortController | null = null

  /**
   * Creates a new DefaultCopilotProvider.
   *
   * @param config - Optional provider-level configuration (base URL, headers).
   */
  constructor(config: DefaultCopilotConfig = {}) {
    this.config = config
  }

  /**
   * Requests inline suggestions from the backend.
   *
   * Aborts any previous in-flight request before starting a new one.
   * Parses the SSE stream and emits `CopilotEvent` objects via `onEvent`.
   *
   * @param context - Current editor context (prefix, suffix, language, etc.).
   * @param config - Request-level settings (endpoint, model, maxSuggestions).
   * @param onEvent - Callback for each streamed event.
   */
  async getSuggestions(
    context: CopilotContext,
    config: AICopilotConfig,
    onEvent: CopilotEventHandler,
  ): Promise<void> {
    this.abortController?.abort()
    const controller = new AbortController()
    this.abortController = controller
    const { signal } = controller
    const url = `${this.config.baseUrl ?? ''}${config.endpoint}`

    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          ...this.config.headers,
        },
        body: JSON.stringify({
          prefix: context.prefix,
          suffix: context.suffix,
          language: context.language,
          filePath: context.filePath,
          cursorLine: context.cursorLine,
          cursorColumn: context.cursorColumn,
          model: config.model,
          maxSuggestions: config.maxSuggestions,
          projectId: config.projectId,
        }),
        signal,
      })

      if (!response.ok) {
        const text = await response
          .text()
          .catch(() =>
            t('copilot.error.unknownError', undefined, { defaultValue: 'Unknown error' }),
          )
        let errorMessage: string | undefined
        try {
          const body = JSON.parse(text) as Record<string, unknown>
          if (typeof body.error === 'string') errorMessage = body.error
        } catch (_error) {
          // Not JSON — use raw text
        }
        onEvent({
          type: 'error',
          message:
            errorMessage ??
            t(
              'copilot.error.httpError',
              { status: response.status, text },
              { defaultValue: 'HTTP {{status}}: {{text}}' },
            ),
        })
        return
      }

      if (!response.body) {
        onEvent({
          type: 'error',
          message: t('copilot.error.noResponseBody', undefined, {
            defaultValue: 'No response body',
          }),
        })
        return
      }

      const reader = response.body.getReader()
      const decoder = new TextDecoder()
      let buffer = ''

      while (true) {
        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 data = line.slice(6).trim()
          if (!data) continue

          try {
            const event = JSON.parse(data) as CopilotEvent
            onEvent(event)
          } catch (_error) {
            // Skip malformed SSE data lines
          }
        }

        // Yield to main thread between chunks
        await new Promise((r) => setTimeout(r, 0))
      }

      // Flush any remaining buffered data
      if (buffer.startsWith('data: ')) {
        const data = buffer.slice(6).trim()
        if (data) {
          try {
            const event = JSON.parse(data) as CopilotEvent
            onEvent(event)
          } catch (_error) {
            // Skip malformed data
          }
        }
      }
    } catch (err) {
      if (err instanceof Error && err.name === 'AbortError') {
        return
      }
      const message =
        err instanceof Error
          ? err.message
          : t('copilot.error.streamError', undefined, { defaultValue: 'Stream error' })
      onEvent({ type: 'error', message })
    } finally {
      if (this.abortController === controller) {
        this.abortController = null
      }
    }
  }

  /**
   * Reports a suggestion acceptance to the backend for analytics/learning.
   *
   * @param suggestion - The suggestion the user accepted.
   * @param config - Request-level configuration (endpoint).
   */
  async acceptSuggestion(suggestion: CopilotSuggestion, config: AICopilotConfig): Promise<void> {
    const url = `${this.config.baseUrl ?? ''}${config.endpoint}/feedback`
    try {
      await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          ...this.config.headers,
        },
        body: JSON.stringify({
          suggestionId: suggestion.id,
          action: 'accept',
          text: suggestion.text,
          metadata: suggestion.metadata,
        }),
      })
    } catch (_error) {
      // Feedback is best-effort — do not propagate errors
    }
  }

  /**
   * Reports a suggestion rejection to the backend for analytics/learning.
   *
   * @param suggestion - The suggestion the user dismissed.
   * @param config - Request-level configuration (endpoint).
   */
  async rejectSuggestion(suggestion: CopilotSuggestion, config: AICopilotConfig): Promise<void> {
    const url = `${this.config.baseUrl ?? ''}${config.endpoint}/feedback`
    try {
      await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          ...this.config.headers,
        },
        body: JSON.stringify({
          suggestionId: suggestion.id,
          action: 'reject',
          metadata: suggestion.metadata,
        }),
      })
    } catch (_error) {
      // Feedback is best-effort — do not propagate errors
    }
  }

  /**
   * Cancels any in-flight suggestion request.
   */
  abort(): void {
    this.abortController?.abort()
    this.abortController = null
  }
}

Functions

createProvider(config)

Creates a DefaultCopilotProvider instance.

function createProvider(config?: DefaultCopilotConfig): DefaultCopilotProvider
  • config — Optional provider-level configuration (base URL, headers).

Returns: A new DefaultCopilotProvider.

Constants

provider

The provider implementation — the fleet-standard typed provider const.

Wire it once at startup: setProvider(provider) from @molecule/app-ai-copilot. It is a lazy proxy: construction is deferred to the first property access, so importing this module never throws and needs no config up front. Use createProvider(config) instead when you need to pass a base URL or headers.

const provider: AICopilotProvider

Core Interface

Implements @molecule/app-ai-copilot interface.

Bond Wiring

Setup function to register this provider with the core interface:

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

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

Injection Notes

Requirements

Peer dependencies:

  • @molecule/app-ai-copilot ^1.0.1
  • @molecule/app-i18n ^1.0.1

Runtime Dependencies

  • @molecule/app-ai-copilot
  • @molecule/app-i18n

Server contract: getSuggestions POSTs { prefix, suffix, language, filePath?, cursorLine?, cursorColumn?, model?, maxSuggestions?, projectId? } to config.endpoint and reads an SSE stream of data: <CopilotEvent JSON> lines. acceptSuggestion / rejectSuggestion POST { suggestionId, action: 'accept' | 'reject', text?, metadata } to ${config.endpoint}/feedback — best-effort, errors are swallowed, so implement the route (or expect silent no-ops). getSuggestions auto-aborts the previous in-flight request; still call abort() on keystrokes you debounce away (see @molecule/app-ai-copilot).

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:

  • Typing in the editor fires getSuggestions(context, config, onEvent) and the returned CopilotSuggestion.text renders as ghost/inline text anchored to the suggestion's range (CopilotRange) — or the caret when range is omitted — never at a stale or wrong offset.
  • Accepting the suggestion (e.g. Tab) inserts EXACTLY suggestion.text at that range/caret and nothing stale, and fires acceptSuggestion(suggestion, config); the buffer holds only the accepted text with no leftover ghost preview.
  • Continuing to type or explicitly dismissing removes the ghost cleanly and calls abort() so the in-flight request is cancelled before the next getSuggestions — a late-arriving stale suggestion never lands at the moved cursor, and rejectSuggestion(suggestion, config) reports the miss.
  • The suggestion is context-aware, not generic: it reflects the real CopilotContext (prefix/suffix/language around the cursor), so editing the surrounding code visibly changes what gets proposed.
  • With copilot disabled (no provider bonded, or the app's config/toggle off) no ghost text ever appears and typing stays completely unaffected.
  • A provider error (an onEvent { type: 'error' }) fails quietly — no ghost text, no thrown exception in the editor, the buffer is untouched, and the user can keep typing.
  • Correctness/security: accepted text is inserted ONLY at the intended CopilotRange (it never overwrites unrelated lines), and suggestion.text is treated as plain model output — inserted as text, never eval'd or run as trusted code by the copilot itself.