Spaces:
Running
Running
File size: 3,278 Bytes
67f4321 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 | /**
* 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)
}
}
}
|