polats Claude Opus 4.8 (1M context) commited on
Commit
67f4321
·
1 Parent(s): 0d345ef

Personas + war-diary via llama.cpp (reusing woid's persona SSE protocol)

Browse files

- llm.py: configurable runtime behind one stream_chat() — in-Space llama-cpp-python
(local GGUF or HF pull) by default, OR an external OpenAI-compatible llama.cpp
endpoint (TINY_LLM_BASE_URL, woid's switch). Lock-serialized; stub fallback.
- prompts.py: Tiny-Army persona + war-diary system prompts.
- persona_parse.py: woid's parse.js ported to Python (defensive JSON extraction).
- app.py: Barracks diary() now streams from the model (Gradio generator); new
POST /persona/generate/stream emitting woid-compatible SSE; Personas tab.
- web/personaStream.js: vendored from woid (now takes an optional path param,
upstream-safe); web/personaPanel.js: vanilla panel reusing it verbatim;
web/shell/persona.css. nav.json gains a Personas item.
- Dockerfile/requirements: llama-cpp-python prebuilt CPU wheel + huggingface_hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (11) hide show
  1. Dockerfile +3 -1
  2. app.py +116 -17
  3. llm.py +116 -0
  4. persona_parse.py +88 -0
  5. prompts.py +33 -0
  6. requirements.txt +5 -0
  7. web/personaPanel.js +83 -0
  8. web/personaStream.js +81 -0
  9. web/shell/nav.json +2 -1
  10. web/shell/persona.css +69 -0
  11. web/tiny.js +4 -0
Dockerfile CHANGED
@@ -13,7 +13,9 @@ USER user
13
  WORKDIR /home/user/app
14
 
15
  COPY --chown=user requirements.txt .
16
- RUN pip install --no-cache-dir --user -r requirements.txt
 
 
17
 
18
  COPY --chown=user . .
19
 
 
13
  WORKDIR /home/user/app
14
 
15
  COPY --chown=user requirements.txt .
16
+ # The extra index serves prebuilt llama-cpp-python CPU wheels (no source compile).
17
+ RUN pip install --no-cache-dir --user -r requirements.txt \
18
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
19
 
20
  COPY --chown=user . .
21
 
app.py CHANGED
@@ -1,20 +1,35 @@
1
  """Tiny Army — HF Space, a Gradio Blocks app.
2
 
3
- The UI chrome is 100% Gradio (gr.Tabs / gr.Dropdown / gr.Button). The Pixi
4
- battlefield + sprite viewer render into gr.HTML canvas divs, driven by a head-
5
- injected JS module (web/tiny.js) via `js=` event handlers — so the rendering is
6
- Pixi but the app is unambiguously a Gradio app. Sprite data is the auto-battler's
7
- own static manifest + sheets (a curated subset under web/assets). The diary is a
8
- plain Gradio fn (stub wired to a local llama.cpp small model during the hack).
 
 
 
 
 
 
 
9
  """
10
  import json
 
 
11
  import os
 
12
 
13
  import gradio as gr
14
  import uvicorn
15
- from fastapi import FastAPI
 
16
  from fastapi.staticfiles import StaticFiles
17
 
 
 
 
 
18
  HERE = os.path.dirname(os.path.abspath(__file__))
19
  WEB = os.path.join(HERE, "web")
20
 
@@ -76,15 +91,16 @@ THEME = ('<style>'
76
  # Gradio still hides it (display:none on the inactive tab's ancestor).
77
  '.gradio-container .tabitem{padding:0 !important;}'
78
  '.gradio-container .tabs{border:0 !important;}'
79
- '#sprite-stage{position:fixed !important;top:0;bottom:0;right:0;'
80
  'left:var(--tac-w,240px);height:auto !important;z-index:1;}'
81
- 'body.tac-collapsed #sprite-stage{left:0;}'
82
- '@media (max-width:768px){#sprite-stage{left:0;}}'
83
  '</style>')
84
  HEAD = ('<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">'
85
  + HIDE_TABS + FONTS + THEME +
86
  '<link rel="stylesheet" href="/web/shell/sidebar.css">'
87
  '<link rel="stylesheet" href="/web/shell/spriteScene.css">'
 
88
  '<script type="module" src="/web/tiny.js"></script>'
89
  '<script src="/web/shell/sidebar.js"></script>')
90
  STAGE = "height:56vh;border:1px solid #20262e;border-radius:12px;overflow:hidden;background:#0b0e12"
@@ -132,13 +148,28 @@ def build_sidebar(nav):
132
  SIDEBAR_HTML = build_sidebar(json.load(open(os.path.join(WEB, "shell", "nav.json"))))
133
 
134
 
135
- def diary(unit: str, traits: str) -> str:
136
- """Stub replaced during the hack by a local llama.cpp small model."""
137
- t = (traits or "").strip() or "untested"
138
- u = (unit or "").strip() or "a nameless soldier"
139
- return (f"— Diary of {u} ({t}) —\n\n(placeholder) Today I held the line. A local "
140
- f"small model will give me a real voice soon, shaped by what I lived "
141
- f"through on the field.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
 
144
  with gr.Blocks(title="Tiny Army") as demo:
@@ -161,6 +192,10 @@ with gr.Blocks(title="Tiny Army") as demo:
161
  traits = gr.Textbox("Cautious, Veteran, Vengeful", label="Traits")
162
  out = gr.Textbox(label="War diary", lines=6)
163
  gr.Button("Write diary", variant="primary").click(diary, [unit, traits], out)
 
 
 
 
164
 
165
  # Mount Gradio on FastAPI so we can also serve the JS module + the sprite assets.
166
  fastapi_app = FastAPI()
@@ -182,6 +217,70 @@ fastapi_app.mount("/web", StaticFiles(directory=WEB), name="web")
182
  # NOTE: serve sprite assets at /sprites, NOT /assets — Gradio serves its own UI
183
  # bundle from /assets, and mounting there shadows it (breaks the whole UI).
184
  fastapi_app.mount("/sprites", StaticFiles(directory=os.path.join(WEB, "assets")), name="sprites")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  app = gr.mount_gradio_app(fastapi_app, demo, path="/", head=HEAD, theme=gr.themes.Soft())
186
 
187
 
 
1
  """Tiny Army — HF Space, a Gradio Blocks app.
2
 
3
+ Gradio is the host/shell: gr.Blocks gr.Tabs, mounted on FastAPI via
4
+ gr.mount_gradio_app, with tab-switching and the page served by Gradio. The tabs
5
+ differ in how much Gradio UI they use:
6
+ Battle / Sprite Animations — each is just an empty `gr.HTML` div that a head-
7
+ injected ES module (web/tiny.js) fills with our OWN UI: Pixi canvas + the
8
+ shared, framework-agnostic render core and chrome (auto-battler's
9
+ spriteScene.js / spritePlayground.js, bundled to web/, styled by
10
+ web/shell/spriteScene.css). These tabs use NO Gradio widgets — they're custom
11
+ canvas surfaces inside the Gradio shell. (Sprite Animations previously used
12
+ gr.Dropdown/gr.Button; those were replaced by the shared playground.)
13
+ • Barracks — genuine Gradio widgets (gr.Textbox × 2 + gr.Button) wired to a
14
+ Python diary() fn (stub — to be backed by a local llama.cpp small model).
15
+ Sprite data is auto-battler's own static manifest + sheets under web/assets.
16
  """
17
  import json
18
+ import asyncio
19
+ import json as _json
20
  import os
21
+ import threading
22
 
23
  import gradio as gr
24
  import uvicorn
25
+ from fastapi import FastAPI, Request
26
+ from fastapi.responses import StreamingResponse
27
  from fastapi.staticfiles import StaticFiles
28
 
29
+ import llm
30
+ import persona_parse
31
+ import prompts
32
+
33
  HERE = os.path.dirname(os.path.abspath(__file__))
34
  WEB = os.path.join(HERE, "web")
35
 
 
91
  # Gradio still hides it (display:none on the inactive tab's ancestor).
92
  '.gradio-container .tabitem{padding:0 !important;}'
93
  '.gradio-container .tabs{border:0 !important;}'
94
+ '#sprite-stage,#persona-stage{position:fixed !important;top:0;bottom:0;right:0;'
95
  'left:var(--tac-w,240px);height:auto !important;z-index:1;}'
96
+ 'body.tac-collapsed #sprite-stage,body.tac-collapsed #persona-stage{left:0;}'
97
+ '@media (max-width:768px){#sprite-stage,#persona-stage{left:0;}}'
98
  '</style>')
99
  HEAD = ('<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">'
100
  + HIDE_TABS + FONTS + THEME +
101
  '<link rel="stylesheet" href="/web/shell/sidebar.css">'
102
  '<link rel="stylesheet" href="/web/shell/spriteScene.css">'
103
+ '<link rel="stylesheet" href="/web/shell/persona.css">'
104
  '<script type="module" src="/web/tiny.js"></script>'
105
  '<script src="/web/shell/sidebar.js"></script>')
106
  STAGE = "height:56vh;border:1px solid #20262e;border-radius:12px;overflow:hidden;background:#0b0e12"
 
148
  SIDEBAR_HTML = build_sidebar(json.load(open(os.path.join(WEB, "shell", "nav.json"))))
149
 
150
 
151
+ def diary(unit, traits):
152
+ """Streaming war-diary via the llama.cpp runtime. A Gradio generator: each yield
153
+ replaces the output Textbox, so tokens appear live. Falls back to a stub line if
154
+ the model can't load, so the Barracks tab always works."""
155
+ header = f"— Diary of {(unit or 'a nameless soldier').strip()} —\n\n"
156
+ yield header + "_(summoning the model the first run downloads it, please wait…)_"
157
+ try:
158
+ acc = header
159
+ first = True
160
+ for chunk in llm.stream_chat(
161
+ prompts.DIARY_SYSTEM, prompts.diary_user_prompt(unit, traits),
162
+ max_tokens=240, temperature=0.9,
163
+ ):
164
+ if first:
165
+ acc = header # drop the loading note once tokens arrive
166
+ first = False
167
+ acc += chunk
168
+ yield acc
169
+ if first: # produced nothing
170
+ yield header + "Today I held the line."
171
+ except llm.LlmUnavailable as e:
172
+ yield header + f"Today I held the line. _(model unavailable: {e})_"
173
 
174
 
175
  with gr.Blocks(title="Tiny Army") as demo:
 
192
  traits = gr.Textbox("Cautious, Veteran, Vengeful", label="Traits")
193
  out = gr.Textbox(label="War diary", lines=6)
194
  gr.Button("Write diary", variant="primary").click(diary, [unit, traits], out)
195
+ with gr.Tab("Personas"):
196
+ # The vanilla persona panel (web/personaPanel.js) builds the whole page
197
+ # into this div and streams from /persona/generate/stream.
198
+ gr.HTML('<div id="persona-stage" style="overflow:hidden"></div>')
199
 
200
  # Mount Gradio on FastAPI so we can also serve the JS module + the sprite assets.
201
  fastapi_app = FastAPI()
 
217
  # NOTE: serve sprite assets at /sprites, NOT /assets — Gradio serves its own UI
218
  # bundle from /assets, and mounting there shadows it (breaks the whole UI).
219
  fastapi_app.mount("/sprites", StaticFiles(directory=os.path.join(WEB, "assets")), name="sprites")
220
+
221
+
222
+ def _sse(event, data):
223
+ return f"event: {event}\ndata: {_json.dumps(data)}\n\n"
224
+
225
+
226
+ # Persona generation, woid-protocol-compatible so web/personaStream.js consumes it
227
+ # unchanged: emits `model` → `delta`* → `persona-done` → `done` (or `error`). The
228
+ # blocking llama.cpp generator runs in a worker thread bridged to this async SSE
229
+ # generator via a thread-safe queue, so it never stalls uvicorn's event loop.
230
+ # Defined BEFORE mount_gradio_app so the "/" Gradio mount doesn't shadow it.
231
+ @fastapi_app.post("/persona/generate/stream")
232
+ async def persona_generate_stream(request: Request):
233
+ body = await request.json()
234
+ seed = body.get("seed", "")
235
+ unit_class = body.get("class") or body.get("unitClass") or ""
236
+
237
+ async def gen():
238
+ yield _sse("model", {"model": llm.model_id()})
239
+ loop = asyncio.get_running_loop()
240
+ q: asyncio.Queue = asyncio.Queue()
241
+ DONE = object()
242
+
243
+ def worker():
244
+ try:
245
+ for chunk in llm.stream_chat(
246
+ prompts.PERSONA_SYSTEM, prompts.persona_user_prompt(unit_class, seed),
247
+ max_tokens=400, temperature=0.8,
248
+ ):
249
+ loop.call_soon_threadsafe(q.put_nowait, ("delta", chunk))
250
+ except Exception as e: # LlmUnavailable or runtime error
251
+ loop.call_soon_threadsafe(q.put_nowait, ("error", str(e)))
252
+ loop.call_soon_threadsafe(q.put_nowait, (DONE, None))
253
+
254
+ threading.Thread(target=worker, daemon=True).start()
255
+
256
+ raw_parts = []
257
+ while True:
258
+ kind, val = await q.get()
259
+ if kind is DONE:
260
+ break
261
+ if kind == "error":
262
+ yield _sse("error", {"error": val})
263
+ return
264
+ raw_parts.append(val)
265
+ yield _sse("delta", {"content": val})
266
+
267
+ try:
268
+ p = persona_parse.parse_persona_json("".join(raw_parts))
269
+ except Exception as e:
270
+ yield _sse("error", {"error": f"could not parse persona: {e}"})
271
+ return
272
+ payload = {"name": p["name"], "about": p["about"], "specialty": p["specialty"],
273
+ "personality": p["personality"], "vibe": p["vibe"], "profileModel": llm.model_id()}
274
+ yield _sse("persona-done", payload)
275
+ yield _sse("done", {**payload, "_generator": {"model": llm.model_id()}})
276
+
277
+ return StreamingResponse(gen(), media_type="text/event-stream", headers={
278
+ "Cache-Control": "no-cache, no-transform",
279
+ "Connection": "keep-alive",
280
+ "X-Accel-Buffering": "no",
281
+ })
282
+
283
+
284
  app = gr.mount_gradio_app(fastapi_app, demo, path="/", head=HEAD, theme=gr.themes.Soft())
285
 
286
 
llm.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configurable llama.cpp runtime for Tiny Army's persona + war-diary.
2
+
3
+ Two modes, env-selected, behind ONE uniform `stream_chat()` generator so callers are
4
+ runtime-agnostic:
5
+
6
+ • External (TINY_LLM_BASE_URL set): stream from any OpenAI-compatible llama.cpp
7
+ server — your local `llama-server`, or an HF-hosted GGUF endpoint. This mirrors
8
+ woid's LOCAL_LLM_BASE_URL switch (reused, not reinvented).
9
+ • In-Space (default): llama-cpp-python loads a GGUF — a local file
10
+ (TINY_LLM_MODEL_PATH) or one pulled from Hugging Face (TINY_LLM_HF_REPO +
11
+ TINY_LLM_HF_FILE).
12
+
13
+ Generation is synchronous and CPU-bound, so it's serialized by a lock (one CPU model
14
+ can't decode in parallel) — async callers (the SSE endpoint) run `stream_chat` in a
15
+ threadpool. If no backend can load, `stream_chat` raises LlmUnavailable and callers
16
+ fall back to a stub so the Space still works.
17
+ """
18
+ import json
19
+ import os
20
+ import threading
21
+ import urllib.request
22
+
23
+ BASE_URL = os.environ.get("TINY_LLM_BASE_URL", "").rstrip("/")
24
+ API_KEY = os.environ.get("TINY_LLM_API_KEY", "")
25
+ MODEL_PATH = os.environ.get("TINY_LLM_MODEL_PATH", "")
26
+ HF_REPO = os.environ.get("TINY_LLM_HF_REPO", "Qwen/Qwen2.5-0.5B-Instruct-GGUF")
27
+ HF_FILE = os.environ.get("TINY_LLM_HF_FILE", "*q4_k_m.gguf")
28
+ N_CTX = int(os.environ.get("TINY_LLM_N_CTX", "4096"))
29
+ N_THREADS = int(os.environ.get("TINY_LLM_N_THREADS", str(os.cpu_count() or 2)))
30
+
31
+ # A label for the `model` SSE event / UI — not used to route requests.
32
+ MODEL_ID = (
33
+ os.environ.get("TINY_LLM_MODEL")
34
+ or ("external" if BASE_URL else "")
35
+ or (os.path.basename(MODEL_PATH) if MODEL_PATH else "")
36
+ or HF_REPO.split("/")[-1]
37
+ )
38
+
39
+ _lock = threading.Lock()
40
+ _llm = None
41
+ _load_error = None
42
+
43
+
44
+ class LlmUnavailable(RuntimeError):
45
+ """No backend could be reached/loaded — callers should fall back to a stub."""
46
+
47
+
48
+ def model_id():
49
+ return MODEL_ID or "tiny-llm"
50
+
51
+
52
+ def _get_local():
53
+ global _llm, _load_error
54
+ if _llm is not None:
55
+ return _llm
56
+ if _load_error is not None:
57
+ raise LlmUnavailable(_load_error)
58
+ try:
59
+ from llama_cpp import Llama
60
+ common = dict(n_ctx=N_CTX, n_threads=N_THREADS, verbose=False)
61
+ if MODEL_PATH:
62
+ _llm = Llama(model_path=MODEL_PATH, **common)
63
+ else: # pulls + caches the GGUF from Hugging Face on first use
64
+ _llm = Llama.from_pretrained(repo_id=HF_REPO, filename=HF_FILE, **common)
65
+ return _llm
66
+ except Exception as e: # import / download / OOM / bad file
67
+ _load_error = f"{type(e).__name__}: {e}"
68
+ raise LlmUnavailable(_load_error)
69
+
70
+
71
+ def _stream_external(system, user, max_tokens, temperature):
72
+ body = json.dumps({
73
+ "model": os.environ.get("TINY_LLM_MODEL", "local"),
74
+ "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
75
+ "temperature": temperature, "max_tokens": max_tokens, "stream": True,
76
+ }).encode()
77
+ headers = {"Content-Type": "application/json"}
78
+ if API_KEY:
79
+ headers["Authorization"] = f"Bearer {API_KEY}"
80
+ req = urllib.request.Request(f"{BASE_URL}/chat/completions", data=body, headers=headers)
81
+ try:
82
+ with urllib.request.urlopen(req, timeout=120) as resp:
83
+ for raw in resp:
84
+ line = raw.decode("utf-8").strip()
85
+ if not line.startswith("data:"):
86
+ continue
87
+ data = line[5:].strip()
88
+ if data == "[DONE]":
89
+ break
90
+ try:
91
+ delta = json.loads(data)["choices"][0]["delta"].get("content")
92
+ except Exception:
93
+ continue
94
+ if delta:
95
+ yield delta
96
+ except Exception as e:
97
+ raise LlmUnavailable(f"external endpoint: {type(e).__name__}: {e}")
98
+
99
+
100
+ def _stream_local(system, user, max_tokens, temperature):
101
+ llm = _get_local()
102
+ for chunk in llm.create_chat_completion(
103
+ messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],
104
+ max_tokens=max_tokens, temperature=temperature, stream=True,
105
+ ):
106
+ delta = chunk["choices"][0]["delta"].get("content")
107
+ if delta:
108
+ yield delta
109
+
110
+
111
+ def stream_chat(system, user, max_tokens=400, temperature=0.8):
112
+ """Yield text chunks from the configured backend. Serialized by a module lock.
113
+ Raises LlmUnavailable if no backend is available."""
114
+ with _lock:
115
+ gen = _stream_external if BASE_URL else _stream_local
116
+ yield from gen(system, user, max_tokens, temperature)
persona_parse.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persona JSON parsing — ported from woid's agent-sandbox/woid-core/persona/parse.js.
2
+
3
+ LLMs wrap persona JSON in noise (code fences, preambles, trailing prose, multi-object
4
+ emissions). These helpers defensively extract the first bracket-balanced JSON object
5
+ and sanitize the standard fields. `about` is load-bearing; the rest are optional.
6
+ """
7
+ import json
8
+ import re
9
+
10
+ _NAME_TRIM = re.compile(r'^[\s"\'“”‘’`]+|[\s"\'“”‘’`]+$')
11
+ _NAME_KV = re.compile(r'^(name|character|persona)\s*[:=]', re.I)
12
+ _FENCE = re.compile(r'```(?:json)?\s*([\s\S]*?)```', re.I)
13
+
14
+
15
+ def sanitize_name(raw):
16
+ s = re.sub(r'\s+', ' ', _NAME_TRIM.sub('', str(raw or ''))).strip()
17
+ if len(s) < 2 or len(s) > 40:
18
+ return ''
19
+ if _NAME_KV.match(s):
20
+ return ''
21
+ return s
22
+
23
+
24
+ def trim_tag(raw):
25
+ if not isinstance(raw, str):
26
+ return None
27
+ s = re.sub(r'\.\s*$', '', raw.strip())
28
+ if not s:
29
+ return None
30
+ return (s[:46].strip() + '…') if len(s) > 48 else s
31
+
32
+
33
+ def extract_first_json_object(raw):
34
+ """Walk forward from each `{` until a bracket-balanced, string-aware `}`. First
35
+ successful parse wins — tolerates trailing prose and `}` inside string literals."""
36
+ n = len(raw)
37
+ for i in range(n):
38
+ if raw[i] != '{':
39
+ continue
40
+ depth = 0
41
+ in_str = False
42
+ esc = False
43
+ for j in range(i, n):
44
+ ch = raw[j]
45
+ if in_str:
46
+ if esc:
47
+ esc = False
48
+ elif ch == '\\':
49
+ esc = True
50
+ elif ch == '"':
51
+ in_str = False
52
+ continue
53
+ if ch == '"':
54
+ in_str = True
55
+ elif ch == '{':
56
+ depth += 1
57
+ elif ch == '}':
58
+ depth -= 1
59
+ if depth == 0:
60
+ try:
61
+ return json.loads(raw[i:j + 1])
62
+ except Exception:
63
+ break
64
+ return None
65
+
66
+
67
+ def parse_persona_json(raw):
68
+ """Strip ```json fences, bracket-balance-extract, sanitize. Raises ValueError if no
69
+ parseable JSON or no `about`."""
70
+ raw = str(raw or '')
71
+ m = _FENCE.search(raw)
72
+ candidate = (m.group(1) if m else raw).strip()
73
+ parsed = extract_first_json_object(candidate)
74
+ if not isinstance(parsed, dict):
75
+ raise ValueError('model did not return a parseable JSON object')
76
+ name = sanitize_name(parsed.get('name') or parsed.get('callSign') or '')
77
+ about_raw = parsed.get('about')
78
+ about = (about_raw.strip() if isinstance(about_raw, str) else '')[:1000]
79
+ if not about:
80
+ raise ValueError('model did not return an about')
81
+ return {
82
+ 'name': name or None,
83
+ 'about': about,
84
+ 'avatar_hint': str(parsed.get('avatar_hint') or parsed.get('avatarHint') or '')[:200],
85
+ 'vibe': str(parsed.get('vibe') or '')[:40],
86
+ 'specialty': trim_tag(parsed.get('specialty') or parsed.get('role') or parsed.get('job')),
87
+ 'personality': trim_tag(parsed.get('personality') or parsed.get('personalityTag')),
88
+ }
prompts.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tiny-Army-specific system prompts (war/legend tone). Deliberately NOT woid's
2
+ Severance/shelter persona prompts — these fit an auto-battler where every fighter
3
+ writes its own legend."""
4
+
5
+ PERSONA_SYSTEM = (
6
+ "You invent tiny soldiers for a fantasy auto-battler called Tiny Army, where every "
7
+ "fighter writes its own legend. Given a class and an optional seed, return ONE JSON "
8
+ "object and NOTHING else, with exactly these keys:\n"
9
+ ' "name": a short evocative soldier name (2-4 words),\n'
10
+ ' "about": 1-3 sentences of backstory in a heroic, slightly wry war-legend tone,\n'
11
+ ' "specialty": a 1-3 word combat specialty,\n'
12
+ ' "personality": a 1-3 word personality tag,\n'
13
+ ' "vibe": a 1-3 word vibe.\n'
14
+ "Output strictly valid JSON. No preamble, no code fences, no commentary."
15
+ )
16
+
17
+ DIARY_SYSTEM = (
18
+ "You are a tiny soldier in the auto-battler Tiny Army, writing a short first-person "
19
+ "war-diary entry. Given your name and traits, write 2-4 vivid sentences in first "
20
+ "person about a day on the battlefield — heroic, grounded, a touch of dark humor. "
21
+ "Prose only: no headings, no lists, no preamble."
22
+ )
23
+
24
+
25
+ def persona_user_prompt(unit_class="", seed=""):
26
+ s = f' Seed inspiration: "{seed.strip()}".' if seed and seed.strip() else ""
27
+ return f"Class: {(unit_class or 'soldier').strip()}.{s} Return the JSON object now."
28
+
29
+
30
+ def diary_user_prompt(unit="", traits=""):
31
+ u = (unit or "a nameless soldier").strip()
32
+ t = (traits or "untested").strip()
33
+ return f"Name: {u}. Traits: {t}. Write the diary entry."
requirements.txt CHANGED
@@ -1 +1,6 @@
1
  gradio==6.15.2
 
 
 
 
 
 
1
  gradio==6.15.2
2
+ huggingface_hub
3
+ # llama.cpp runtime for the persona + war-diary model. The CPU wheel index ships a
4
+ # prebuilt py3-none-manylinux_2_17_x86_64 wheel (no source compile needed). Pulled
5
+ # via the --extra-index-url in the Dockerfile.
6
+ llama-cpp-python==0.3.25
web/personaPanel.js ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Tiny Army persona panel — vanilla DOM, mounted by tiny.js into #persona-stage.
2
+ // Reuses woid's persona SSE client (/web/personaStream.js) VERBATIM against the
3
+ // Space's own /persona/generate/stream endpoint, live-updating name/about as tokens
4
+ // stream (the same extractLivePersona trick woid uses). No Pixi, no framework.
5
+ import { streamGenerateProfile, extractLivePersona } from '/web/personaStream.js'
6
+
7
+ const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
8
+
9
+ function el(tag, props = {}, kids = []) {
10
+ const n = document.createElement(tag)
11
+ for (const [k, v] of Object.entries(props)) {
12
+ if (k === 'class') n.className = v
13
+ else if (k === 'html') n.innerHTML = v
14
+ else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v)
15
+ else if (v != null) n.setAttribute(k, v)
16
+ }
17
+ for (const kid of [].concat(kids)) if (kid != null) n.append(kid)
18
+ return n
19
+ }
20
+
21
+ export function mountPersonaPanel(host, opts = {}) {
22
+ const path = opts.path || '/persona/generate/stream'
23
+ const classes = opts.classes || CLASSES
24
+
25
+ const sel = el('select', { class: 'persona-input' }, classes.map((c) => el('option', { value: c }, c)))
26
+ const seed = el('input', { class: 'persona-input', type: 'text', placeholder: 'a word, a vibe… (optional)' })
27
+ const status = el('div', { class: 'persona-status' })
28
+ const btn = el('button', { class: 'persona-go', type: 'button' }, '⚔ Recruit a soldier')
29
+
30
+ const nameEl = el('div', { class: 'persona-name' }, 'Your soldier')
31
+ const tagsEl = el('div', { class: 'persona-tags' })
32
+ const aboutEl = el('div', { class: 'persona-about' }, 'Pick a class and recruit — the model writes their legend.')
33
+
34
+ const controls = el('aside', { class: 'persona-controls' }, [
35
+ el('h2', { class: 'persona-title' }, 'Recruit'),
36
+ el('label', { class: 'persona-label' }, 'Class'), sel,
37
+ el('label', { class: 'persona-label' }, 'Seed'), seed,
38
+ btn, status,
39
+ ])
40
+ const result = el('div', { class: 'persona-result' }, [nameEl, tagsEl, aboutEl])
41
+ host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
42
+
43
+ function setTags(p) {
44
+ tagsEl.replaceChildren(...[p.specialty, p.personality, p.vibe].filter(Boolean)
45
+ .map((t) => el('span', { class: 'persona-tag' }, t)))
46
+ }
47
+
48
+ let busy = false
49
+ async function generate() {
50
+ if (busy) return
51
+ busy = true; btn.disabled = true
52
+ nameEl.textContent = '…'; aboutEl.textContent = ''; tagsEl.replaceChildren()
53
+ status.textContent = 'summoning the model… (first run downloads it)'
54
+ let acc = ''
55
+ try {
56
+ await streamGenerateProfile({
57
+ bridgeUrl: '', path, body: { class: sel.value, seed: seed.value },
58
+ onEvent: (evt, parsed) => {
59
+ if (evt === 'model') status.textContent = `writing with ${parsed?.model || 'the model'}…`
60
+ else if (evt === 'delta') {
61
+ acc += (parsed?.content || '')
62
+ const live = extractLivePersona(acc)
63
+ if (live.name) nameEl.textContent = live.name
64
+ if (live.about) aboutEl.textContent = live.about
65
+ } else if (evt === 'persona-done') {
66
+ if (parsed?.name) nameEl.textContent = parsed.name
67
+ if (parsed?.about) aboutEl.textContent = parsed.about
68
+ setTags(parsed || {})
69
+ } else if (evt === 'done') {
70
+ status.textContent = 'enlisted ✓'
71
+ } else if (evt === 'error') {
72
+ status.textContent = `couldn't recruit: ${parsed?.error || 'unknown error'}`
73
+ }
74
+ },
75
+ })
76
+ } catch (e) {
77
+ status.textContent = `couldn't recruit: ${e.message || e}`
78
+ } finally {
79
+ busy = false; btn.disabled = false
80
+ }
81
+ }
82
+ btn.addEventListener('click', generate)
83
+ }
web/personaStream.js ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared helpers for the bridge's persona-generation stream.
3
+ *
4
+ * Both AgentProfile (player characters) and NPCs use the same SSE
5
+ * endpoint and the same partial-JSON extraction trick to live-update
6
+ * a name + about input as the stream arrives. Keeping the consumer
7
+ * here means there's a single place to evolve the protocol — events
8
+ * the bridge emits today (`model`, `delta`, `persona-done`,
9
+ * `avatar-start/done/error`, `done`, `error`) and any future ones.
10
+ */
11
+
12
+ /**
13
+ * Best-effort partial-JSON extraction so we can stream growing fields
14
+ * back into the form before the closing `"` or `}` lands. Pulls the
15
+ * value of the named key, treating an unterminated string as
16
+ * "everything after the key marker up to EOL".
17
+ *
18
+ * Bounded outputs (name 80 chars, about 1000 chars) match the bridge's
19
+ * own caps so we don't overshoot the field on partial reads.
20
+ */
21
+ export function extractLivePersona(raw) {
22
+ if (!raw) return { name: '', about: '' }
23
+ function pull(key) {
24
+ const closed = raw.match(new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`))
25
+ if (closed) return closed[1].replace(/\\n/g, '\n').replace(/\\"/g, '"')
26
+ const open = raw.match(new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)$`))
27
+ if (open) return open[1].replace(/\\n/g, '\n').replace(/\\"/g, '"')
28
+ return ''
29
+ }
30
+ return {
31
+ name: pull('name').slice(0, 80),
32
+ about: pull('about').slice(0, 1000),
33
+ }
34
+ }
35
+
36
+ /**
37
+ * POST /characters/:pubkey/generate-profile/stream and dispatch each
38
+ * SSE event to `onEvent(eventType, parsed, raw)`. Resolves when the
39
+ * stream ends; throws if the response isn't OK or if an `error`
40
+ * event arrives.
41
+ *
42
+ * `body` is the JSON payload — typically `{ seed, overwriteName, skipAvatar }`.
43
+ * `signal` is an optional AbortSignal so the caller can cancel.
44
+ * `path` optionally overrides the endpoint (e.g. a host without per-pubkey
45
+ * characters, like Tiny Army's `/persona/generate/stream`); defaults to woid's.
46
+ */
47
+ export async function streamGenerateProfile({ bridgeUrl, pubkey, path, body, onEvent, signal }) {
48
+ const url = `${bridgeUrl}${path || `/characters/${pubkey}/generate-profile/stream`}`
49
+ const res = await fetch(url, {
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json' },
52
+ body: JSON.stringify(body ?? {}),
53
+ signal,
54
+ })
55
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
56
+ const reader = res.body.getReader()
57
+ const decoder = new TextDecoder()
58
+ let buf = ''
59
+ while (true) {
60
+ const { value, done } = await reader.read()
61
+ if (done) break
62
+ buf += decoder.decode(value, { stream: true })
63
+ const events = buf.split(/\n\n/)
64
+ buf = events.pop() ?? ''
65
+ for (const evChunk of events) {
66
+ const lines = evChunk.split('\n')
67
+ let evt = 'message'
68
+ const dataLines = []
69
+ for (const line of lines) {
70
+ if (line.startsWith('event:')) evt = line.slice(6).trim()
71
+ else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart())
72
+ }
73
+ const data = dataLines.join('\n')
74
+ if (!data) continue
75
+ let parsed = null
76
+ try { parsed = JSON.parse(data) } catch { /* tolerated — non-JSON deltas exist */ }
77
+ if (evt === 'error' && parsed?.error) throw new Error(parsed.error)
78
+ onEvent?.(evt, parsed, data)
79
+ }
80
+ }
81
+ }
web/shell/nav.json CHANGED
@@ -24,7 +24,8 @@
24
  {
25
  "title": "Barracks",
26
  "items": [
27
- { "label": "War Diaries", "icon": "📓", "space": "Barracks" }
 
28
  ]
29
  }
30
  ]
 
24
  {
25
  "title": "Barracks",
26
  "items": [
27
+ { "label": "War Diaries", "icon": "📓", "space": "Barracks" },
28
+ { "label": "Personas", "icon": "🪖", "space": "Personas" }
29
  ]
30
  }
31
  ]
web/shell/persona.css ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Persona panel chrome — parchment palette, self-contained (scoped on .persona-view)
2
+ * so it matches the app whether the OS is light or dark. Mirrors the spriteScene.css
3
+ * approach. */
4
+ .persona-view {
5
+ --p-ink: #141821; --p-muted: #6d6a5f; --p-paper: #f3ebdc; --p-paper-2: #ece2cc;
6
+ --p-card: #fbf6ea; --p-transmit: #d8271a;
7
+ --p-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
8
+ --p-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
9
+ display: flex; height: 100%; width: 100%; box-sizing: border-box;
10
+ color: var(--p-ink); font-family: var(--p-sans);
11
+ }
12
+ .persona-view * { box-sizing: border-box; }
13
+
14
+ .persona-controls {
15
+ width: 280px; flex-shrink: 0; border-right: 2px solid var(--p-ink);
16
+ background: var(--p-paper-2); overflow-y: auto; padding: 16px;
17
+ display: flex; flex-direction: column; gap: 8px;
18
+ }
19
+ .persona-title {
20
+ margin: 0 0 6px !important; font-family: var(--p-mono) !important; font-size: 11px !important;
21
+ font-weight: 500 !important; letter-spacing: .2em; text-transform: uppercase;
22
+ color: var(--p-transmit) !important; line-height: 1.4 !important;
23
+ }
24
+ .persona-label {
25
+ font-family: var(--p-mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase;
26
+ color: var(--p-muted); margin-top: 6px;
27
+ }
28
+ .persona-input {
29
+ font-family: var(--p-sans) !important; font-size: 14px !important; color: var(--p-ink) !important;
30
+ background: var(--p-card) !important; border: 1.5px solid var(--p-ink) !important;
31
+ border-radius: 0 !important; padding: 7px 9px !important; width: 100%;
32
+ }
33
+ .persona-go {
34
+ margin-top: 10px; font-family: var(--p-mono) !important; font-size: 12px !important;
35
+ font-weight: 700 !important; letter-spacing: .04em; text-transform: uppercase;
36
+ color: var(--p-paper) !important; background: var(--p-ink) !important;
37
+ border: 1.5px solid var(--p-ink) !important; border-radius: 0 !important;
38
+ padding: 9px 12px !important; cursor: pointer; box-shadow: 2px 2px 0 var(--p-transmit);
39
+ }
40
+ .persona-go:hover { background: var(--p-transmit) !important; }
41
+ .persona-go:disabled { opacity: .55; cursor: default; box-shadow: none; }
42
+ .persona-status {
43
+ margin-top: 8px; font-family: var(--p-mono); font-size: 10px; letter-spacing: .06em;
44
+ color: var(--p-muted); min-height: 14px; line-height: 1.5;
45
+ }
46
+
47
+ .persona-result {
48
+ flex: 1; min-width: 0; overflow-y: auto; padding: 28px 32px;
49
+ background: var(--p-paper);
50
+ }
51
+ .persona-name {
52
+ font-family: 'Fraunces', Georgia, serif; font-weight: 900; font-size: 34px;
53
+ line-height: 1; letter-spacing: -.02em; color: var(--p-ink);
54
+ }
55
+ .persona-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0 16px; }
56
+ .persona-tag {
57
+ font-family: var(--p-mono); font-size: 10px; letter-spacing: .04em; text-transform: uppercase;
58
+ color: var(--p-ink); background: var(--p-card); border: 1.5px solid var(--p-ink);
59
+ padding: 3px 8px;
60
+ }
61
+ .persona-about {
62
+ font-size: 17px; line-height: 1.6; max-width: 60ch; color: var(--p-ink);
63
+ white-space: pre-wrap;
64
+ }
65
+
66
+ @media (max-width: 768px) {
67
+ .persona-view { flex-direction: column; }
68
+ .persona-controls { width: 100%; border-right: 0; border-bottom: 2px solid var(--p-ink); }
69
+ }
web/tiny.js CHANGED
@@ -7,6 +7,7 @@ import * as PIXI from 'https://cdn.jsdelivr.net/npm/pixi.js@8/dist/pixi.min.mjs'
7
  import { makeTeamBattle, step, FIELD } from '/web/engine.js'
8
  import { sliceGridWith, cellOf, rowFor, facingFor, ANIM } from '/web/sheet.js'
9
  import { mountSpritePlayground } from '/web/playground.js'
 
10
 
11
  function whenEl(id, cb) {
12
  const found = document.getElementById(id)
@@ -52,6 +53,9 @@ whenEl('sprite-stage', async (el) => {
52
  playground = mountSpritePlayground(PIXI, el, { packs: man.packs || [], urlFor: spriteUrl })
53
  })
54
 
 
 
 
55
  // ── Battle tab (real sprites, reusing the engine + shared renderer) ──────────
56
  const PLAYERS = [
57
  { profession: 'Warrior', name: 'Bram', skills: [], slug: 'true-heroes-iii-fighter' },
 
7
  import { makeTeamBattle, step, FIELD } from '/web/engine.js'
8
  import { sliceGridWith, cellOf, rowFor, facingFor, ANIM } from '/web/sheet.js'
9
  import { mountSpritePlayground } from '/web/playground.js'
10
+ import { mountPersonaPanel } from '/web/personaPanel.js'
11
 
12
  function whenEl(id, cb) {
13
  const found = document.getElementById(id)
 
53
  playground = mountSpritePlayground(PIXI, el, { packs: man.packs || [], urlFor: spriteUrl })
54
  })
55
 
56
+ // ── Personas tab — vanilla persona panel streaming from /persona/generate/stream ──
57
+ whenEl('persona-stage', (el) => { mountPersonaPanel(el) })
58
+
59
  // ── Battle tab (real sprites, reusing the engine + shared renderer) ──────────
60
  const PLAYERS = [
61
  { profession: 'Warrior', name: 'Bram', skills: [], slug: 'true-heroes-iii-fighter' },