diff --git a/astro.config.mjs b/astro.config.mjs index c741223..06e8abb 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -55,6 +55,17 @@ export default defineConfig({ { label: 'Component catalog', slug: 'components', badge: 'v0.4.1' }, ], }, + { + label: 'Integration', + items: [ + { label: 'Overview', slug: 'integration' }, + { label: 'Agent provider', slug: 'integration/agent-provider' }, + { label: 'Agent controller', slug: 'integration/agent-controller' }, + { label: 'File uploads', slug: 'integration/uploads' }, + { label: 'Keeping keys safe', slug: 'integration/keeping-keys-safe' }, + { label: 'HTTP adapter', slug: 'integration/http-adapter' }, + ], + }, { label: 'Guides', items: [ diff --git a/src/content/docs/guides/events-and-state.mdx b/src/content/docs/guides/events-and-state.mdx index d0d1d60..0afb0ad 100644 --- a/src/content/docs/guides/events-and-state.mdx +++ b/src/content/docs/guides/events-and-state.mdx @@ -7,6 +7,8 @@ import { Aside, Card, CardGrid } from '@astrojs/starlight/components'; Loquix components communicate with standard attributes, DOM properties, methods, and custom events. Your application remains responsible for conversations, requests, persistence, and provider credentials. +This page covers that DOM contract — the attributes, properties, and events every component exposes — independent of how you drive them. The [Integration](/docs/integration/) section covers `AgentController`, the piece that now exists to drive the chat seam of that contract for you, so you do not have to wire `loquix-submit` to a raw `fetch` call by hand the way the example below still does. + ## The data flow diff --git a/src/content/docs/integration/agent-controller.mdx b/src/content/docs/integration/agent-controller.mdx new file mode 100644 index 0000000..58a31ab --- /dev/null +++ b/src/content/docs/integration/agent-controller.mdx @@ -0,0 +1,220 @@ +--- +title: Agent controller +description: The Lit reactive controller that owns conversation state and drives an agent provider. +--- + +import { Aside } from '@astrojs/starlight/components'; + +The **agent controller** is the piece between your [agent provider](/docs/integration/agent-provider/) and Loquix components. It holds the conversation history, tracks the send lifecycle as a small state machine, and calls your provider — components never call a provider directly. See the [integration overview](/docs/integration/) for how the three pieces relate. + +You do not write this piece. You construct it with a provider and, optionally, some configuration, and then call its methods from your host component. + +```ts +import { AgentController } from '@loquix/core/controllers/agent.controller'; + +const agent = new AgentController(this, provider, { + model: 'claude-sonnet-4-20250514', +}); +``` + +## States + +The controller's `state` is one of: + +| State | Meaning | +| --- | --- | +| `idle` | No send in progress. The starting state, and where `abort()` and `reset()` return to. | +| `sending` | `send()` has been called and is awaiting the provider's `send()` promise. | +| `streaming` | The response stream is being read and its chunks are accumulating. | +| `paused` | Streaming was suspended with `pause()`; chunks continue to arrive but are buffered rather than applied. | +| `complete` | The response finished and was appended to `messages` as an assistant message. | +| `error` | The provider's `send()` rejected after being called — including a `sendTimeout` that fires while it is still pending — or the stream errored outside of an abort, including a `streamIdleTimeout` that trips. What `currentResponseText` holds at that point depends on which — see "Partial responses" below. This is narrower than every way `send()` can reject: the `maxMessageLength` and `maxMessages` checks and the concurrent-send guard, all described under `send()` below, reject before the provider is ever called and leave `state` untouched. | + +## Methods + +| Method | Signature | Effect | +| --- | --- | --- | +| `send` | `send(content: string, attachments?: AgentMessageAttachment[]): Promise` | Appends a user message, calls the provider, and streams the response. | +| `pause` | `pause(): void` | Suspends stream consumption. Buffered chunks are not lost — `resume()` flushes them. | +| `resume` | `resume(): void` | Resumes a paused stream. | +| `abort` | `abort(): void` | Cancels the in-flight send or stream and returns to `idle`. No-op while `idle` or `complete`. | +| `reset` | `reset(): void` | Aborts, clears `messages`, and returns to `idle`. | +| `setMessages` | `setMessages(messages: AgentMessage[]): void` | Replaces the conversation history, for restoring a persisted conversation. Throws if called while `sending`, `streaming`, or `paused`. | +| `setProvider` | `setProvider(provider: AgentProvider): void` | Swaps the provider. If the controller's `state` is not `idle` or `complete` — that includes `error`, not only `sending`/`streaming`/`paused` — it logs a console warning and calls `abort()` first. [`UploadController.setProvider()`](/docs/integration/uploads/) warns the same way, but on a narrower condition: only while `uploading`, not while `error`. | +| `updateOptions` | `updateOptions(partial: Partial): void` | Merges new values into the controller's options, such as `model` or `systemPrompt`, without touching the provider or the conversation. | + + + +## Read-only properties + +| Property | Type | Description | +| --- | --- | --- | +| `state` | `AgentState` | The current state, one of the six values above. | +| `messages` | `readonly AgentMessage[]` | The full conversation history, including the in-progress user message once `send()` has been called. | +| `currentResponseText` | `string` | Text accumulated so far for the response currently streaming, or the last one that streamed. It is cleared when the provider hands back a stream, not when `send()` is called, so between those two moments it still holds the previous response — see "Partial responses" below. | +| `isStreaming` | `boolean` | `true` while `state` is `streaming` or `paused`. | + +## Options + +The third constructor argument configures the controller. Everything is optional. + +| Option | Type | Default | +| --- | --- | --- | +| `model` | `string` | none | +| `params` | `Record` | none | +| `systemPrompt` | `string` | none | +| `sendTimeout` | `number` (ms) | `60_000` | +| `streamIdleTimeout` | `number` (ms) | `60_000` | +| `maxMessageLength` | `number` | `100_000` | +| `maxMessages` | `number` | `200` | + +`model`, `params`, and `systemPrompt` are passed straight through to the provider's `send()` as part of `AgentSendOptions` on every call; see the [agent provider page](/docs/integration/agent-provider/) for how a provider is expected to use them. + +`maxMessageLength` rejects an individual message whose content is too long; `maxMessages` rejects a `send()` that would push the conversation over the cap, counting both the new user message and the assistant response it will produce. Both reject the promise `send()` returns, before any request is made. `send()` is `async`, so the error never arrives as a synchronous exception — `await` the call, or attach a `.catch()`, to see it. + +These two guards, and the concurrent-send guard above, are the only things that reject `send()`. Everything that goes wrong *after* the provider is called — a provider that throws, a `sendTimeout`, a stream that errors — is caught by the controller instead: `send()` resolves normally, `state` becomes `error`, and `onError` is where you hear about it. + +### `sendTimeout` + +`sendTimeout` bounds only the wait for `provider.send()` to resolve with a stream — not the response that follows. On every `send()`, the controller composes its own abort signal with a dedicated timeout signal and passes the combined signal to `provider.send()` as `options.signal`, the same as before. The difference is what happens once `provider.send()` resolves: the controller clears that timer itself, right there, before doing anything else. From that point on the composed signal can still fire from a deliberate abort — `abort()`, `reset()`, `setProvider()`, `hostDisconnected()` — but `sendTimeout` itself has no further effect, even for a provider that forwards the signal straight to `fetch` for the whole request, as the [example provider](/docs/integration/agent-provider/) does. + +Measured: with `sendTimeout: 500`, a stream that starts inside that window and then keeps emitting chunks for 1.2 seconds completes in full — the assistant message is appended and no error fires. A `provider.send()` that never resolves at all is a different story: that wait is still bounded at the configured value, exactly as before, since there is no stream yet to leave alone. + +The controller's own doc comment describes `sendTimeout` as bounding the time "until the `ReadableStream` is returned, NOT until first chunk." That is now accurate for every provider, including one that forwards the signal to `fetch` for the entire exchange — the timer backing `sendTimeout` is already gone by the time the stream exists, so there is nothing left for that forwarded signal to cut off mid-answer. + +Use `streamIdleTimeout`, below, to bound a stream that has already started. + +### `streamIdleTimeout` + +Aborts the response stream when no chunk has arrived within this many milliseconds, resetting on every chunk — a long but healthy answer is never punished, only silence is. Default `60_000`, same as `sendTimeout`. + +Measured: a stream that emits one chunk and then goes silent trips a `streamIdleTimeout: 300` after 300ms, moving the controller to `error` and firing `onError`. The same 300ms window does not trip against a stream with steady 150ms gaps between chunks, which runs to completion normally. + +Pausing suspends idle detection entirely rather than letting it keep counting down — time spent `paused` is never held against the stream. Measured: pausing for 600ms under a 300ms `streamIdleTimeout` does not trip it, and resuming gives the stream a fresh idle window rather than one already half-spent. + +When `streamIdleTimeout` trips, `currentResponseText` still holds whatever text had accumulated — see "Partial responses" below. + +### Sanitizing + +`sendTimeout` and `streamIdleTimeout` are each sanitized the same way, and the rule maps each kind of odd value onto what it most likely means: + +| Value | Result | Why | +| --- | --- | --- | +| `undefined` | `60_000` | The option was not set. | +| `0` or negative | disabled | An explicit request for no timeout. `sendTimeout: -1` no longer throws; it behaves exactly like `0`. | +| `Infinity`, `-Infinity` | disabled | The natural way to write "no limit". | +| `NaN`, or anything that is not a number | `60_000` | Almost always arithmetic on a missing config value, so the guard is kept rather than silently removed. | + +This differs on purpose from the upload options on [File uploads](/docs/integration/uploads/), where `Infinity` falls back to the default: an unbounded retry count or concurrency is meaningless, while an unbounded timeout is a thing people genuinely want. + +This is a different rule from the [upload controller](/docs/integration/uploads/)'s four sanitized options, which floor a too-low value up to a minimum rather than disabling it — `uploadTimeout: 500` becomes `1000`, never "off". Here, `0` or negative always means off, for both options. + +### Partial responses + +A response that gets interrupted is not handled the same way by every path that can cause it — `messages` never gets an entry for a response that did not finish, in any of these cases, but `currentResponseText` behaves differently in each: + +- **`streamIdleTimeout` tripping, or another stream failure that lands *after* the response has started streaming,** moves the controller to `error` and fires `onError`. `currentResponseText` keeps whatever text had accumulated up to that point. Persisting it, if you want to, is the host's job — do it from the `onError` callback. `sendTimeout` cannot land here anymore — see above. +- **`abort()` and `hostDisconnected()`** stop the stream and return to `idle` instead — `onError` does not fire for either. `currentResponseText` still keeps the accumulated text, so a host that cares about this case has to check for it right after calling `abort()` itself (or after tearing down the host), not from a callback. +- **`reset()`** goes further: it clears `currentResponseText` along with everything else, so there is nothing left to read once it returns. +- **A call to the provider's `send()` that rejects before any response starts streaming** also lands in `error`, but `currentResponseText` is untouched by it. This covers two different failures the same way: an immediate network error the provider throws right away, and a `sendTimeout` that fires while the provider's own promise is still pending — before it ever hands back a stream to interrupt. Either way, `currentResponseText` still holds whatever response completed *last*, not anything from the new failed attempt. Reading it as "the answer that just failed" only makes sense for the mid-stream case above; here, persisting it would just re-save the previous response. (This is distinct from the guard-clause rejections on `send()` covered above, which reject before the provider is ever called and never touch `state` at all.) + +## The host argument + +The constructor's first argument is a Lit [`ReactiveControllerHost`](https://lit.dev/docs/composition/controllers/). Inside a `LitElement`, that host is the element itself: + +```ts +class MyChat extends LitElement { + private agent = new AgentController(this, provider); +} +``` + +As a `ReactiveController`, `AgentController` implements `hostConnected()` and `hostDisconnected()` — the same shape as [`UploadController`](/docs/integration/uploads/). `hostConnected()` is a no-op. `hostDisconnected()` calls `abort()`. That means removing the host element from the DOM aborts an in-flight send or stream, not just an explicit call to `abort()`. The same internal abort also fires from `reset()` (which calls `abort()` itself) and from `setProvider()` when the controller is not `idle` or `complete`. All four routes end up calling the same `AbortController`, so a provider that forwards `options.signal` to `fetch` is defended against every one of them, not only a deliberate stop button. + + + +## Wiring it to chat components + +Nothing connects `AgentController` to Loquix components automatically — a host renders the controller's state into them and turns their events back into controller calls, the same division of labor [`UploadController` has with ``](/docs/integration/uploads/#wiring-it-to-attachment-panel): + +```ts +import { LitElement, html } from 'lit'; +import { AgentController } from '@loquix/core/controllers/agent.controller'; +import '@loquix/core/define/define-message-list'; +import '@loquix/core/define/define-message-item'; +import '@loquix/core/define/define-message-content'; +import '@loquix/core/define/define-chat-composer'; +import '@loquix/core/define/define-generation-controls'; + +class MyChat extends LitElement { + // MyBackendProvider is the example from the agent provider page. + agent = new AgentController(this, new MyBackendProvider()); + + private get busy() { + return this.agent.state === 'sending' || this.agent.isStreaming; + } + + render() { + const { state, messages, currentResponseText, isStreaming } = this.agent; + + return html` + + ${messages.map( + (m) => html` + + ${m.content} + + `, + )} + ${isStreaming + ? html` + + ${currentResponseText} + + ` + : null} + + + this.agent.abort()} + @loquix-pause=${() => this.agent.pause()} + @loquix-resume=${() => this.agent.resume()} + > + + ) => this.agent.send(e.detail.content)} + @loquix-stop=${() => this.agent.abort()} + > + `; + } +} +``` + +A few things worth pointing out, since none of them are obvious from either component's own reference page: + +- ``'s `loquix-submit` event is typed with an optional `attachments?: File[]` on `event.detail`, but nothing in `@loquix/core` ever sets it — in practice the event carries only `content`. That is just as well: `File[]` is not the `AgentMessageAttachment[]` shape `agent.send()` takes as its second argument anyway, so wiring uploads through still means building that array yourself from completed [upload results](/docs/integration/uploads/), not from this event. `event.detail.content` is exactly the string `agent.send()` expects as its first argument. +- `messages` only ever holds a user message or a *finished* assistant message — never a partial one (see "Partial responses" above) — so `status="complete"` is always correct for everything this loop renders. The one in-progress response is rendered separately, from `currentResponseText`, with `status="streaming"`. +- `` already renders its own stop button in place of the send button whenever its `streaming` property is `true`, and dispatches `loquix-stop` when it is clicked — that is what the composer's own `@loquix-stop` handler above is for. `` is a separate, optional component for exposing `pause()`/`resume()`, since nothing in the composer drives those. + +## Where this fits + +The agent controller is the only consumer of the [agent provider](/docs/integration/agent-provider/) interface. See the [integration overview](/docs/integration/) for how the provider, the controller, and Loquix components divide the work. diff --git a/src/content/docs/integration/agent-provider.mdx b/src/content/docs/integration/agent-provider.mdx new file mode 100644 index 0000000..ee251a1 --- /dev/null +++ b/src/content/docs/integration/agent-provider.mdx @@ -0,0 +1,164 @@ +--- +title: Agent provider +description: The interface you implement to connect a Loquix chat to your backend. +--- + +An **agent provider** is the object you write to connect Loquix to a real backend. It is the only integration point: the agent controller calls it, and it calls your API. See the [integration overview](/docs/integration/) for how the two relate. + +A provider does two things: + +- Converts the conversation history and send options into whatever shape your backend expects. +- Converts your backend's response into a `ReadableStream` of plain text chunks. + +Nothing else about your backend — auth, request format, streaming protocol — is visible outside the provider. The controller only ever sees `AgentMessage[]` in and a stream of strings out. + +## The `AgentProvider` interface + +```ts +interface AgentProvider { + readonly name: string; + + send(messages: AgentMessage[], options: AgentSendOptions): Promise; + + listModels?(): Promise>; +} +``` + +- **`name`** — a human-readable label for the provider (for example `"Claude API"` or `"my-ai"`). It is not used for routing; it exists for logging and for display. +- **`send(messages, options)`** — the one required method. It receives the full conversation history and returns a promise for an `AgentResponse`. The controller awaits this promise once per send; everything after that point is the stream inside the response. +- **`listModels()`** — optional, and a convention rather than a hook: nothing in `@loquix/core` calls it. Neither the controller nor any component invokes `listModels()` — if you implement it, your own application code is what calls it and feeds the result to ``. The shapes do not match either, so that call has to map one to the other: + + ```ts + const models = await provider.listModels(); // { id, name, description }[] + + modelSelector.models = models.map(m => ({ + value: m.id, + label: m.name, + description: m.description, + })); // ModelOption[] + ``` + + If you do not implement `listModels()`, drive the selector from static configuration instead. + +`send` should throw on failure — network error, non-2xx response, auth failure, rate limit — rather than returning a response with an error inside it. The controller catches the rejection and moves to its `error` state. + +## `AgentMessage` + +```ts +interface AgentMessage { + id: string; + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; + attachments?: AgentMessageAttachment[]; + metadata?: Record; +} +``` + +This is the shape the controller keeps for conversation history and the shape `send` receives, one per message. It is deliberately minimal — a provider maps it to its backend's own message format internally rather than the reverse. + +`attachments`, when present, is an array of: + +```ts +interface AgentMessageAttachment { + url: string; + mimeType: string; + filename?: string; +} +``` + +These are references to already-uploaded files, not file contents — Loquix does not send binary data through `AgentMessage`. + +`metadata` is an open bag for anything provider-specific, such as tool call results or token counts. Loquix does not read it. + +## `AgentSendOptions` + +```ts +interface AgentSendOptions { + signal?: AbortSignal; + model?: string; + params?: Record; + systemPrompt?: string; +} +``` + +- **`signal`** — the controller always passes one. It combines its own internal abort with its send timeout into a single signal, but the send-timeout side only bounds the wait for your `send()` to resolve: the instant it does, the controller clears that timer, and the signal cannot fire from `sendTimeout` again. Forward it to `fetch`, as the example below does, and a deliberate stop — `abort()`, `reset()`, `setProvider()` swapping providers, or `hostDisconnected()` when the host element is removed from the DOM — still cancels the request and its body at any point, mid-stream included. A slow-starting request times out; a request that is already streaming does not, no matter how long it runs. To bound silence within a stream that has started, the controller has a separate `streamIdleTimeout` option — see the [agent controller page](/docs/integration/agent-controller/) — enforced by the controller's own stream consumption, not through this signal at all. +- **`model`** — the model identifier configured on the controller, if any. +- **`params`** — inference parameters such as `temperature`, `top_p`, or `max_tokens`, passed through as-is. +- **`systemPrompt`** — a system prompt override, if the controller was configured with one. + +A provider is free to ignore any option it does not support. + +## `AgentResponse` + +```ts +interface AgentResponse { + id: string; + stream: ReadableStream; + metadata?: Record; +} +``` + +- **`id`** — a unique identifier for this response message. +- **`stream`** — a standard `ReadableStream`. The controller reads it chunk by chunk and accumulates the result; it does not know or care what streaming protocol your backend used to produce those chunks. +- **`metadata`** — provider-specific information about the response, such as the model that actually served it or a finish reason. + +The stream carries plain text chunks, not tokens and not raw SSE frames. If your backend streams newline-delimited JSON, server-sent events, or a token-by-token protocol, decoding that into text is the provider's job, done before each chunk reaches the stream. + +## A complete example + +This provider calls a `/api/chat` endpoint that streams newline-delimited JSON events (`{ "text": "..." }`) and turns them into a plain-text stream: + +```ts +import type { + AgentProvider, + AgentMessage, + AgentSendOptions, + AgentResponse, +} from '@loquix/core/providers/agent-provider'; + +class MyBackendProvider implements AgentProvider { + readonly name = 'my-backend'; + + async send(messages: AgentMessage[], options: AgentSendOptions): Promise { + const response = await fetch('/api/chat', { + method: 'POST', + signal: options.signal, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + messages: messages.map(m => ({ role: m.role, content: m.content })), + model: options.model, + system: options.systemPrompt, + }), + }); + + if (!response.ok) throw new Error(`Chat request failed: ${response.status}`); + + const body = response.body!; + const stream = new ReadableStream({ + async start(controller) { + const reader = body.pipeThrough(new TextDecoderStream()).getReader(); + let buffer = ''; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + for (const line of lines) { + if (line.trim()) controller.enqueue(JSON.parse(line).text); + } + } + controller.close(); + }, + }); + + return { id: crypto.randomUUID(), stream }; + } +} +``` + +Hand an instance of this class to the agent controller in place of a stub, and messages sent through Loquix components reach `/api/chat` and stream back into the conversation. + +## Where this fits + +The agent controller is the only consumer of this interface — nothing in the component catalog calls a provider directly. See the [integration overview](/docs/integration/) for how the provider, the controller, and Loquix components divide the work. diff --git a/src/content/docs/integration/http-adapter.mdx b/src/content/docs/integration/http-adapter.mdx new file mode 100644 index 0000000..28a80a9 --- /dev/null +++ b/src/content/docs/integration/http-adapter.mdx @@ -0,0 +1,204 @@ +--- +title: HTTP adapter +description: Turn an HTTP endpoint into an AgentProvider, with streaming, cancellation, and error handling already wired up. +--- + +import { Aside, Tabs, TabItem } from '@astrojs/starlight/components'; + + + +`@loquix/adapter-http` builds an [`AgentProvider`](/docs/integration/agent-provider/) for you out of a URL. Point it at your own backend and it POSTs the conversation, reads the response as it streams in, and turns it into the plain-text `ReadableStream` the interface expects — including the abort handling, the non-2xx rejections, and the frame-by-frame decoding a hand-written provider would otherwise have to implement itself. What it replaces is the hand-written `send()` implementation from the agent provider page, not the `AgentProvider` interface itself — everything the agent controller expects from a provider still applies. + +The package has zero runtime dependencies and holds no credentials. The URL it POSTs to is your own backend, so provider API keys stay on your server; see [keeping keys safe](/docs/integration/keeping-keys-safe/) for why that split exists and what it costs you to keep. + +## Install + + + + + ```sh + npm install @loquix/adapter-http + ``` + + + + + ```sh + pnpm add @loquix/adapter-http + ``` + + + + + ```sh + yarn add @loquix/adapter-http + ``` + + + + +`@loquix/core` (`>=0.5.0`) is a peer dependency, needed only for its types. + +## A minimal example + +```ts +import { createHttpAgentProvider } from '@loquix/adapter-http'; + +const provider = createHttpAgentProvider({ + url: '/api/chat', +}); +``` + +Hand `provider` to `AgentController` the same way you would a hand-written one. By default the adapter POSTs `{ messages, model, params, systemPrompt }` to `/api/chat` and reads the response as server-sent events, extracting each frame's `data:` payload as plain text. + +If your backend streams newline-delimited JSON objects instead, set the transport and nothing else changes: + +```ts +const provider = createHttpAgentProvider({ + url: '/api/chat', + transport: 'ndjson', +}); +``` + +## Options + +The table lists every field of `HttpAgentProviderOptions`, in the order the interface declares them. + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `url` | `string \| ((messages, options) => string)` | *(required)* | The endpoint to POST to. A function receives the outgoing messages and send options, so the URL can vary per request — for example to embed a conversation ID. | +| `name` | `string` | `'HTTP'` | The provider's `name`, used for logging and display, not routing. | +| `transport` | `'sse' \| 'ndjson' \| 'text'` | `'sse'` | Which wire format to decode the response as. See "Transports" below. | +| `headers` | `Record \| (() => Record \| Promise>)` | *(none)* | Extra request headers, merged over the adapter's own `content-type: application/json`. A function may be async, so a fresh auth token can be fetched per request. | +| `credentials` | `RequestCredentials` | `'same-origin'` | Passed straight through to `fetch`. | +| `body` | `(messages, options) => unknown` | posts `{ messages, model, params, systemPrompt }` | Overrides the request payload. A `string` return is sent as the request body as-is — use this if you are already serializing yourself, so it does not get `JSON.stringify`'d a second time. Anything else is `JSON.stringify`'d as before. | +| `parse` | `(chunk: string, frame?: SseFrameMeta) => string \| null` | transport-dependent, see "Transports" | Overrides how a decoded chunk becomes text. See "The `parse` hook" below. Cannot be combined with `transport: 'text'` — throws at construction. | +| `fetch` | `typeof globalThis.fetch` | `globalThis.fetch` | Swap in a wrapped or polyfilled `fetch`, or a stub for tests. | + + + +## Transports + +The `transport` option picks how the response body is decoded into text chunks. All three read the response as a stream of bytes; they differ in how they split it into frames and what, if anything, they parse out of each one. + +### `sse` (default) + +Expects [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events): frames separated by a blank line (`\n\n`, or `\r\n\r\n`), each carrying one or more `data:` lines. Several `data:` lines in one frame are joined with `\n`; `event:`, `id:`, and comment (`:`-prefixed) lines are read and discarded. The default parser returns each frame's joined `data:` payload verbatim — no JSON parsing. + +### `ndjson` + +Expects one JSON object per line, separated by `\n`. The default parser picks the first of `text`, `content`, or `delta` (in that order) that is *neither `null` nor `undefined`* on the parsed object — `parsed.text ?? parsed.content ?? parsed.delta` — and emits it only if that one field is a string. Because `??` only skips `null`/`undefined`, not any other falsy value, a field can be present-but-`null` and still get skipped: `{"text": null, "content": "hi"}` emits `"hi"`, since `null` is passed over in favor of `content`. Once a field is selected this way, though, there is no further fallback if it turns out not to be a string. `{"text": 42, "content": "hi"}` emits nothing, because `text` was chosen (`42` is neither `null` nor `undefined`) and failed the string check — not `"hi"`. A line that parses as JSON but has none of the three fields set to anything other than `null`/`undefined` is skipped the same way: not an error, just no text to emit for that line. + + + +### `text` + +Expects nothing in particular. Every byte that arrives is decoded and passed straight through as a chunk of the output stream, with no framing and no parsing. Choose this when your backend already streams plain text and any structure — including a literal `[DONE]` — should reach the caller as ordinary bytes rather than being interpreted by the adapter. `parse` cannot be combined with `text` — see "The `parse` hook" below. + + + +## Errors + +Three unrelated situations produce an `HttpAgentError`, an `Error` subclass carrying a `status: number`, a `code` naming which one it was, and, for one of those codes, the parsed error body. Two of them reject `send()`; the third, `transport_mismatch`, lets `send()` resolve and surfaces on the first read of the stream, so a `try`/`catch` around `send()` alone will not see it: + +```ts +type HttpAgentErrorCode = 'http' | 'no_body' | 'transport_mismatch'; + +class HttpAgentError extends Error { + readonly status: number; + readonly code: HttpAgentErrorCode; + readonly body?: unknown; +} +``` + +`code` exists so you can tell the three kinds apart without string-matching `message`, which was never meant to be parsed. It is one of: + +- **`'http'`** — a non-2xx response. `status` is the response's actual HTTP status. `body` is the parsed JSON error body, when there was one. +- **`'no_body'`** — a 2xx response with no body to stream. `body` is not set. +- **`'transport_mismatch'`** — the body never produced a single frame recognizable as the configured `transport`. See "A transport mismatch" below. `body` is not set. + +### The `'http'` message and `body` + +The adapter only attempts to read the response as JSON when the `content-type` header says so — a non-JSON error body (an HTML error page, or a `text/event-stream` response that opens and never closes) is left alone rather than awaited with `response.json()`, which would otherwise hang the rejection until the stream ends, or forever. When the content-type does say JSON, the parsed value becomes `body`, and its message is extracted from whichever shape matches: + +- A top-level `message` field. +- `error.message`, the nested shape OpenAI and Anthropic both use. +- A bare `error` field, string or otherwise. +- `detail`, including FastAPI's validation-error array (`[{ loc, msg, type }, ...]`), whose `msg` fields are joined with `; `. + +If none of those match, or the body was not JSON, the message falls back to the response's status text. Either way, `status` is the response's actual HTTP status. + +**A 2xx response with no body** (`code: 'no_body'`) is a separate, narrower case. The guard is `if (!response.body)`. A real `200 OK` with an *empty* body still has a non-null, readable `body` — `send()` resolves normally and the stream simply yields nothing. What actually triggers `no_body` is a response with no body object at all: a `204 No Content` (the fetch spec gives these a null `body`), or a stub `Response` built with `body: null` in a test. `status` is still the response's real status — a `204` produces `status: 204`, not a fabricated one. + +Both of the above happen after `fetch` itself has already resolved. Anything that keeps `fetch` from resolving at all rejects `send()` unwrapped, with whatever `fetch` (or your own `fetch` replacement) rejects with — the adapter does not catch or wrap it, so there is no `HttpAgentError` and no `code` to check: + +- A network failure — DNS, connection refused, offline — rejects with a `TypeError`, `fetch`'s own error for that case. +- A `signal` that aborts before the response arrives rejects with a `DOMException` named `AbortError`. See "Cancellation" below for the case where the abort instead happens mid-stream, after `fetch` has already resolved. + +A malformed *line inside* a body that did arrive is not one of these cases — see the caution above. Besides the cases above, `send()` **itself** also rejects, unwrapped, if a `url`, `headers`, or `body` function you supplied throws — `performRequest` calls all three before `fetch` ever runs, so that exception is entirely your own code's, not the adapter's. A body that did arrive can still produce an error later, on the stream rather than from `send()` — see below. + +### A transport mismatch + +The default parser can legitimately produce no text for an entire response. A stream of heartbeat comments, tool-call metadata objects, or `{"text": null}` lines are all correctly shaped for their transport — they just have nothing to say this turn — and none of that should look like an error. + +What the adapter actually checks is narrower than "produced no chunk": whether the response contained at least one frame that was not merely blank, and whether the configured transport ever recognized one of those frames as its own shape. For `sse`, recognized means a line starting with `data:`, `event:`, `id:`, `retry:`, or `:` (a comment). For `ndjson`, it means a line that parses as JSON at all, whatever it contains. Only when at least one such frame arrived and *none* of them were recognized does the adapter treat it as a likely transport mismatch. Left alone, that would otherwise close as an ordinary empty stream: a blank assistant message with no clue why. Instead, the first `reader.read()` on `response.stream` rejects with an `HttpAgentError` (`code: 'transport_mismatch'`) carrying the response's real `status`, naming both the transport that was configured and the response's `content-type`. + +This catches a bare JSON error object under `sse` — a `200` whose body is `{"error":"..."}` with no `data:` line at all is unrecognized, since nothing in it matches the SSE grammar. It does **not** catch the same shape under `ndjson`: `{"error":"..."}` is well-formed JSON, so `ndjson`'s recognizer accepts it as its own shape, and the adapter closes the stream blank instead of erroring. That is a deliberate, narrower check, not an oversight — an earlier version special-cased a top-level `error` field to catch this, and it took a review round to remove, because it false-positived on backends whose own protocol legitimately has a per-item `error` field. If your `ndjson` backend can return a bare error object as its whole response body, check for an empty response yourself rather than relying on this to surface it. + +A redirect can produce the same symptom as a genuine mismatch — a `POST` redirected to a GET login page, say, serving HTML as a `200` — without the `transport` option being wrong at all. When `fetch` followed a redirect to get there, the mismatch message names the URL it landed on and points at the redirect as the more likely cause, instead of sending you to change a transport setting that was already correct. + +Supplying your own `parse` disables this check entirely, regardless of what it returns — a genuine mismatch behind a `parse` hook still closes cleanly with no diagnostic, the one case where you are back to the old silent blank message, so it is worth testing a custom `parse` against a real response from your backend. A `[DONE]` sentinel also rules the check out on its own, even with no preceding content, since reaching it at all proves the transport matched what the server sent. A body that arrives with zero bytes total never reaches this check either, since there is nothing to judge in the first place. (A *null* body is a separate, earlier case — see "A 2xx response with no body" above.) Neither does the `text` transport, which has no framing to recognize in the first place. + +## Cancellation + +Cancellation flows in both directions between the returned stream and the underlying `fetch`: + +- **Cancelling the stream cancels the response.** If the code consuming `response.stream` calls `reader.cancel()` (or the controller's own `abort()` does the equivalent), the adapter cancels the underlying response body too, so the connection does not keep receiving data nobody is reading. +- **Aborting the signal errors the stream.** The `signal` on `AgentSendOptions` is forwarded to `fetch`. If it aborts mid-response — after `send()` has already resolved with a stream — the next read from `response.stream` rejects with a `DOMException` named `AbortError`, and the underlying body is cancelled. + +That covers an abort *after* the response has arrived. An abort before `fetch` resolves — for example, the signal is already aborted when you call `send()` — never produces a stream to cancel: it rejects `send()` itself with the same `AbortError`, as described under "Errors" above. + +`[DONE]` ends the stream the same way for `sse` and `ndjson`: as soon as either transport reads a frame or line whose payload is `[DONE]`, ignoring surrounding whitespace, it closes the output stream and cancels the underlying response body — even if the server keeps the connection open past that point. Trailing whitespace after the sentinel (`data: [DONE] \n\n`, seen from real servers) is still recognized rather than leaking into the chat as a stray final chunk. The `text` transport never does this: with no framing to interpret, a literal `[DONE]` in the byte stream is just more text, passed through like everything else. + +## The `parse` hook + +Set `parse` to replace the default per-transport parsing entirely. It receives one decoded chunk — one `sse` frame's joined `data:` payload, or one `ndjson` line — and returns the text to emit, or `null` to emit nothing for that chunk. A return value that is neither a string nor `null` is coerced with `String(...)` rather than silently dropped, since a hook that returned *something* clearly meant to emit something. + +For `sse`, `parse` also receives a second argument, `frame?: { event?: string; id?: string }` — the frame's `event:` and `id:` lines, when present. It is `undefined` for `ndjson`, which has no such metadata. This is the only way to see a discriminator some backends put only in `event:` and never in the `data:` payload: LangServe frames a failure as `event: error`, with nothing in the payload itself to tell it apart from ordinary assistant text, so a `parse` that ignores `frame` renders that failure as prose. + +```ts +const provider = createHttpAgentProvider({ + url: '/api/chat', + parse: (chunk, frame) => { + if (frame?.event === 'error') return null; // or throw, to fail the stream instead of masking it + const parsed = JSON.parse(chunk); + return parsed.choices[0]?.delta?.content ?? null; + }, +}); +``` + +Given frames whose payloads are `{"choices":[{"delta":{"content":"Hel"}}]}` and `{"choices":[{"delta":{"content":"lo"}}]}`, this accumulates to `"Hello"`. + +`parse` does not see every chunk, even so. An `sse` frame with no `data:` lines at all (only `event:`, `id:`, or comment lines) never reaches it, and neither does a payload that is `[DONE]` (ignoring surrounding whitespace) — both are handled before your hook runs, for either transport it applies to. + + + + + +## Where this fits + +`createHttpAgentProvider` returns an ordinary [`AgentProvider`](/docs/integration/agent-provider/) — hand it to `AgentController` and nothing downstream needs to know the send it drives is happening over HTTP at all. It exists so that connecting a backend that speaks SSE, NDJSON, or plain text does not require writing the stream-decoding half of a provider by hand; see the agent provider page if your backend needs something this adapter's options cannot express, since a hand-written `send()` remains the fallback for anything more unusual. diff --git a/src/content/docs/integration/index.mdx b/src/content/docs/integration/index.mdx new file mode 100644 index 0000000..f30e3b4 --- /dev/null +++ b/src/content/docs/integration/index.mdx @@ -0,0 +1,36 @@ +--- +title: Integration +description: How an agent provider, the agent controller, and Loquix components divide the work of talking to a backend. +--- + +Loquix components render a conversation. They do not decide what an assistant says, and they never call a backend. Connecting a real model happens in two other pieces: a provider you write, and a controller Loquix supplies. + +## The three pieces + +An **agent provider** is a small object that talks to your backend and returns a streaming response. You write it, because only you know your API shape, your auth, and your request format. + +The **agent controller** is a Lit reactive controller that owns conversation state: the message history, the current state (`idle`, `sending`, `streaming`, `paused`, `complete`, `error`), and the send lifecycle. It calls your provider and turns its stream into updates your component can render. + +**Components** — the message list, the composer, and the rest of the catalog — read and display that state. They dispatch events such as `loquix-submit`; they do not fetch, stream, or hold conversation history themselves. + +Components read from the controller, the controller reads from your provider, and nothing upstream of the controller talks back to a component directly. + +## What you write + +For the common case, you write one thing: a provider that implements `send(messages, options)` and returns a stream of text. History, state transitions, and DOM wiring are handled once you hand that provider to a controller. + +## What you do not write + +The controller already handles work that is easy to underestimate: + +- **Streaming assembly** — accumulating chunks into a current response as they arrive. +- **Abort** — a single `AbortController` per request that cancels an in-flight send or an active stream. +- **Pause and resume** — suspending and continuing stream consumption, handled separately from abort. +- **Timeouts** — two of them, each defaulting to sixty seconds. `sendTimeout` bounds only the wait for your provider's `send()` to resolve with a stream; the moment it does, that timeout is cleared and cannot fire again, so a response that goes on streaming past sixty seconds is left alone. `streamIdleTimeout` bounds the stream itself, but only its silences — it resets on every chunk, so a long, actively-producing answer is never cut off, only one that goes quiet. +- **Message limits** — default caps on a single message's length and on how many messages a conversation holds. + +None of that is implicit or hidden inside a component. It lives in the controller, and it exists so you do not have to reimplement it around every backend. + +## Where to go next + +Five pages cover the rest of this layer: one on the [agent provider](/docs/integration/agent-provider/) interface you implement, one on the [agent controller](/docs/integration/agent-controller/) that consumes it, one on moving files through [file uploads](/docs/integration/uploads/), one on [keeping keys](/docs/integration/keeping-keys-safe/) off the client entirely, and one on the [HTTP adapter](/docs/integration/http-adapter/) that builds a provider for you out of a URL. Read the provider page first if you are connecting a new backend; read the keys page first if you are deciding where a request should originate; read the adapter page if your backend already speaks plain HTTP and you would rather not write `send()` by hand. diff --git a/src/content/docs/integration/keeping-keys-safe.mdx b/src/content/docs/integration/keeping-keys-safe.mdx new file mode 100644 index 0000000..815d737 --- /dev/null +++ b/src/content/docs/integration/keeping-keys-safe.mdx @@ -0,0 +1,40 @@ +--- +title: Keeping keys safe +description: Why a provider API key never belongs in the browser, and where it lives instead. +--- + +A provider API key is a credential, and this page is about the one place it must never live: code that runs in a browser. It covers why that is a hard rule rather than a preference, what shape your architecture takes once you follow it, and how much of that shape Loquix's components and controllers already assume on your behalf. + +## The rule + +A provider API key never reaches the browser. Anything you ship to a client — JavaScript source, a bundled config object, an environment variable exposed at build time — is readable by whoever is running that client. There is no way to hand a key to code running on a user's machine and keep it from that user. + +An `Origin` header check does not change this. A real browser does set that header itself — `Origin` is a forbidden header name, so page JavaScript cannot override it in a `fetch` call. But nothing about an HTTP request requires a browser. Whoever has extracted your key calls the vendor, or your proxy, from `curl` or a script, and sends whatever `Origin` you want to see, or none at all. The check constrains cross-origin requests made by unmodified browsers; it says nothing about the client that is already holding your key. + +If a key can be extracted from what you send to the browser, it is compromised, regardless of what checks the request that carries it passes. + +## What this means for architecture + +Your backend sits between the UI and the model. The browser talks to your backend; your backend talks to the vendor. + +``` +Loquix components → your backend → provider API +``` + +The browser never holds a provider credential and never makes a request the vendor accepts directly. Your backend is the only thing that presents the key, and it presents it in requests it constructs itself, from messages it received over a connection it controls. + +An [agent provider](/docs/integration/agent-provider/) you write is exactly the seam where this split happens: `send()` runs in the browser and calls your backend, not the vendor, so the object shipped to the client never contains a credential to begin with. + +## What the components already assume + +Loquix components never call a backend. They render conversation state and dispatch events; the [agent controller](/docs/integration/agent-controller/) drives that state by calling the provider you hand it. Nothing in the catalog, and nothing in the controller, makes an outbound request to a model vendor — that request only exists inside the provider's `send()`, and only your code decides what it targets. + +This is why the split above costs you nothing extra to adopt: the provider interface already expects to call *something you control*, and putting your own backend on the other end of that call is the interface working as designed, not a workaround bolted onto it. + +The same applies to file uploads. An [upload provider](/docs/integration/uploads/)'s own interface comment is explicit that implementations must not embed secret keys client-side — operations that need one, such as deletion or signed uploads, belong behind a server-side proxy, not inside a provider shipped to the browser. + +## Where this fits + +Concrete, vendor-by-vendor recipes for putting a specific backend behind a proxy are not written yet — this page describes the architecture the recipes will implement, not the recipes themselves. The [HTTP adapter](/docs/integration/http-adapter/) is the piece that turns a proxy endpoint into a working provider once you have one, and a future revision of this page will point to per-vendor recipes once they exist. + +Until then, the rule above and the shape it implies are what to build against: keep the key on a server you control, and let an agent or upload provider talk to that server instead of the vendor directly. diff --git a/src/content/docs/integration/uploads.mdx b/src/content/docs/integration/uploads.mdx new file mode 100644 index 0000000..63ecadf --- /dev/null +++ b/src/content/docs/integration/uploads.mdx @@ -0,0 +1,195 @@ +--- +title: File uploads +description: The provider and controller that move a picked file to a hosted URL. +--- + +import { Aside } from '@astrojs/starlight/components'; + +Loquix's chat components never send file contents anywhere. What moves through the chat seam is a reference — an `AgentMessageAttachment` of `{ url, mimeType, filename }`, described on the [agent provider page](/docs/integration/agent-provider/) as "references to already-uploaded files, not file contents." Getting a picked file to that `url` is a separate concern, and it has the same two-piece shape as the chat seam: an **upload provider** you write, and an **upload controller** Loquix supplies to drive it. + +`` collects files from the user and renders them as chips, using the `status` and `progress` fields on each [`Attachment`](/docs/components/attachment-panel/) to show pending, uploading, complete, and error states. It does not upload anything itself — see [Attachment Panel](/docs/components/attachment-panel/) for the reassignment rule this page reuses. `UploadController` is what actually runs the uploads and keeps those fields current. + +## The `UploadProvider` interface + +```ts +interface UploadProvider { + readonly name: string; + + upload(file: File, options: UploadOptions): Promise; + + delete?(result: UploadResult): Promise; + + validate?(file: File): string | undefined; +} +``` + +- **`name`** — a human-readable label, for debugging and logging. +- **`upload(file, options)`** — the one required method. It receives the picked `File` and an `AbortSignal`/progress callback, and resolves with an `UploadResult`. Throw on failure; `UploadController` handles retries. +- **`delete?(result)`** — optional. Receives the full `UploadResult`, not just a URL, so a provider can read its own asset identifier back out of `result.assetId` or `result.metadata` rather than parsing a possibly-transformed URL. +- **`validate?(file)`** — optional and synchronous. `UploadController` calls it before a file enters the queue, for checks that do not need the network — size, MIME type, extension. Return an error message to reject the file, or `undefined` to accept it. + + + +## `UploadOptions` and `UploadResult` + +`UploadController` calls `upload(file, options)` with: + +```ts +interface UploadOptions { + signal?: AbortSignal; + onProgress?: (progress: number) => void; +} +``` + +`signal` is always present in practice — see "`uploadTimeout` and abort" below for what it is composed from. `onProgress` reports 0–100; a provider unable to report real progress can simply never call it. + +A resolved upload returns: + +```ts +interface UploadResult { + url: string; + assetId?: string; + variants?: Record; + metadata?: Record; +} +``` + +- **`url`** — the only required field. The controller validates this URL itself before treating the upload as successful; see "URL validation" below. +- **`assetId`** — a provider-specific identifier (an Uploadcare UUID, an S3 key, a Supabase path) for `delete()` to use later. +- **`variants`** — optional named URL variants, such as a thumbnail or a transcoded format. +- **`metadata`** — an open bag of provider-specific data. + +## The `UploadController` + +```ts +import { UploadController } from '@loquix/core/controllers/upload.controller'; + +class MyHost extends LitElement { + private _upload = new UploadController(this, new MyUploadProvider(), { + concurrency: 3, + onUploadComplete: (attachment, result) => { /* ... */ }, + }); +} +``` + +The constructor's first argument is a Lit `ReactiveControllerHost` — inside a `LitElement`, that is the element itself, the same as [`AgentController`](/docs/integration/agent-controller/). + +As a `ReactiveController`, it implements `hostConnected()` and `hostDisconnected()` — the same shape as [`AgentController`](/docs/integration/agent-controller/): `hostConnected()` is a no-op — nothing starts until you call `add()` — and `hostDisconnected()` calls `abortAll()`, so removing the host element from the DOM cancels every upload still in flight. + +## States + +`state` is one of: + +| State | Meaning | +| --- | --- | +| `idle` | Nothing active, queued, or waiting on a retry, and no results or errors recorded. The starting state, and where `reset()` returns to. | +| `uploading` | At least one upload is active, queued, or waiting on a retry timer. This takes priority over `error` — a batch with some failures and others still running reports `uploading` until nothing is left in flight. | +| `error` | Nothing is active, queued, or retrying, and at least one file ended in an error. | +| `complete` | Nothing is active, queued, or retrying, nothing errored, and at least one file succeeded. | + +Unlike the chat controller, there is no `paused` state — uploads either run or they do not. + +## Methods + +| Method | Signature | Effect | +| --- | --- | --- | +| `add` | `add(attachments: Attachment[]): void` | Validates each attachment synchronously via `provider.validate()` (if defined). An attachment with no `file`, or one that fails validation, is marked `status: 'error'` immediately and never queued; everything else is queued and starts uploading right away, up to `concurrency`. | +| `retry` | `retry(attachmentId: string): void` | Re-queues a previously errored attachment. Throws — synchronously, not as a rejected promise, since `retry()` is not `async` — if the ID was never passed to `add()`. Otherwise a no-op unless the attachment is currently errored: active, queued, and completed attachments cannot be retried. An errored attachment with no `file` — including one that errored because it never had one — also stays put; there is nothing to upload. Otherwise re-runs `provider.validate()` if present; a renewed validation failure leaves the attachment errored without firing any callback. | +| `cancel` | `cancel(attachmentId: string): void` | Removes the attachment from the queue if queued, clears its pending retry timer if any, and aborts it if active. Unlike `retry()`, an unknown or already-settled ID is a silent no-op — it never throws. | +| `abortAll` | `abortAll(): void` | Aborts every active upload, clears the queue, and clears pending retry timers. Does not clear completed results. | +| `reset` | `reset(): void` | Calls `abortAll()`, then clears results, error tracking, and attachment history, and returns to `idle`. | +| `setProvider` | `setProvider(provider: UploadProvider): void` | Swaps the provider used for subsequent uploads. If the controller is currently `uploading`, it logs a console warning and calls `abortAll()` first — in-flight uploads are aborted, not handed to the new provider. | + + + +## Read-only properties + +| Property | Type | Description | +| --- | --- | --- | +| `state` | `UploadState` | The current state, one of the four values above. | +| `results` | `ReadonlyMap` | Completed upload results, keyed by attachment ID. Not cleared by `abortAll()`. `reset()` clears every entry. `retry()` also deletes the entry for its one attachment, though in the ordinary flow there is nothing there to delete — that only matters if the same attachment ID was previously completed, re-added, and failed the second time around. | +| `activeCount` | `number` | Number of uploads currently in flight. | +| `queuedCount` | `number` | Number of uploads waiting for a concurrency slot. | + +## Options + +The third constructor argument is optional, and so is everything on it: + +| Option | Type | Default | +| --- | --- | --- | +| `concurrency` | `number` | `3` | +| `maxRetries` | `number` | `2` | +| `retryDelay` | `number` (ms) | `1000` | +| `uploadTimeout` | `number` (ms) | `300_000` (5 minutes) | + +These four are sanitized: a value that is not a finite number falls back to its default; a finite value is floored to an integer and then clamped up to the option's floor if it is below it. `concurrency` floors at `1`, `maxRetries` and `retryDelay` at `0`, and `uploadTimeout` at `1000`. So `uploadTimeout: 500` becomes `1000`, not the five-minute default, and `concurrency: 0` becomes `1`, not `3` — only a non-finite value (`NaN`, `undefined`, `Infinity`) falls back to the default. Passing `maxRetries: 0` is a valid way to disable retries entirely: `0` is not below the floor, it *is* the floor, so there is nothing for sanitization to correct. + +### `uploadTimeout` and abort + +Each upload attempt gets its own combined signal: the per-file `AbortController` used by `cancel()`/`abortAll()`, composed with a fresh `AbortSignal.timeout(uploadTimeout)` via `AbortSignal.any([...])`. That combined signal is what `provider.upload()` receives as `options.signal`. The timeout is measured from when the attempt actually starts — not from when `add()` queued it — so a file waiting behind others at the concurrency limit does not burn down its timeout while it waits, and a retried attempt gets a full new timeout window rather than a countdown continuing from the failed attempt. + +## Callbacks + +All optional, and all part of the same options object as above: + +| Callback | Signature | Fires when | +| --- | --- | --- | +| `onAttachmentUpdate` | `(attachment: Attachment) => void` | An attachment's status or progress changes — queued, uploading with a new `progress` value, completed, or errored. | +| `onUploadComplete` | `(attachment: Attachment, result: UploadResult) => void` | A single file's upload succeeds. | +| `onUploadError` | `(attachment: Attachment, error: Error) => void` | A single file's upload fails. This fires for a validation failure caught in `add()` before the file ever reaches the queue, just as it does for a network or provider failure after every retry is exhausted — an invalid file never gets a retry, but it still reaches this callback. | +| `onAllComplete` | `(results: ReadonlyMap) => void` | The queue is fully drained and every file succeeded. If even one file is in an errored state when the queue empties, this does not fire at all. | +| `onStateChange` | `(state: UploadState) => void` | `state` changes to a new value (no-op transitions to the same state do not re-fire it). | + +## URL validation + + + +## Wiring it to Attachment Panel + +`UploadController` never touches the array bound to `` — the same reassignment rule from [Attachment Panel](/docs/components/attachment-panel/) applies here. `onAttachmentUpdate` hands you a new attachment object; your host reassigns its own array so the panel re-renders: + +```js +import { UploadController } from '@loquix/core/controllers/upload.controller'; + +class MyHost extends LitElement { + attachments = []; + + _upload = new UploadController(this, new MyUploadProvider(), { + onAttachmentUpdate: (attachment) => { + this.attachments = this.attachments.map((a) => + a.id === attachment.id ? attachment : a, + ); + }, + }); + + render() { + return html` + { + this.attachments = [...this.attachments, ...e.detail.attachments]; + this._upload.add(e.detail.attachments); + }} + @loquix-attachment-remove=${(e) => { + this._upload.cancel(e.detail.id); + this.attachments = this.attachments.filter((a) => a.id !== e.detail.id); + }} + > + `; + } +} +``` + +Once `onUploadComplete` or `onAllComplete` reports a finished `UploadResult`, its `url` is what you put into an `AgentMessageAttachment` for [`AgentController.send()`](/docs/integration/agent-controller/) — nothing wires that hand-off automatically, since that is the point where the upload seam ends and the chat seam begins. + +## Where this fits + +The upload seam and the chat seam run independently: nothing in `UploadController` calls an agent provider, and nothing in `AgentController` calls an upload provider. See the [integration overview](/docs/integration/) for how a provider, a controller, and Loquix components divide the work on each seam. diff --git a/src/content/docs/quick-start.mdx b/src/content/docs/quick-start.mdx index 2bb3dd5..3f3c7b5 100644 --- a/src/content/docs/quick-start.mdx +++ b/src/content/docs/quick-start.mdx @@ -146,6 +146,7 @@ chat.addEventListener('loquix-submit', async event => { ## Where to go next - Use [Events and state](/docs/guides/events-and-state/) to connect streaming and application state. +- Grow the `fetch` call above into something that streams, aborts, and tracks conversation history for you: the [Integration](/docs/integration/) section covers the agent provider and controller built for exactly that. - Apply your brand in [Theming](/docs/guides/theming/). - Review [Accessibility](/docs/guides/accessibility/) before shipping. - Browse the [component catalog](/docs/components/).