Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions libraries/llm/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
types/
116 changes: 116 additions & 0 deletions libraries/llm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# @patchwork/llm

An LLM toolkit for Patchwork tools, extracted from `chat` and enriched with
`rlm`'s teaching telemetry. It gives you:

- **`popup()` / `dom()`** — a `<div popover>` model picker (Browser / OpenRouter
/ Ollama + sampling parameters, prompts, and tools). `popup()` is framed
(header + Cancel/Done); `dom()` is the bare panel to embed. Writes the choice
to the user's **account settings doc**, so the model + API key are shared
across every tool and synced across devices.
- **A refresh-surviving `SharedWorker`** that runs all three providers off the
main thread (cross-tab, survives reload — keyed by an optional `sessionKey`).
- **A streaming API** (`generate` callback-style, `stream` async-iterator-style)
that carries **rich telemetry alongside the text** so you can build UIs that
show *how the model thinks*:
- **`prediction`** events — the model's top-k next-token distribution at each
step (`[{token, p}]`). Works for **local** (via a transformers.js
`logits_processor`) **and OpenRouter** (via `logprobs`/`top_logprobs`).
- **`stats`** events — prompt/gen token counts, time-to-first-token,
tokens/sec, and the exact decode settings used (`temperature`, `top_p`,
greedy, …).

Plain vanilla JS, no build step. Its only npm dependency is
`@inkandswitch/patchwork-providers` (the request/provide config plumbing);
transformers.js — the model runtime — is imported from a CDN inside the worker,
only when the local provider is used.

## Install / consume

The package lives at `libraries/llm` and is named `@patchwork/llm`.

- **Bundled tools (vite):** add a resolve alias and the bundler inlines it,
worker included (vite understands `new URL("./worker.js", import.meta.url)`):

```js
// vite.config.js
resolve: {
alias: {
"@patchwork/llm": fileURLToPath(new URL("../libraries/llm/index.js", import.meta.url)),
},
}
```

- **Bundleless tools:** import by relative path, or add `@patchwork/llm` to the
host importmap to share one copy across tools.

## Usage

```js
import { popup, stream, generate, readConfig } from "@patchwork/llm"

// 1. Let the user choose a model / paste their OpenRouter key.
const el = popup()
document.body.append(el)
el.showPopover()
await el.result // resolves to the config on close (null if cancelled)

// 2a. Stream with telemetry (async iterator):
let text = ""
for await (const ev of stream(messages, { topk: 5 })) {
switch (ev.type) {
case "status": setStatus(ev.message); break // model loading…
case "token": text += ev.delta; render(text); break
case "prediction": renderCandidates(ev.step, ev.candidates); break
case "stats": renderStats(ev); break // ttftMs, tokPerSec, decode…
case "done": finish(ev.text); break
}
}

// 2b. …or callback style:
const { text, stats } = await generate(messages, {
topk: 5,
temperature: 0.7,
onToken: (delta, full) => render(full),
onPrediction: (candidates, step) => renderCandidates(step, candidates),
onStats: (s) => renderStats(s),
onStatus: (m) => setStatus(m),
signal, // AbortSignal
})
```

### Config (on the account doc)

Everything lives under `accountDoc.llm`:

```js
{
provider: "local" | "openrouter" | "ollama",
temperature: 0.7,
local: { model },
openrouter: { apiKey, model, contextLength, maxCompletionTokens },
ollama: { url, model },
}
```

`readConfig()` / `writeConfig(patch)` read/write it (defaulting missing fields);
`popup()` / `dom()` are the UI over them.

### Resume after refresh

Pass a stable `sessionKey` (e.g. a doc URL) to `generate`/`stream`; after a
reload, `resume(sessionKey, { onToken, onDone })` re-attaches to the still-running
stream in the worker.

## Events reference

| event | fields | local | openrouter | ollama |
|--------------|------------------------------------------------------------------|:-----:|:----------:|:------:|
| `token` | `delta`, `text` | ✓ | ✓ | ✓ |
| `prediction` | `step`, `candidates: [{token, p}]` | ✓ | ✓ | — |
| `stats` | `promptTokens`, `genTokens`, `ttftMs`, `totalMs`, `tokPerSec`, `decode` | ✓ | ✓ | ✓* |
| `status` | `message` (model download / shader compile) | ✓ | — | — |
| `done` | `text`, `stats` | ✓ | ✓ | ✓ |

\* Ollama stats come from its final `done` chunk (`eval_count`, `eval_duration`).
OpenRouter needs a model that supports `logprobs` for `prediction` events.
135 changes: 135 additions & 0 deletions libraries/llm/builtin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Chrome built-in AI (the Prompt API — on-device Gemini Nano).
*
* Runs on the MAIN thread: the `LanguageModel` global is a window API, not a
* worker one, so this path bypasses the SharedWorker. No next-token logprobs are
* exposed, so `predict()` (the typing popup) doesn't work with built-in —
* generation + streaming do.
*/

function getLM() {
if (typeof self === "undefined") return null
return self.LanguageModel || (self.ai && self.ai.languageModel) || null
}

/** Is the Prompt API present at all (i.e. show the option)? */
export function builtinSupported() {
return !!getLM()
}

/** "available" | "downloadable" | "downloading" | "unavailable" */
export async function builtinAvailability() {
const LM = getLM()
if (!LM) return "unavailable"
try {
if (LM.availability) return await LM.availability()
if (LM.capabilities) {
const c = await LM.capabilities()
return c?.available === "readily"
? "available"
: c?.available === "after-download"
? "downloadable"
: "unavailable"
}
} catch {}
return "unavailable"
}

/** @param {import("./config.js").ChatMessage[]} messages */
function messagesToText(messages) {
return messages
.filter((m) => m.role !== "system")
.map((m) => m.content)
.join("\n\n")
}

/** @param {ReadableStream<any>} stream */
async function* readStream(stream) {
const reader = stream.getReader()
try {
while (true) {
const {done, value} = await reader.read()
if (done) break
yield value
}
} finally {
try {
reader.releaseLock()
} catch {}
}
}

/**
* @typedef {Object} BuiltinOpts
* @property {number} [temperature]
* @property {number} [topK]
* @property {string} [system]
* @property {(delta: string, full: string) => void} [onToken]
* @property {(status: string) => void} [onStatus]
* @property {AbortSignal} [signal]
*/

/**
* Generate via the Prompt API. `onToken(delta, full)` per chunk; returns full
* text. Handles both the old (cumulative chunk) and new (delta chunk) shapes.
* @param {string|import("./config.js").ChatMessage[]} input
* @param {BuiltinOpts} [opts]
*/
export async function builtinGenerate(input, opts = {}) {
const {temperature, topK, system, onToken, onStatus, signal} = opts
const LM = getLM()
if (!LM)
throw new Error(
"Built-in AI isn't available here (needs Chrome with the Prompt API)."
)
/** @type {any} */
const createOpts = {}
if (typeof temperature === "number") {
createOpts.temperature = Math.min(2, Math.max(0, temperature))
createOpts.topK = topK && topK > 0 ? topK : 8 // Prompt API needs topK alongside temperature
}
if (system) createOpts.initialPrompts = [{role: "system", content: system}]
createOpts.monitor = (/** @type {any} */ m) => {
try {
m.addEventListener("downloadprogress", (/** @type {any} */ e) =>
onStatus?.(
"Downloading built-in model… " + Math.round((e.loaded || 0) * 100) + "%"
)
)
} catch {}
}
let session
try {
session = await LM.create(createOpts)
} catch {
// retry without sampling params (version differences)
session = await LM.create(system ? {initialPrompts: createOpts.initialPrompts} : {})
}
try {
const text = typeof input === "string" ? input : messagesToText(input)
let full = ""
if (session.promptStreaming) {
const stream = session.promptStreaming(text, signal ? {signal} : undefined)
for await (const chunk of readStream(stream)) {
if (typeof chunk !== "string") continue
let delta
if (chunk.startsWith(full)) {
delta = chunk.slice(full.length)
full = chunk
} else {
delta = chunk
full += chunk
}
if (delta) onToken?.(delta, full)
}
} else {
full = await session.prompt(text)
onToken?.(full, full)
}
return full
} finally {
try {
session.destroy?.()
} catch {}
}
}
Loading
Loading