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)
    }
  }
}