@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-defaultnpm · 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 URLReference
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.tsJSDoc, 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 ofdata: <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 bareproviderexport iscreateProvider()with no options — to setbaseUrl/headers, wiresetProvider(createProvider({ ... }))instead.sendMessageaborts 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().isOpenis true) and sending a message throughsendMessagerenders the user turn immediately, then the assistant reply below it — both present ingetState().messages. - The reply STREAMS:
textevents append the assistant message progressively (token by token) while itsisStreamingstays true, andisStreamingclears when thedoneevent 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 markedaborted, not left spinning. - A thinking/loading indicator driven by
getState().isLoadingshows while a response is in flight and clears once it settles — ondoneAND onabort. - A provider failure surfaces
getState().error(from theerrorstream event) as a visible message in the panel — never a blank or perpetually spinning panel. - Suggestions from
getState().suggestionsrender as chips, and clicking one sends THAT chip's ownactionstring — 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;clearContextdrops it, and one user's context never bleeds into another user's session. - Conversation history persists across turns and reloads —
loadHistoryrehydratesgetState().messagesandclearHistoryempties the panel — and model output renders through the app's sanitizing markdown renderer, never as raw or executable HTML.