Spaces:
Running
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>
- Dockerfile +3 -1
- app.py +116 -17
- llm.py +116 -0
- persona_parse.py +88 -0
- prompts.py +33 -0
- requirements.txt +5 -0
- web/personaPanel.js +83 -0
- web/personaStream.js +81 -0
- web/shell/nav.json +2 -1
- web/shell/persona.css +69 -0
- web/tiny.js +4 -0
|
@@ -13,7 +13,9 @@ USER user
|
|
| 13 |
WORKDIR /home/user/app
|
| 14 |
|
| 15 |
COPY --chown=user requirements.txt .
|
| 16 |
-
|
|
|
|
|
|
|
| 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 |
|
|
@@ -1,20 +1,35 @@
|
|
| 1 |
"""Tiny Army — HF Space, a Gradio Blocks app.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 136 |
-
"""
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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)
|
|
@@ -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 |
+
}
|
|
@@ -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."
|
|
@@ -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
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
]
|
|
@@ -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 |
+
}
|
|
@@ -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' },
|