tiny-army / web /personaStream.js
polats's picture
Personas + war-diary via llama.cpp (reusing woid's persona SSE protocol)
67f4321
/**
* Shared helpers for the bridge's persona-generation stream.
*
* Both AgentProfile (player characters) and NPCs use the same SSE
* endpoint and the same partial-JSON extraction trick to live-update
* a name + about input as the stream arrives. Keeping the consumer
* here means there's a single place to evolve the protocol — events
* the bridge emits today (`model`, `delta`, `persona-done`,
* `avatar-start/done/error`, `done`, `error`) and any future ones.
*/
/**
* Best-effort partial-JSON extraction so we can stream growing fields
* back into the form before the closing `"` or `}` lands. Pulls the
* value of the named key, treating an unterminated string as
* "everything after the key marker up to EOL".
*
* Bounded outputs (name 80 chars, about 1000 chars) match the bridge's
* own caps so we don't overshoot the field on partial reads.
*/
export function extractLivePersona(raw) {
if (!raw) return { name: '', about: '' }
function pull(key) {
const closed = raw.match(new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`))
if (closed) return closed[1].replace(/\\n/g, '\n').replace(/\\"/g, '"')
const open = raw.match(new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)$`))
if (open) return open[1].replace(/\\n/g, '\n').replace(/\\"/g, '"')
return ''
}
return {
name: pull('name').slice(0, 80),
about: pull('about').slice(0, 1000),
}
}
/**
* POST /characters/:pubkey/generate-profile/stream and dispatch each
* SSE event to `onEvent(eventType, parsed, raw)`. Resolves when the
* stream ends; throws if the response isn't OK or if an `error`
* event arrives.
*
* `body` is the JSON payload — typically `{ seed, overwriteName, skipAvatar }`.
* `signal` is an optional AbortSignal so the caller can cancel.
* `path` optionally overrides the endpoint (e.g. a host without per-pubkey
* characters, like Tiny Army's `/persona/generate/stream`); defaults to woid's.
*/
export async function streamGenerateProfile({ bridgeUrl, pubkey, path, body, onEvent, signal }) {
const url = `${bridgeUrl}${path || `/characters/${pubkey}/generate-profile/stream`}`
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body ?? {}),
signal,
})
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const events = buf.split(/\n\n/)
buf = events.pop() ?? ''
for (const evChunk of events) {
const lines = evChunk.split('\n')
let evt = 'message'
const dataLines = []
for (const line of lines) {
if (line.startsWith('event:')) evt = line.slice(6).trim()
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart())
}
const data = dataLines.join('\n')
if (!data) continue
let parsed = null
try { parsed = JSON.parse(data) } catch { /* tolerated — non-JSON deltas exist */ }
if (evt === 'error' && parsed?.error) throw new Error(parsed.error)
onEvent?.(evt, parsed, data)
}
}
}