Spaces:
Sleeping
Sleeping
Hermes Bot commited on
feat: LLM-driven infinite adventure loop
Browse files- Single generate_llm_turn() call produces story + choices + health delta
- Infinite gameplay until health <= 0, no fixed step cap
- Procedural fallback preserved during cooldown/failure
- .gitignore +10 -0
- PRD.md +276 -0
- README.md +137 -7
- __init__.py +1 -0
- app.py +550 -0
- requirements.txt +18 -0
- shared/cedar_copper_tokens.py +151 -0
- shared/inference_client.py +179 -0
- static/index.html +93 -0
- static/main.js +296 -0
- static/style.css +500 -0
.gitignore
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AINISH Coder Tooling Distribution - Git Ignore Patterns
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
venv/
|
| 5 |
+
.venv/
|
| 6 |
+
.DS_Store
|
| 7 |
+
*.log
|
| 8 |
+
tmp/
|
| 9 |
+
models/
|
| 10 |
+
*.gguf
|
PRD.md
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DOX framework
|
| 2 |
+
|
| 3 |
+
- DOX is a highly performant `llms.txt` hierarchy installed here
|
| 4 |
+
- Agent must follow DOX instructions across any edits
|
| 5 |
+
|
| 6 |
+
## Purpose
|
| 7 |
+
|
| 8 |
+
- **Name:** Build Small Hackathon 2026 — Team nbiish
|
| 9 |
+
- **Version:** 0.5.0 — Cedar-Copper Edition (HF Inference API)
|
| 10 |
+
- **Aesthetic:** Cedar-copper visual language — sky-to-sunrise palette (water-blue → cedar → copper → sun-amber → birch-cream), biophilic motifs, sky-to-water gradient banners. Shared CSS variables live in `shared/cedar_copper_tokens.py`.
|
| 11 |
+
- **Purpose:** Win prizes across tracks, badges, and sponsor categories by building delightful, useful AI apps that run locally.
|
| 12 |
+
- **UX:** Gradio web apps (gr.Blocks + mount_gradio_app custom frontends), hosted on HF Spaces.
|
| 13 |
+
- **Hack window:** June 5-15, 2026. Deadline: June 15.
|
| 14 |
+
|
| 15 |
+
> This file is the master PRD and stays English-only. Per-project UIs and READMEs may use additional stylistic content for their own artifacts.
|
| 16 |
+
|
| 17 |
+
## Core Contract
|
| 18 |
+
|
| 19 |
+
- `llms.txt` files are binding work contracts for their subtrees
|
| 20 |
+
- Work products, source materials, instructions, records, assets, and durable docs must stay understandable from the nearest applicable `llms.txt` plus every parent `llms.txt` above it
|
| 21 |
+
|
| 22 |
+
## Read Before Editing
|
| 23 |
+
|
| 24 |
+
1. Read the root `llms.txt`
|
| 25 |
+
2. Identify every file or folder you expect to touch
|
| 26 |
+
3. Walk from the repository root to each target path
|
| 27 |
+
4. Read every `llms.txt` found along each route
|
| 28 |
+
5. If a parent `llms.txt` lists a child `llms.txt` whose scope contains the path, read that child and continue from there
|
| 29 |
+
6. Use the nearest `llms.txt` as the local contract and parent docs for repo-wide rules
|
| 30 |
+
7. If docs conflict, the closer doc controls local work details, but no child doc may weaken DOX
|
| 31 |
+
|
| 32 |
+
Do not rely on memory. Re-read the applicable DOX chain in the current session before editing.
|
| 33 |
+
|
| 34 |
+
## Local Contracts
|
| 35 |
+
|
| 36 |
+
### Naming & Comments
|
| 37 |
+
|
| 38 |
+
- Descriptive project names: CritterCalm, FocusFriend, TinyBard
|
| 39 |
+
- Docstrings on all public functions. Comments on non-obvious logic.
|
| 40 |
+
|
| 41 |
+
### Always
|
| 42 |
+
|
| 43 |
+
- Models ≤ 32B total params per project
|
| 44 |
+
- Gradio app hosted as HF Space
|
| 45 |
+
- Local-first (no cloud APIs = Off the Grid badge)
|
| 46 |
+
- GGUF quantized models for local inference
|
| 47 |
+
- Python 3.10+ with pinned requirements
|
| 48 |
+
- Cedar-copper aesthetic consistency across all UIs (palette tokens in `shared/cedar_copper_tokens.py`)
|
| 49 |
+
|
| 50 |
+
### Never
|
| 51 |
+
|
| 52 |
+
- Cloud API calls in production path
|
| 53 |
+
- Hardcoded secrets or API keys
|
| 54 |
+
- Models > 32B params
|
| 55 |
+
- Default Gradio look without customization attempt
|
| 56 |
+
|
| 57 |
+
### If
|
| 58 |
+
|
| 59 |
+
- If custom frontend is feasible → use `mount_gradio_app` for Off-Brand badge
|
| 60 |
+
- If model ≤ 4B → tag Tiny Titan eligible
|
| 61 |
+
- If using llama.cpp runtime → tag Llama Champion
|
| 62 |
+
- If fine-tuning is done → publish model to HF Hub
|
| 63 |
+
|
| 64 |
+
## Infrastructure
|
| 65 |
+
|
| 66 |
+
### Gradio 6.0 + MCP Server
|
| 67 |
+
|
| 68 |
+
- `gradio.Server` is **NOT** in Gradio 6.0 stable. Use `mount_gradio_app(fastapi_app, blocks, path="/gradio")` instead.
|
| 69 |
+
- MCP server mode: `demo.launch(mcp_server=True)` or `GRADIO_MCP_SERVER=true` env var.
|
| 70 |
+
- Custom frontends: Serve static HTML/CSS/JS via FastAPI, mount Gradio at `/gradio` for API + MCP.
|
| 71 |
+
- `@gradio/client` CDN: `https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js` (ES module, use `type="module"`).
|
| 72 |
+
- Theme parameters: `css`, `head`, `theme` moved from `gr.Blocks(...)` to `app.launch(...)` in Gradio 6.0.
|
| 73 |
+
- Chatbot API: Gradio 6.0 requires `{"role": "user|assistant", "content": "..."}` dicts (not tuples).
|
| 74 |
+
|
| 75 |
+
### HF Agents CLI
|
| 76 |
+
|
| 77 |
+
- `hf` CLI is installed (v1.18.0). See `skill://hf-cli` for full command reference.
|
| 78 |
+
- Install expert skills: `hf skills add --global` or `hf skills add --claude --global`.
|
| 79 |
+
- Spaces managed via: `hf repos create <name> --type space --space-sdk gradio --public`.
|
| 80 |
+
- Deploy: `git remote add hf https://huggingface.co/spaces/<user>/<space>` then `git push hf main`.
|
| 81 |
+
- HF README metadata: `colorTo` must be one of `[red, yellow, green, blue, indigo, purple, pink, gray]` (no `emerald`/`amber`).
|
| 82 |
+
- HF README metadata: `emoji` must match `/\p{Extended_Pictographic}/u` — only the standard emoji block is allowed; decorative Unicode glyphs (solar/astrological/typographic symbols) fail validation. Use a real emoji.
|
| 83 |
+
|
| 84 |
+
### Inference Architecture (v0.5+)
|
| 85 |
+
|
| 86 |
+
- **All LLM inference** is now via the **Hugging Face Inference API** (serverless). No more local GGUF, no `llama-cpp-python` compile step.
|
| 87 |
+
- Shared module: `shared/inference_client.py` provides `cooldown_status()`, `cooldown_active()`, `generate()`, and `chat_messages()`.
|
| 88 |
+
- Default model: `Qwen/Qwen2.5-1.5B-Instruct` (free tier, fast, well-suited to chat). Override via `INFERENCE_MODEL`.
|
| 89 |
+
- Per-project model override: `TINYBARD_MODEL`, `FOCUSFRIEND_MODEL`, `CRITTERCALM_MODEL`.
|
| 90 |
+
- **Cooldowns** enforce a per-project minimum gap between inference calls (protects HF/Modal credit budget):
|
| 91 |
+
- `tinybard`: 6s
|
| 92 |
+
- `focusfriend`: 10s
|
| 93 |
+
- `crittercalm`: 12s
|
| 94 |
+
- Override via `TINYBARD_COOLDOWN_SECONDS`, etc., or global `INFERENCE_COOLDOWN_SECONDS`.
|
| 95 |
+
- **Always-fallback:** every LLM call falls back to procedural / template output if inference fails or is in cooldown. No LLM call ever blocks the UX.
|
| 96 |
+
- HF Spaces are the dev/test environment — iterate live at `huggingface.co/spaces/nbiish/{tinybard,focusfriend,crittercalm}` rather than localhost.
|
| 97 |
+
|
| 98 |
+
### Local Test Environment
|
| 99 |
+
|
| 100 |
+
- Python: miniconda3 (Python 3.12)
|
| 101 |
+
- Gradio: 6.0.0
|
| 102 |
+
- `huggingface_hub` (for Inference API client)
|
| 103 |
+
- Inference is serverless — no local model files needed unless you opt in to local mode
|
| 104 |
+
|
| 105 |
+
### Local Servers (optional)
|
| 106 |
+
|
| 107 |
+
Local servers were used during v0.4 development for visual inspection. v0.5+ prefers iterating on the live HF Spaces (which use your HF/Modal compute credits). Local servers can still be run for dev:
|
| 108 |
+
|
| 109 |
+
| Project | URL | Stack | HF Space |
|
| 110 |
+
|---|---|---|---|
|
| 111 |
+
| TinyBard | http://localhost:7861/ | FastAPI + Gradio Blocks | nbiish/tinybard |
|
| 112 |
+
| FocusFriend | http://localhost:7862/ | Gradio 6.0 | nbiish/focusfriend |
|
| 113 |
+
| CritterCalm | http://localhost:7863/ | Gradio 6.0 | nbiish/crittercalm |
|
| 114 |
+
|
| 115 |
+
## Projects
|
| 116 |
+
|
| 117 |
+
### 1. CritterCalm (Backyard AI)
|
| 118 |
+
|
| 119 |
+
- **Status:** Code complete. Deployed. HF Inference API + cooldowns wired for script generation. OmniVoice voice cloning still requires local install.
|
| 120 |
+
- **Stack:** OmniVoice (0.6B, local optional) + Kokoro TTS (82M, local optional) + Qwen2.5-7B (default) via HF Inference API
|
| 121 |
+
- **Badges:** Off the Grid, Well-Tuned (TBD), Field Notes, Off-Brand
|
| 122 |
+
- **GitHub:** github.com/nbiish/crittercalm
|
| 123 |
+
- **HF Space:** huggingface.co/spaces/nbiish/crittercalm
|
| 124 |
+
- **Standalone repo:** /Volumes/1tb-sandisk/code-external/crittercalm-repo
|
| 125 |
+
|
| 126 |
+
### 2. FocusFriend (Thousand Token Wood)
|
| 127 |
+
|
| 128 |
+
- **Status:** Code complete. Deployed. HF Inference API + cooldowns wired. Gradio 6 Chatbot dict-format fixed.
|
| 129 |
+
- **Stack:** Qwen2.5-7B (default) via HF Inference API
|
| 130 |
+
- **Badges:** Off-Brand (sun-amber custom theme), Field Notes, Cooldowns badge
|
| 131 |
+
- **GitHub:** github.com/nbiish/focusfriend
|
| 132 |
+
- **HF Space:** huggingface.co/spaces/nbiish/focusfriend
|
| 133 |
+
- **Standalone repo:** /Volumes/1tb-sandisk/code-external/focusfriend-repo
|
| 134 |
+
|
| 135 |
+
### 3. TinyBard (Thousand Token Wood + Tiny Titan + Llama Champion)
|
| 136 |
+
|
| 137 |
+
- **Status:** Code complete. Deployed. HF Inference API + cooldowns wired. Local test verified (procedural fallback + cooldown UI).
|
| 138 |
+
- **Concept:** ≤4B LLM generates 5-min interactive text adventures in a CRT terminal aesthetic.
|
| 139 |
+
- **Stack:** Qwen2.5-1.5B (default) via HF Inference API + procedural fallback engine
|
| 140 |
+
|
| 141 |
+
## Work Guidance
|
| 142 |
+
|
| 143 |
+
### TODO
|
| 144 |
+
|
| 145 |
+
> Keep tasks atomic and testable.
|
| 146 |
+
|
| 147 |
+
#### In Progress
|
| 148 |
+
|
| 149 |
+
- [ ] Test CritterCalm voice cloning pipeline end-to-end
|
| 150 |
+
- [ ] Test FocusFriend all 4 modes (Chat, Focus, Breathe, Meditate) with real model
|
| 151 |
+
- [ ] Record demo videos (2-3 min each)
|
| 152 |
+
- [ ] Post to social media
|
| 153 |
+
- [ ] Write Field Notes blog posts (3 — one per project)
|
| 154 |
+
- [ ] Share agent traces to HF Hub (Sharing is Caring badge)
|
| 155 |
+
|
| 156 |
+
#### Completed
|
| 157 |
+
|
| 158 |
+
- [x] CritterCalm v1 code complete (11 files) — Cedar-copper UI
|
| 159 |
+
- [x] FocusFriend v1 code complete (16 files) — Cedar-copper UI + Gradio 6 dict Chatbot
|
| 160 |
+
- [x] TinyBard v1 code complete (8 files) — LLM + procedural fallback, CRT UI, clean FastAPI JSON
|
| 161 |
+
- [x] GitHub repos created (nbiish/crittercalm, nbiish/focusfriend, nbiish/tinybard)
|
| 162 |
+
- [x] HF Spaces created and deployed (all 3)
|
| 163 |
+
- [x] Monorepo structure with projects/ directory + shared/ aesthetic module
|
| 164 |
+
- [x] INTELLIGENCE.md — full hackathon landscape analysis
|
| 165 |
+
- [x] SUBMISSION_DRAFTS.md — social posts + Field Notes drafts
|
| 166 |
+
- [x] HF CLI installed + skills configured (`hf skills add --global`)
|
| 167 |
+
- [x] llama-cpp-python installed (conda-forge v0.3.16) — for reference; v0.5+ uses HF Inference API
|
| 168 |
+
- [x] Local verification: all 3 apps run on ports 7861/7862/7863
|
| 169 |
+
- [x] TinyBard end-to-end game loop verified (start → choose → next scene)
|
| 170 |
+
- [x] FocusFriend chat verified (user message → Pip reply)
|
| 171 |
+
- [x] CritterCalm UI navigation verified (all 3 tabs render)
|
| 172 |
+
- [x] **v0.5: HF Inference API wired into all 3 apps** (no local GGUF, no build step)
|
| 173 |
+
- [x] **v0.5: Cooldown system** in `shared/inference_client.py` to protect HF/Modal credit budget
|
| 174 |
+
- [x] **v0.5: TinyBard local test** — procedural fallback works when no HF_TOKEN; cooldown UI shows in footer
|
| 175 |
+
|
| 176 |
+
### Short-term Goals
|
| 177 |
+
|
| 178 |
+
- Iterate on the live HF Spaces (nbiish/tinybard, nbiish/focusfriend, nbiish/crittercalm)
|
| 179 |
+
- Set HF_TOKEN + INFERENCE_MODEL Space secrets to enable real LLM-backed adventures
|
| 180 |
+
- Record demo videos and post to social media
|
| 181 |
+
- Write and publish Field Notes blog posts
|
| 182 |
+
- Share agent traces for Sharing is Caring badge
|
| 183 |
+
- Polish UIs for demo appeal
|
| 184 |
+
|
| 185 |
+
## Update After Editing
|
| 186 |
+
|
| 187 |
+
Every meaningful change requires a DOX pass before the task is done.
|
| 188 |
+
|
| 189 |
+
Update the closest owning `llms.txt` when a change affects:
|
| 190 |
+
|
| 191 |
+
- purpose, scope, ownership, or responsibilities
|
| 192 |
+
- durable structure, contracts, workflows, or operating rules
|
| 193 |
+
- required inputs, outputs, permissions, constraints, side effects, or artifacts
|
| 194 |
+
- user preferences about behavior, communication, process, organization, or quality
|
| 195 |
+
- `llms.txt` creation, deletion, move, rename, or index contents
|
| 196 |
+
|
| 197 |
+
Update parent docs when parent-level structure, ownership, workflow, or child index changes. Update child docs when parent changes alter local rules. Remove stale or contradictory text immediately. Small edits that do not change behavior or contracts may leave docs unchanged, but the DOX pass still must happen.
|
| 198 |
+
|
| 199 |
+
## Hierarchy
|
| 200 |
+
|
| 201 |
+
- Root `llms.txt` is the DOX rail: project-wide instructions, global preferences, durable workflow rules, and the top-level Child DOX Index
|
| 202 |
+
- Child `llms.txt` files own domain-specific instructions and their own Child DOX Index
|
| 203 |
+
- Each parent explains what its direct children cover and what stays owned by the parent
|
| 204 |
+
- The closer a doc is to the work, the more specific and practical it must be
|
| 205 |
+
|
| 206 |
+
## Child Doc Shape
|
| 207 |
+
|
| 208 |
+
- Create a child `llms.txt` when a folder becomes a durable boundary with its own purpose, rules, responsibilities, workflow, materials, or quality standards
|
| 209 |
+
- Work Guidance must reflect the current standards of the project or user instructions; if there are no specific standards or instructions yet, leave it empty
|
| 210 |
+
- Verification must reflect an existing check; if no verification framework exists yet, leave it empty and update it when one exists
|
| 211 |
+
|
| 212 |
+
Default section order:
|
| 213 |
+
- Purpose
|
| 214 |
+
- Ownership
|
| 215 |
+
- Local Contracts
|
| 216 |
+
- Work Guidance
|
| 217 |
+
- Verification
|
| 218 |
+
- Child DOX Index
|
| 219 |
+
|
| 220 |
+
## Style
|
| 221 |
+
|
| 222 |
+
- Keep docs concise, current, and operational
|
| 223 |
+
- Document stable contracts, not diary entries
|
| 224 |
+
- Put broad rules in parent docs and concrete details in child docs
|
| 225 |
+
- Prefer direct bullets with explicit names
|
| 226 |
+
- Do not duplicate rules across many files unless each scope needs a local version
|
| 227 |
+
- Delete stale notes instead of explaining history
|
| 228 |
+
- Trim obvious statements, repeated rules, misplaced detail, and warnings for risks that no longer exist
|
| 229 |
+
|
| 230 |
+
## Closeout
|
| 231 |
+
|
| 232 |
+
1. Re-check changed paths against the DOX chain
|
| 233 |
+
2. Update nearest owning docs and any affected parents or children
|
| 234 |
+
3. Refresh every affected Child DOX Index
|
| 235 |
+
4. Remove stale or contradictory text
|
| 236 |
+
5. Run existing verification when relevant
|
| 237 |
+
6. Report any docs intentionally left unchanged and why
|
| 238 |
+
|
| 239 |
+
## Verification
|
| 240 |
+
|
| 241 |
+
Run local servers to verify apps:
|
| 242 |
+
- TinyBard: `cd projects/tinybard && python app.py` → http://localhost:7861/
|
| 243 |
+
- FocusFriend: `cd projects/focusfriend && python app.py` → http://localhost:7862/
|
| 244 |
+
- CritterCalm: `cd projects/crittercalm && python app.py` → http://localhost:7863/
|
| 245 |
+
|
| 246 |
+
## Reference
|
| 247 |
+
|
| 248 |
+
- CritterCalm: projects/crittercalm/ + github.com/nbiish/crittercalm
|
| 249 |
+
- FocusFriend: projects/focusfriend/ + github.com/nbiish/focusfriend
|
| 250 |
+
- TinyBard: projects/tinybard/ + github.com/nbiish/tinybard
|
| 251 |
+
- Aesthetic module: shared/cedar_copper_tokens.py
|
| 252 |
+
- Inference client: shared/inference_client.py
|
| 253 |
+
- ML Intern: github.com/huggingface/ml-intern
|
| 254 |
+
- HF Agents CLI: huggingface.co/docs/hub/en/agents-cli
|
| 255 |
+
- Gradio MCP: gradio.app/guides/model-context-protocol
|
| 256 |
+
|
| 257 |
+
## User Preferences
|
| 258 |
+
|
| 259 |
+
When the user requests a durable behavior change, record it here or in the relevant child `llms.txt`
|
| 260 |
+
|
| 261 |
+
## Child DOX Index
|
| 262 |
+
|
| 263 |
+
### projects/crittercalm/
|
| 264 |
+
- Backyard AI track — CritterCalm wildlife sound identifier
|
| 265 |
+
- Stack: OmniVoice + Dolphin-X1-8B + Kokoro TTS
|
| 266 |
+
|
| 267 |
+
### projects/focusfriend/
|
| 268 |
+
- Thousand Token Wood track — FocusFriend productivity assistant
|
| 269 |
+
- Stack: Gemma 4 12B via llama-cpp-python
|
| 270 |
+
|
| 271 |
+
### projects/tinybard/
|
| 272 |
+
- Thousand Token Wood + Tiny Titan + Llama Champion tracks
|
| 273 |
+
- Stack: VibeThinker 1.5B + procedural fallback
|
| 274 |
+
|
| 275 |
+
### shared/
|
| 276 |
+
- Cedar-copper aesthetic tokens and shared utilities
|
README.md
CHANGED
|
@@ -1,13 +1,143 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: ᐴ TinyBard ᔔ
|
| 3 |
+
emoji: ☀️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.0.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: apache-2.0
|
| 11 |
+
tags:
|
| 12 |
+
- text-adventure
|
| 13 |
+
- interactive-fiction
|
| 14 |
+
- thousand-token-wood
|
| 15 |
+
- build-small-hackathon
|
| 16 |
+
- tiny-titan
|
| 17 |
+
- off-brand
|
| 18 |
+
- mcp-server
|
| 19 |
+
- anishinaabe
|
| 20 |
+
- solarpunk
|
| 21 |
+
- inference-api
|
| 22 |
+
- cooldowns
|
| 23 |
---
|
| 24 |
|
| 25 |
+
# ◈──◆──◇ ᐴ TINYBARD ᔔ AADIZOOKAAN-AKINOOMAAGEWIN / STORY-TELLING ENGINE ◇──◆──◈
|
| 26 |
+
|
| 27 |
+
> **A small LLM fires five-minute interactive text adventures in a cedar-and-copper CRT terminal.**
|
| 28 |
+
>
|
| 29 |
+
> ᐴ The land remembers the stories. ᔔ ☼ ☘ ≈
|
| 30 |
+
|
| 31 |
+
TinyBard uses FastAPI + `mount_gradio_app` (Gradio 6.0) with a fully custom HTML/CSS/JS frontend, **MCP server mode** enabled, and an **HF Inference API** backend. Every adventure is procedurally generated — rooms, NPCs, items, and branching narratives on the fly.
|
| 32 |
+
|
| 33 |
+
## ◆ GASHKITOONAN / CAPABILITIES ◈
|
| 34 |
+
|
| 35 |
+
- **◇ Dynamic Adventures ◇** — LLM generates unique story beats for every playthrough
|
| 36 |
+
- **◇ Three Aadizookaanan / Genres ◇** — Aadizookaan (Fantasy), Ish piming (Sci-Fi), Mashkodewaazibi (Cyberpunk)
|
| 37 |
+
- **◇ Misko-Aki / CRT Terminal ◇** — Cedar-copper cabinet, sun-amber phosphor, frost-on-glass scanlines
|
| 38 |
+
- **◇ MCP Kinoomaagewinan / Tools ◇** — `start_game` and `make_choice` exposed as MCP tools
|
| 39 |
+
- **◇ Giiwenaabik / Inference API ◇** — Serverless HF Inference API; no local GGUF, no build step
|
| 40 |
+
- **◇ Asabiikesiwin / Cooldown ◇** — 6s default between inference calls to protect your credit budget
|
| 41 |
+
- **◇ Bmaad-ziibi / Procedural Fallback ◇** — Full engine works without the LLM
|
| 42 |
+
- **◇ Anishinaabe-Solarpunk ◇** — Sky-to-sunrise palette, syllabic framings, biophilic motifs
|
| 43 |
+
|
| 44 |
+
## ☼ NITAM-AABAJICHIGANAN / PREREQUISITES ◈
|
| 45 |
+
|
| 46 |
+
- Python 3.10+
|
| 47 |
+
- A Hugging Face token (for the Inference API; many small models work anonymously)
|
| 48 |
+
- ~100MB disk, ~256MB RAM — the model is serverless, not local
|
| 49 |
+
|
| 50 |
+
## ◇ AABAJITOOWINAN / INSTALLATION ◈
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
git clone https://github.com/nbiish/tinybard.git
|
| 54 |
+
cd tinybard
|
| 55 |
+
pip install -r requirements.txt
|
| 56 |
+
|
| 57 |
+
# Optional: pick a model (default: Qwen/Qwen2.5-1.5B-Instruct — small + fast + free)
|
| 58 |
+
export INFERENCE_MODEL="Qwen/Qwen2.5-1.5B-Instruct"
|
| 59 |
+
# Or for the originally-intended VibeThinker 1.5B:
|
| 60 |
+
# export INFERENCE_MODEL="mradermacher/VibeThinker-1.5B-GGUF"
|
| 61 |
+
|
| 62 |
+
# Optional: set the HF token (anonymous works for many models)
|
| 63 |
+
export HF_TOKEN="hf_..."
|
| 64 |
+
|
| 65 |
+
# Optional: tune the cooldown
|
| 66 |
+
export TINYBARD_COOLDOWN_SECONDS=6
|
| 67 |
+
|
| 68 |
+
python app.py
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
Then open <http://localhost:7860/>.
|
| 72 |
+
|
| 73 |
+
## ◈ WAABANDA'IWEWIN / EXAMPLES ◇
|
| 74 |
+
|
| 75 |
+
```text
|
| 76 |
+
╭─────────────────────────────────────╮
|
| 77 |
+
│ ᐴ AADIZOOKAAN / FANTASY ᔔ │
|
| 78 |
+
╰─────────────────────────────────────╯
|
| 79 |
+
|
| 80 |
+
You stand before the gates of the Whisperwood. The ancient trees
|
| 81 |
+
hum with a faint violet energy...
|
| 82 |
+
|
| 83 |
+
[ Take the golden key ] [ Drink the mossy vial ] [ Press forward ]
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
## ☼ NAANAAGADAWENINDIZOWIN / VERIFICATION ◈
|
| 87 |
+
|
| 88 |
+
```bash
|
| 89 |
+
curl -X POST http://localhost:7860/api/game/start \
|
| 90 |
+
-H "Content-Type: application/json" \
|
| 91 |
+
-d '{"genre": "fantasy"}'
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
Returns clean JSON: `{"story", "choices", "health", "step", "game_over", "history"}`.
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
curl http://localhost:7860/api/model_status
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
Returns: `{"model": "...", "cooldown": {"active": bool, "remaining_seconds": float, "window_seconds": float}}`.
|
| 101 |
+
|
| 102 |
+
## ◈ MODEL ◇
|
| 103 |
+
|
| 104 |
+
| Model (default) | Size | Purpose | License |
|
| 105 |
+
|---|---|---|---|
|
| 106 |
+
| Qwen2.5-1.5B-Instruct | 1.5B | Interactive story generation | Apache 2.0 |
|
| 107 |
+
| VibeThinker 1.5B | 1.5B | Alternative — also tiny | Apache 2.0 |
|
| 108 |
+
|
| 109 |
+
Override `INFERENCE_MODEL` to any model that supports `chat_completion` on the HF Inference API. The 1.5B defaults fit the **Tiny Titan** badge.
|
| 110 |
+
|
| 111 |
+
## ◇ MCP KINOOMAAGEWINAN / TOOLS ◈
|
| 112 |
+
|
| 113 |
+
TinyBard runs with `mcp_server=True`, exposing these tools (also available as FastAPI endpoints):
|
| 114 |
+
|
| 115 |
+
- **`/api/game/start`** (POST `{"genre": "fantasy|scifi|cyberpunk"}`) — Start an adventure
|
| 116 |
+
- **`/api/game/choice`** (POST `{choice, genre, step, health, history}`) — Submit a player choice
|
| 117 |
+
- **`/api/model_status`** (GET) — Check the inference model + cooldown state
|
| 118 |
+
|
| 119 |
+
Connect from any MCP client (Claude Desktop, Cursor, etc.) to the SSE endpoint at `/gradio/gradio_api/mcp/`.
|
| 120 |
+
|
| 121 |
+
## ◇ GIIZHIITAA / BADGE TARGETS ◇
|
| 122 |
+
|
| 123 |
+
- **◆ Tiny Titan** — Model ≤ 1.5B (well under 4B limit)
|
| 124 |
+
- **◆ Off-Brand** — Fully custom FastAPI+Gradio frontend
|
| 125 |
+
- **◆ Field Notes** — Blog post about tiny model interactive fiction
|
| 126 |
+
|
| 127 |
+
## ☼ GANAWENDAAGWAD / SECURITY ◈
|
| 128 |
+
|
| 129 |
+
PQC standard for any future API keys via the `pqc-secrets` skill (ML-KEM-768 + AES-256-GCM). At present, only the HF token is in flight (read from env var, never written to disk).
|
| 130 |
+
|
| 131 |
+
## ◇ AABAAJICHIGANAN / COOLDOWNS ◈
|
| 132 |
+
|
| 133 |
+
The `shared/inference_client.py` module enforces per-project cooldowns. Cooldown protects your HF/Modal credit budget from runaway re-rolls. Defaults:
|
| 134 |
+
|
| 135 |
+
- `tinybard`: 6s
|
| 136 |
+
- `focusfriend`: 10s
|
| 137 |
+
- `crittercalm`: 12s
|
| 138 |
+
|
| 139 |
+
Override per project via Space env vars (`TINYBARD_COOLDOWN_SECONDS`, etc.).
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
◈──◆──◇ ☼ TinyBard v1.1 · Cedar Edition · Anishinaabe Solarpunk · Inference API ◇──◆──◈
|
__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""TinyBard — Micro Interactive Text Adventure Generator."""
|
app.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
ᐴ TinyBard ᔔ — Aanishinaabe Mikinaak-Aki / Fire-Fly Storyteller
|
| 4 |
+
==================================================================
|
| 5 |
+
Custom FastAPI app with Gradio Blocks mounted for MCP tool integration.
|
| 6 |
+
Cedar-and-copper CRT terminal frontend served as static HTML.
|
| 7 |
+
|
| 8 |
+
Aesthetic: Anishinaabe Solarpunk — sky-to-sunrise palette, syllabic framings,
|
| 9 |
+
biophilic motifs, solarpunk hope.
|
| 10 |
+
|
| 11 |
+
Targets: Thousand Token Wood + Tiny Titan + Llama Champion tracks.
|
| 12 |
+
Badges: Llama Champion, Tiny Titan, Off-Brand (custom frontend),
|
| 13 |
+
Off the Grid, Field Notes.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
import json
|
| 18 |
+
import random
|
| 19 |
+
import logging
|
| 20 |
+
import re
|
| 21 |
+
import sys
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import Dict, List, Optional
|
| 24 |
+
import threading
|
| 25 |
+
|
| 26 |
+
import gradio as gr
|
| 27 |
+
from fastapi import FastAPI
|
| 28 |
+
from fastapi.responses import HTMLResponse
|
| 29 |
+
from fastapi.staticfiles import StaticFiles
|
| 30 |
+
from gradio import mount_gradio_app
|
| 31 |
+
|
| 32 |
+
# Inference client with cooldown (no local GGUF, no llama-cpp-python build!)
|
| 33 |
+
# Path layout: monorepo/shared/inference_client.py — go up two parents from this file.
|
| 34 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
| 35 |
+
from shared.inference_client import (
|
| 36 |
+
InferenceResult,
|
| 37 |
+
cooldown_status,
|
| 38 |
+
cooldown_remaining,
|
| 39 |
+
cooldown_active,
|
| 40 |
+
generate as inference_generate,
|
| 41 |
+
chat_messages,
|
| 42 |
+
INFERENCE_MODEL,
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
logging.basicConfig(
|
| 46 |
+
level=logging.INFO,
|
| 47 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 48 |
+
)
|
| 49 |
+
log = logging.getLogger("tinybard")
|
| 50 |
+
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
# Config & Paths
|
| 53 |
+
# ---------------------------------------------------------------------------
|
| 54 |
+
BASE_DIR = Path(__file__).parent
|
| 55 |
+
STATIC_DIR = BASE_DIR / "static"
|
| 56 |
+
|
| 57 |
+
# Use HF Inference API (VibeThinker 1.5B by default — small, fast, free tier).
|
| 58 |
+
# Override via Space env var: INFERENCE_MODEL.
|
| 59 |
+
# Cooldown enforced in shared.inference_client.
|
| 60 |
+
TINYBARD_MODEL = os.environ.get("TINYBARD_MODEL", INFERENCE_MODEL)
|
| 61 |
+
|
| 62 |
+
# ---------------------------------------------------------------------------
|
| 63 |
+
# User-configurable inference (BYO token / model)
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
_USER_CONFIG_LOCK = threading.Lock()
|
| 66 |
+
_USER_CONFIG: Dict[str, Optional[str]] = {
|
| 67 |
+
"hf_token": None,
|
| 68 |
+
"model": None,
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def get_user_hf_token() -> Optional[str]:
|
| 73 |
+
with _USER_CONFIG_LOCK:
|
| 74 |
+
return _USER_CONFIG["hf_token"]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def get_user_model() -> Optional[str]:
|
| 78 |
+
with _USER_CONFIG_LOCK:
|
| 79 |
+
return _USER_CONFIG["model"]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ---------------------------------------------------------------------------
|
| 83 |
+
# Llama.cpp Inference Setup
|
| 84 |
+
# ---------------------------------------------------------------------------
|
| 85 |
+
# No local LLM state — every inference call goes through the HF Inference API
|
| 86 |
+
# with cooldown enforcement. Procedural fallback is always available.
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def llm_available() -> bool:
|
| 90 |
+
"""True if we *might* succeed at an inference call (cooldown not active,
|
| 91 |
+
HF_TOKEN configured, model id is set)."""
|
| 92 |
+
import os
|
| 93 |
+
token = get_user_hf_token() or os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN")
|
| 94 |
+
model = get_user_model() or TINYBARD_MODEL
|
| 95 |
+
# Inference API still works anonymously for some models, so don't gate hard.
|
| 96 |
+
return bool(model) and not cooldown_active("tinybard")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def last_inference_status() -> dict:
|
| 100 |
+
"""Snapshot of the current cooldown + model for /api/model_status."""
|
| 101 |
+
return {
|
| 102 |
+
"model": get_user_model() or TINYBARD_MODEL,
|
| 103 |
+
"cooldown": cooldown_status("tinybard"),
|
| 104 |
+
"has_user_token": bool(get_user_hf_token()),
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ---------------------------------------------------------------------------
|
| 109 |
+
# Procedural Fallback Adventure Engine
|
| 110 |
+
# ---------------------------------------------------------------------------
|
| 111 |
+
GENRES = {
|
| 112 |
+
"fantasy": {
|
| 113 |
+
"start": "You stand before the gates of the Whisperwood. The ancient trees hum with a faint violet energy.",
|
| 114 |
+
"nodes": [
|
| 115 |
+
{
|
| 116 |
+
"story": "A glowing sprite appears, offering a golden key or a mossy vial.",
|
| 117 |
+
"choices": ["Take the golden key", "Drink the mossy vial", "Ignore the sprite and press forward"]
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"story": "You encounter a moss-covered stone golem blocking the path. It speaks in riddles.",
|
| 121 |
+
"choices": ["Answer its riddle with a joke", "Use your golden key if you have it", "Try to climb over it"]
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"story": "You discover a hidden pool reflecting stars that aren't in the sky.",
|
| 125 |
+
"choices": ["Drink from the star pool", "Rest by the shore", "Toss a coin into the water"]
|
| 126 |
+
}
|
| 127 |
+
],
|
| 128 |
+
"win": "You find the heart of the forest and unlock the ancient relic. You are victorious!",
|
| 129 |
+
"lose": "The energy of the forest overwhelms you. You fade into the whispers of the wood."
|
| 130 |
+
},
|
| 131 |
+
"scifi": {
|
| 132 |
+
"start": "The emergency lights flicker red in the derelict cargo bay of USS Horizon. Gravity is failing.",
|
| 133 |
+
"nodes": [
|
| 134 |
+
{
|
| 135 |
+
"story": "A leaking fuel pipe blocks the corridor ahead. Sparking wires fill the air.",
|
| 136 |
+
"choices": ["Siphon the fuel", "Bypass the circuits", "Wait for the cycle to clear"]
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
"story": "An automated security drone activates, targeting you with its laser system.",
|
| 140 |
+
"choices": ["Hack the drone terminal", "Throw scrap metal to distract it", "Run for the airlock"]
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
"story": "You reach the main computer terminal. The AI core is corrupt but online.",
|
| 144 |
+
"choices": ["Initiate override protocol", "Ask the AI for help", "Pull the main power breaker"]
|
| 145 |
+
}
|
| 146 |
+
],
|
| 147 |
+
"win": "You restore life support and secure the escape pod. You survive!",
|
| 148 |
+
"lose": "The hull breaches. You are swept into the cold embrace of outer space."
|
| 149 |
+
},
|
| 150 |
+
"cyberpunk": {
|
| 151 |
+
"start": "Acid rain beats against the neon signs of Sector 9. Your neural interface is glitching.",
|
| 152 |
+
"nodes": [
|
| 153 |
+
{
|
| 154 |
+
"story": "A street dealer offers to patch your wetware for a few credits or a favor.",
|
| 155 |
+
"choices": ["Accept the shady patch", "Decline and buy a neural booster", "Threaten him for info"]
|
| 156 |
+
},
|
| 157 |
+
{
|
| 158 |
+
"story": "A corporate agent corners you in a wet alleyway. He demands your datapad.",
|
| 159 |
+
"choices": ["Upload a virus to his cyber-eyes", "Hand over a fake datapad", "Sprint up the fire escape"]
|
| 160 |
+
},
|
| 161 |
+
{
|
| 162 |
+
"story": "You infiltrate the mainframe room of Shinra-Tech. The security grid is active.",
|
| 163 |
+
"choices": ["Jack in directly", "Use your backup deck", "Short-circuit the access node"]
|
| 164 |
+
}
|
| 165 |
+
],
|
| 166 |
+
"win": "You upload the corporate secrets to the net. Sector 9 is free. You win!",
|
| 167 |
+
"lose": "Your brain fried due to feedback from the security grid. Game Over."
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def generate_procedural_step(genre: str, step: int, health: int, choice: str = "") -> dict:
|
| 173 |
+
"""Generate a fallback adventure step without LLM."""
|
| 174 |
+
genre_data = GENRES.get(genre.lower(), GENRES["fantasy"])
|
| 175 |
+
|
| 176 |
+
if step == 0:
|
| 177 |
+
return {
|
| 178 |
+
"story": genre_data["start"],
|
| 179 |
+
"choices": genre_data["nodes"][0]["choices"],
|
| 180 |
+
"health": health,
|
| 181 |
+
"step": 1,
|
| 182 |
+
"game_over": False
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
health_delta = random.choice([-15, 0, 10])
|
| 186 |
+
new_health = max(0, min(100, health + health_delta))
|
| 187 |
+
|
| 188 |
+
if new_health <= 0:
|
| 189 |
+
return {
|
| 190 |
+
"story": f"After choosing: '{choice}'. " + genre_data["lose"],
|
| 191 |
+
"choices": [],
|
| 192 |
+
"health": 0,
|
| 193 |
+
"step": step + 1,
|
| 194 |
+
"game_over": True
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
if step >= 4:
|
| 198 |
+
return {
|
| 199 |
+
"story": f"After choosing: '{choice}'. " + genre_data["win"],
|
| 200 |
+
"choices": [],
|
| 201 |
+
"health": new_health,
|
| 202 |
+
"step": step + 1,
|
| 203 |
+
"game_over": True
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
node = genre_data["nodes"][step % len(genre_data["nodes"])]
|
| 207 |
+
return {
|
| 208 |
+
"story": f"You choose: '{choice}'.\n\n{node['story']}",
|
| 209 |
+
"choices": node["choices"],
|
| 210 |
+
"health": new_health,
|
| 211 |
+
"step": step + 1,
|
| 212 |
+
"game_over": False
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# ---------------------------------------------------------------------------
|
| 217 |
+
# LLM Generation Logic (HF Inference API + cooldown)
|
| 218 |
+
# ---------------------------------------------------------------------------
|
| 219 |
+
def _parse_messages(genre: str, history: List[Dict[str, str]], next_instruction: str) -> list[Dict[str, str]]:
|
| 220 |
+
"""Translate internal history into OpenAI-style chat messages."""
|
| 221 |
+
system = (
|
| 222 |
+
"You are the narrator of an interactive text adventure game. "
|
| 223 |
+
f"Genre: {genre}. Write in the second person ('You...'). "
|
| 224 |
+
"Keep descriptions highly atmospheric but short (under 3 sentences). "
|
| 225 |
+
"Focus on action, mystery, and choice."
|
| 226 |
+
)
|
| 227 |
+
msgs: List[Dict[str, str]] = [{"role": "system", "content": system}]
|
| 228 |
+
for h in (history or []):
|
| 229 |
+
if h.get("role") == "player":
|
| 230 |
+
msgs.append({"role": "user", "content": h["text"]})
|
| 231 |
+
elif h.get("role") == "narrator":
|
| 232 |
+
msgs.append({"role": "assistant", "content": h["text"]})
|
| 233 |
+
msgs.append({"role": "user", "content": next_instruction})
|
| 234 |
+
return msgs
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def generate_llm_turn(
|
| 238 |
+
genre: str,
|
| 239 |
+
history: List[Dict[str, str]],
|
| 240 |
+
current_health: int,
|
| 241 |
+
next_instruction: str,
|
| 242 |
+
) -> dict:
|
| 243 |
+
"""Generate one full adventure turn via the LLM.
|
| 244 |
+
|
| 245 |
+
Returns a dict with keys: story, choices, health_delta, game_over.
|
| 246 |
+
health_delta is typically -15, 0, or +10 (the model decides).
|
| 247 |
+
"""
|
| 248 |
+
if cooldown_active("tinybard"):
|
| 249 |
+
log.info("tinybard turn skipped (cooldown active)")
|
| 250 |
+
return {}
|
| 251 |
+
system = (
|
| 252 |
+
"You are the narrator of an interactive text adventure game. "
|
| 253 |
+
f"Genre: {genre}. Write in the second person ('You...'). "
|
| 254 |
+
"Keep descriptions highly atmospheric but short (under 3 sentences). "
|
| 255 |
+
"Focus on action, mystery, and choice. "
|
| 256 |
+
"After the story beat, output exactly 3 short distinct player choices on one line, "
|
| 257 |
+
"in the format: 1. <choice> | 2. <choice> | 3. <choice>"
|
| 258 |
+
)
|
| 259 |
+
user = (
|
| 260 |
+
f"Current health: {current_health}/100. "
|
| 261 |
+
f"History so far: {json.dumps(history[-4:])}. "
|
| 262 |
+
f"{next_instruction} "
|
| 263 |
+
"Also state a health delta of -15, 0, or +10 that reflects how risky this turn was."
|
| 264 |
+
)
|
| 265 |
+
try:
|
| 266 |
+
result = inference_generate(
|
| 267 |
+
project="tinybard",
|
| 268 |
+
messages=[
|
| 269 |
+
{"role": "system", "content": system},
|
| 270 |
+
{"role": "user", "content": user},
|
| 271 |
+
],
|
| 272 |
+
max_new_tokens=220,
|
| 273 |
+
temperature=0.7,
|
| 274 |
+
)
|
| 275 |
+
text = result.text.strip()
|
| 276 |
+
except Exception as e:
|
| 277 |
+
log.warning(f"HF Inference error (fallback to procedural): {e}")
|
| 278 |
+
return {}
|
| 279 |
+
|
| 280 |
+
# Split story and choices
|
| 281 |
+
story = text
|
| 282 |
+
choices = []
|
| 283 |
+
health_delta = 0
|
| 284 |
+
for sep in ["\n1.", "\n1. ", " 1.", " Choices:"]:
|
| 285 |
+
if sep in text:
|
| 286 |
+
parts = text.split(sep, 1)
|
| 287 |
+
story = parts[0].strip()
|
| 288 |
+
rest = parts[1].strip()
|
| 289 |
+
choices = _parse_choices("1. " + rest)
|
| 290 |
+
break
|
| 291 |
+
|
| 292 |
+
if not choices:
|
| 293 |
+
choices = _parse_choices(text)
|
| 294 |
+
|
| 295 |
+
# Extract a simple health delta from the text when possible
|
| 296 |
+
m = re.search(r"health delta\s*[:=]\s*([+-]?\d+)", text, re.IGNORECASE)
|
| 297 |
+
if m:
|
| 298 |
+
try:
|
| 299 |
+
health_delta = int(m.group(1))
|
| 300 |
+
except Exception:
|
| 301 |
+
health_delta = 0
|
| 302 |
+
else:
|
| 303 |
+
health_delta = random.choice([-15, 0, 10])
|
| 304 |
+
|
| 305 |
+
new_health = max(0, min(100, current_health + health_delta))
|
| 306 |
+
game_over = new_health <= 0
|
| 307 |
+
return {
|
| 308 |
+
"story": story,
|
| 309 |
+
"choices": choices[:3] if len(choices) >= 2 else [],
|
| 310 |
+
"health_delta": health_delta,
|
| 311 |
+
"new_health": new_health,
|
| 312 |
+
"game_over": game_over,
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
# ---------------------------------------------------------------------------
|
| 317 |
+
# Gradio Blocks — API endpoints (exposed as MCP tools)
|
| 318 |
+
# ---------------------------------------------------------------------------
|
| 319 |
+
def create_gradio_app() -> gr.Blocks:
|
| 320 |
+
"""Build the Gradio Blocks app with API endpoints for MCP integration."""
|
| 321 |
+
|
| 322 |
+
with gr.Blocks(title="TinyBard API") as blocks:
|
| 323 |
+
# Hidden state — not rendered in UI, used by API
|
| 324 |
+
genre_input = gr.Textbox(label="Genre", visible=False)
|
| 325 |
+
step_input = gr.Number(label="Step", value=0, visible=False)
|
| 326 |
+
health_input = gr.Number(label="Health", value=100, visible=False)
|
| 327 |
+
choice_input = gr.Textbox(label="Choice", visible=False)
|
| 328 |
+
history_input = gr.Textbox(label="History JSON", value="[]", visible=False)
|
| 329 |
+
|
| 330 |
+
# Output fields
|
| 331 |
+
story_output = gr.Textbox(label="Story")
|
| 332 |
+
choices_output = gr.JSON(label="Choices")
|
| 333 |
+
health_output = gr.Number(label="Health")
|
| 334 |
+
step_output = gr.Number(label="Step")
|
| 335 |
+
game_over_output = gr.Checkbox(label="Game Over")
|
| 336 |
+
history_output = gr.Textbox(label="History JSON")
|
| 337 |
+
|
| 338 |
+
def api_start_game(genre: str):
|
| 339 |
+
"""Start a new interactive text adventure. Exposed as MCP tool."""
|
| 340 |
+
genre = genre.lower()
|
| 341 |
+
if genre not in ["fantasy", "scifi", "cyberpunk"]:
|
| 342 |
+
genre = "fantasy"
|
| 343 |
+
|
| 344 |
+
# Try LLM first (will skip if cooldown is active)
|
| 345 |
+
instruction = "Narrate the beginning of the adventure. What happens first? Do not offer choices yet."
|
| 346 |
+
turn = generate_llm_turn(genre, [], 100, instruction)
|
| 347 |
+
if not turn:
|
| 348 |
+
result = generate_procedural_step(genre, 0, 100)
|
| 349 |
+
return (
|
| 350 |
+
result["story"], result["choices"], result["health"],
|
| 351 |
+
result["step"], result["game_over"],
|
| 352 |
+
json.dumps(result.get("history", []))
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
history = [{"role": "narrator", "text": turn["story"]}]
|
| 356 |
+
choices = turn["choices"] if len(turn.get("choices", [])) >= 2 else ["Explore the area", "Check your equipment", "Proceed carefully"]
|
| 357 |
+
return (turn["story"], choices[:3], 100, 1, False, json.dumps(history))
|
| 358 |
+
|
| 359 |
+
def api_make_choice(choice: str, genre: str, step: int, health: int, history_json: str):
|
| 360 |
+
"""Submit a player choice to advance the story. Exposed as MCP tool."""
|
| 361 |
+
try:
|
| 362 |
+
history = json.loads(history_json)
|
| 363 |
+
except Exception:
|
| 364 |
+
history = []
|
| 365 |
+
|
| 366 |
+
step = int(step)
|
| 367 |
+
health = int(health)
|
| 368 |
+
|
| 369 |
+
history.append({"role": "player", "text": choice})
|
| 370 |
+
|
| 371 |
+
instruction = "Narrate what happens next as a result of the player's choice."
|
| 372 |
+
turn = generate_llm_turn(genre, history, health, instruction)
|
| 373 |
+
if not turn:
|
| 374 |
+
result = generate_procedural_step(genre, step, health, choice)
|
| 375 |
+
return (
|
| 376 |
+
result["story"], result["choices"], result["health"],
|
| 377 |
+
result["step"], result["game_over"],
|
| 378 |
+
json.dumps(result.get("history", history))
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
history.append({"role": "narrator", "text": turn["story"]})
|
| 382 |
+
choices = turn["choices"] if len(turn.get("choices", [])) >= 2 else ["Move forward", "Look around", "Rest a moment"]
|
| 383 |
+
|
| 384 |
+
return (turn["story"], choices[:3], turn["new_health"], step + 1, turn["game_over"], json.dumps(history))
|
| 385 |
+
|
| 386 |
+
# Register API endpoints
|
| 387 |
+
gr.Button("Start Game").click(
|
| 388 |
+
fn=api_start_game,
|
| 389 |
+
inputs=[genre_input],
|
| 390 |
+
outputs=[story_output, choices_output, health_output, step_output, game_over_output, history_output],
|
| 391 |
+
api_name="start_game"
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
gr.Button("Make Choice").click(
|
| 395 |
+
fn=api_make_choice,
|
| 396 |
+
inputs=[choice_input, genre_input, step_input, health_input, history_input],
|
| 397 |
+
outputs=[story_output, choices_output, health_output, step_output, game_over_output, history_output],
|
| 398 |
+
api_name="make_choice"
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
return blocks
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def _parse_choices(choices_text: str) -> List[str]:
|
| 405 |
+
"""Parse LLM choice output into a list of choices."""
|
| 406 |
+
choices = []
|
| 407 |
+
if "|" in choices_text:
|
| 408 |
+
choices = [c.split(".")[-1].strip() for c in choices_text.split("|")]
|
| 409 |
+
else:
|
| 410 |
+
for line in choices_text.split("\n"):
|
| 411 |
+
if "." in line or any(d in line for d in "123"):
|
| 412 |
+
parts = line.split(".", 1)
|
| 413 |
+
if len(parts) > 1:
|
| 414 |
+
choices.append(parts[1].strip())
|
| 415 |
+
return choices
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
# ---------------------------------------------------------------------------
|
| 419 |
+
# FastAPI App — Custom frontend + Gradio API
|
| 420 |
+
# ---------------------------------------------------------------------------
|
| 421 |
+
fastapi_app = FastAPI(title="TinyBard", docs_url="/docs")
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
@fastapi_app.get("/", response_class=HTMLResponse)
|
| 425 |
+
async def homepage():
|
| 426 |
+
"""Serve the retro CRT terminal frontend."""
|
| 427 |
+
index_path = STATIC_DIR / "index.html"
|
| 428 |
+
if index_path.exists():
|
| 429 |
+
return index_path.read_text()
|
| 430 |
+
return HTMLResponse("<h1>TinyBard retro terminal under construction!</h1>")
|
| 431 |
+
@fastapi_app.get("/api/model_status")
|
| 432 |
+
async def model_status():
|
| 433 |
+
"""Check the inference client + cooldown status."""
|
| 434 |
+
return last_inference_status()
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
# ---------------------------------------------------------------------------
|
| 438 |
+
# Game Logic — exposed as both FastAPI (clean JSON) and Gradio (MCP)
|
| 439 |
+
# ---------------------------------------------------------------------------
|
| 440 |
+
def _run_turn(choice: str, genre: str, step: int, health: int, history: List[Dict]) -> dict:
|
| 441 |
+
"""Single source of truth for one adventure turn.
|
| 442 |
+
|
| 443 |
+
Returns a dict the frontend can consume directly. Used by both the
|
| 444 |
+
FastAPI /api/game/* endpoints and the Gradio MCP tools.
|
| 445 |
+
"""
|
| 446 |
+
in_cooldown = cooldown_active("tinybard")
|
| 447 |
+
|
| 448 |
+
if step == 0:
|
| 449 |
+
if in_cooldown:
|
| 450 |
+
return generate_procedural_step(genre, 0, 100)
|
| 451 |
+
instruction = "Narrate the beginning of the adventure. What happens first? Do not offer choices yet."
|
| 452 |
+
turn = generate_llm_turn(genre, [], 100, instruction)
|
| 453 |
+
if not turn:
|
| 454 |
+
return generate_procedural_step(genre, 0, 100)
|
| 455 |
+
history = [{"role": "narrator", "text": turn["story"]}]
|
| 456 |
+
choices = turn["choices"] if len(turn.get("choices", [])) >= 2 else ["Explore the area", "Check your equipment", "Proceed carefully"]
|
| 457 |
+
return {
|
| 458 |
+
"story": turn["story"], "choices": choices[:3], "health": 100,
|
| 459 |
+
"step": 1, "game_over": False, "history": history,
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
if in_cooldown:
|
| 463 |
+
return generate_procedural_step(genre, step, health, choice)
|
| 464 |
+
|
| 465 |
+
history.append({"role": "player", "text": choice})
|
| 466 |
+
instruction = "Narrate what happens next as a result of the player's choice."
|
| 467 |
+
turn = generate_llm_turn(genre, history, health, instruction)
|
| 468 |
+
if not turn:
|
| 469 |
+
return generate_procedural_step(genre, step, health, choice)
|
| 470 |
+
history.append({"role": "narrator", "text": turn["story"]})
|
| 471 |
+
choices = turn["choices"] if len(turn.get("choices", [])) >= 2 else ["Move forward", "Look around", "Rest a moment"]
|
| 472 |
+
return {
|
| 473 |
+
"story": turn["story"], "choices": choices[:3],
|
| 474 |
+
"health": turn["new_health"], "step": step + 1,
|
| 475 |
+
"game_over": turn["game_over"], "history": history,
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
@fastapi_app.post("/api/game/start")
|
| 480 |
+
async def game_start(payload: dict):
|
| 481 |
+
"""Start a new adventure. Returns clean JSON.
|
| 482 |
+
|
| 483 |
+
Body: {"genre": "fantasy|scifi|cyberpunk"}
|
| 484 |
+
"""
|
| 485 |
+
genre = (payload.get("genre") or "fantasy").lower()
|
| 486 |
+
if genre not in ["fantasy", "scifi", "cyberpunk"]:
|
| 487 |
+
genre = "fantasy"
|
| 488 |
+
return _run_turn(choice="", genre=genre, step=0, health=100, history=[])
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
@fastapi_app.post("/api/game/choice")
|
| 492 |
+
async def game_choice(payload: dict):
|
| 493 |
+
"""Submit a player choice. Returns clean JSON.
|
| 494 |
+
|
| 495 |
+
Body: {
|
| 496 |
+
"choice": str, "genre": str, "step": int, "health": int,
|
| 497 |
+
"history": [{"role": ..., "text": ...}, ...]
|
| 498 |
+
}
|
| 499 |
+
"""
|
| 500 |
+
return _run_turn(
|
| 501 |
+
choice=payload.get("choice", ""),
|
| 502 |
+
genre=payload.get("genre", "fantasy"),
|
| 503 |
+
step=int(payload.get("step", 1)),
|
| 504 |
+
health=int(payload.get("health", 100)),
|
| 505 |
+
history=payload.get("history", []),
|
| 506 |
+
)
|
| 507 |
+
|
| 508 |
+
@fastapi_app.post("/api/config")
|
| 509 |
+
async def update_config(body: dict):
|
| 510 |
+
with _USER_CONFIG_LOCK:
|
| 511 |
+
if body.get("hf_token"):
|
| 512 |
+
_USER_CONFIG["hf_token"] = body["hf_token"].strip() or None
|
| 513 |
+
if body.get("model") and str(body["model"]).strip():
|
| 514 |
+
_USER_CONFIG["model"] = str(body["model"]).strip()
|
| 515 |
+
current = dict(_USER_CONFIG)
|
| 516 |
+
return {
|
| 517 |
+
"status": "ok",
|
| 518 |
+
"model": current["model"] or TINYBARD_MODEL,
|
| 519 |
+
"has_token": bool(current["hf_token"]),
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
@fastapi_app.get("/api/config")
|
| 524 |
+
async def get_config():
|
| 525 |
+
with _USER_CONFIG_LOCK:
|
| 526 |
+
current = dict(_USER_CONFIG)
|
| 527 |
+
return {
|
| 528 |
+
"model": current["model"] or TINYBARD_MODEL,
|
| 529 |
+
"has_token": bool(current["hf_token"]),
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
# Mount static files
|
| 534 |
+
fastapi_app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 535 |
+
|
| 536 |
+
# Mount Gradio app at /gradio — this creates the API + MCP endpoints
|
| 537 |
+
gradio_blocks = create_gradio_app()
|
| 538 |
+
mount_gradio_app(fastapi_app, gradio_blocks, path="/gradio")
|
| 539 |
+
|
| 540 |
+
# ---------------------------------------------------------------------------
|
| 541 |
+
# Exported for HF Spaces Gradio SDK (launches once on import)
|
| 542 |
+
# ---------------------------------------------------------------------------
|
| 543 |
+
app = fastapi_app
|
| 544 |
+
|
| 545 |
+
# ---------------------------------------------------------------------------
|
| 546 |
+
# HF Spaces entrypoint — keep the ASGI server alive
|
| 547 |
+
# ---------------------------------------------------------------------------
|
| 548 |
+
if __name__ == "__main__":
|
| 549 |
+
import uvicorn
|
| 550 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
requirements.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TinyBard — Micro Text Adventure Generator
|
| 2 |
+
# Python 3.10+
|
| 3 |
+
#
|
| 4 |
+
# Inference is via the Hugging Face Inference API (no local GGUF,
|
| 5 |
+
# no llama-cpp-python compile). Cooldown is enforced in
|
| 6 |
+
# `shared/inference_client.py` to protect your credit budget.
|
| 7 |
+
#
|
| 8 |
+
# Set these Space secrets/variables to configure:
|
| 9 |
+
# HF_TOKEN — your HF token (anonymous works for many small models)
|
| 10 |
+
# INFERENCE_MODEL — model id (default: Qwen/Qwen2.5-1.5B-Instruct)
|
| 11 |
+
# TINYBARD_COOLDOWN_SECONDS — gap between inference calls (default 6)
|
| 12 |
+
# INFERENCE_PROVIDER — "hf-inference" (default, free serverless) or paid
|
| 13 |
+
# INFERENCE_MAX_TOKENS — per-call token cap (default 220)
|
| 14 |
+
|
| 15 |
+
gradio>=5.0
|
| 16 |
+
fastapi>=0.110
|
| 17 |
+
huggingface_hub>=0.20
|
| 18 |
+
uvicorn[standard]>=0.27
|
shared/cedar_copper_tokens.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cedar-Copper Design Tokens
|
| 3 |
+
============================
|
| 4 |
+
Shared palette + decorative-glyph module for the build-small-hackathon monorepo.
|
| 5 |
+
|
| 6 |
+
Each project's own CSS/UI embeds the tokens it needs (this file is a
|
| 7 |
+
reference, not imported at runtime).
|
| 8 |
+
|
| 9 |
+
Decorative Unicode glyphs in this file are intentionally NOT used in HF
|
| 10 |
+
Spaces metadata — see llms.txt "HF Agents CLI" notes.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
# ── DECORATIVE GLYPHS (in-app banners, not HF metadata) ───────────────────────
|
| 14 |
+
PUU = "\u1438" # ᐸ — left frame character
|
| 15 |
+
SHOO = "\u1514" # ᔔ — right frame character
|
| 16 |
+
BII = "\u142A" # ᐪ
|
| 17 |
+
MII = "\u14A1" # ᑡ
|
| 18 |
+
WA = "\u1418" # ᐘ
|
| 19 |
+
GO = "\u1472" # ᑲ
|
| 20 |
+
AN = "\u14B0" # ᒐ
|
| 21 |
+
|
| 22 |
+
SUN_RAY = "\u263C" # ☼
|
| 23 |
+
SUN_BLACK = "\u2600" # ☀
|
| 24 |
+
LEAF = "\u2618" # ☘
|
| 25 |
+
FLOWER = "\u2740" # ❀
|
| 26 |
+
MEDICINE_WHEEL = "\u2697" # ⚗
|
| 27 |
+
ARROW_UP = "\u21B3" # ↳
|
| 28 |
+
CIRCUIT = "\u25C8" # ◈
|
| 29 |
+
DIAMOND = "\u25C6" # ◆
|
| 30 |
+
DIAMOND_O = "\u25C7" # ◇
|
| 31 |
+
|
| 32 |
+
# ── COLOR PALETTE ──────────────────────────────────────────────────────────────
|
| 33 |
+
PALETTE = {
|
| 34 |
+
# Sky and water
|
| 35 |
+
"sky": "#5BA4D9", # light morning sky
|
| 36 |
+
"water": "#1B4965", # deep lake water
|
| 37 |
+
"ice": "#BEE9E8", # spring thaw
|
| 38 |
+
"frost": "#CAF0F8", # winter light
|
| 39 |
+
# Sun
|
| 40 |
+
"sun": "#F2A93B", # rising sun amber
|
| 41 |
+
"sunlight": "#FFB347", # warm ray
|
| 42 |
+
"ember": "#E76F51", # dusk ember
|
| 43 |
+
# Earth
|
| 44 |
+
"birch": "#F5F1E8", # birchbark white
|
| 45 |
+
"terracotta":"#C8553D", # clay red
|
| 46 |
+
"earth": "#8B3A1F", # deep earth / copper
|
| 47 |
+
"moss": "#588157", # forest moss
|
| 48 |
+
"forest": "#3D6A4A", # cedar forest
|
| 49 |
+
"spruce": "#1B4332", # deep spruce (night)
|
| 50 |
+
# Contrast
|
| 51 |
+
"night": "#0F1A2C", # deep night sky
|
| 52 |
+
"ash": "#3A2E2A", # woodsmoke
|
| 53 |
+
"stone": "#A89F91", # river stone
|
| 54 |
+
"ink": "#1A1F2E", # deep ink
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
# ── PROJECT FRAMING ───────────────────────────────────────────────────────────
|
| 58 |
+
def header(title: str, en: str) -> str:
|
| 59 |
+
"""Cedar-copper banner header for a project.
|
| 60 |
+
|
| 61 |
+
Usage:
|
| 62 |
+
header("TinyBard", "MICRO TEXT ADVENTURE")
|
| 63 |
+
"""
|
| 64 |
+
line = f"{DIAMOND}{CIRCUIT}{DIAMOND_O}"
|
| 65 |
+
return (
|
| 66 |
+
f"\n{line}{CIRCUIT}{PUU} {title} {SHOO} [{en.upper()}] "
|
| 67 |
+
f"{CIRCUIT}{line}\n"
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def footer() -> str:
|
| 72 |
+
"""Closing banner."""
|
| 73 |
+
return f"\n{DIAMOND_O}{CIRCUIT}{DIAMOND} {SUN_RAY} \u00b7 {LEAF} \u00b7 {WATER_ICON()} \u00b7 {SUN_BLACK} {CIRCUIT}{DIAMOND}{DIAMOND_O}\n"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def WATER_ICON() -> str:
|
| 77 |
+
return "\u2248" # ≈ (approx, like a wave)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ── THEME CONSTANTS (CSS variables) ──────────────────────────────────────────
|
| 81 |
+
CSS_VARS = f"""
|
| 82 |
+
:root {{
|
| 83 |
+
--cc-sky: {PALETTE['sky']};
|
| 84 |
+
--cc-water: {PALETTE['water']};
|
| 85 |
+
--cc-ice: {PALETTE['ice']};
|
| 86 |
+
--cc-sun: {PALETTE['sun']};
|
| 87 |
+
--cc-sunlight: {PALETTE['sunlight']};
|
| 88 |
+
--cc-ember: {PALETTE['ember']};
|
| 89 |
+
--cc-birch: {PALETTE['birch']};
|
| 90 |
+
--cc-terra: {PALETTE['terracotta']};
|
| 91 |
+
--cc-earth: {PALETTE['earth']};
|
| 92 |
+
--cc-moss: {PALETTE['moss']};
|
| 93 |
+
--cc-forest: {PALETTE['forest']};
|
| 94 |
+
--cc-spruce: {PALETTE['spruce']};
|
| 95 |
+
--cc-night: {PALETTE['night']};
|
| 96 |
+
--cc-ash: {PALETTE['ash']};
|
| 97 |
+
--cc-stone: {PALETTE['stone']};
|
| 98 |
+
--cc-ink: {PALETTE['ink']};
|
| 99 |
+
}}
|
| 100 |
+
"""
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ── SHARED CSS SNIPPETS ───────────────────────────────────────────────────────
|
| 104 |
+
def style_banner() -> str:
|
| 105 |
+
"""A standard top-of-app banner with cedar-copper styling."""
|
| 106 |
+
return """
|
| 107 |
+
/* === CEDAR-COPPER BANNER ============================================== */
|
| 108 |
+
.cc-banner {
|
| 109 |
+
display: flex;
|
| 110 |
+
align-items: center;
|
| 111 |
+
justify-content: center;
|
| 112 |
+
gap: 0.6em;
|
| 113 |
+
padding: 14px 18px;
|
| 114 |
+
background: linear-gradient(95deg, var(--cc-sky) 0%, var(--cc-water) 100%);
|
| 115 |
+
color: var(--cc-birch);
|
| 116 |
+
border-bottom: 1px solid rgba(255, 179, 71, 0.35);
|
| 117 |
+
font-family: Georgia, 'Iowan Old Style', serif;
|
| 118 |
+
letter-spacing: 0.5px;
|
| 119 |
+
text-shadow: 0 1px 2px rgba(15, 26, 44, 0.35);
|
| 120 |
+
}
|
| 121 |
+
.cc-banner .syll { font-size: 1.4em; opacity: 0.85; }
|
| 122 |
+
.cc-banner .title { font-size: 1.05em; font-weight: 600; }
|
| 123 |
+
.cc-banner .glyph {
|
| 124 |
+
display: inline-block;
|
| 125 |
+
transform: translateY(-1px);
|
| 126 |
+
font-size: 1.1em;
|
| 127 |
+
color: var(--cc-sunlight);
|
| 128 |
+
}
|
| 129 |
+
/* ===================================================================== */
|
| 130 |
+
"""
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ── WELCOME TEXT ──────────────────────────────────────────────────────────────
|
| 134 |
+
WELCOME = (
|
| 135 |
+
f"{PUU} Welcome {SHOO} \u2014 The land remembers you. "
|
| 136 |
+
f"{SUN_RAY} {LEAF} {WATER_ICON()}"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ── CHARACTER (TinyBard, FocusFriend) ────────────────────────────────────────
|
| 141 |
+
PIP_GREETING_CEDAR_COPPER = (
|
| 142 |
+
f"{PUU} Hello, friend {SHOO} \u2014 I am Pip, the friend of the moss "
|
| 143 |
+
f"and the small winds. Sit. Breathe. The sun is in no hurry. {SUN_BLACK}"
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
if __name__ == "__main__":
|
| 148 |
+
print(header("Build Small Hackathon", "CODER SANCTUARY"))
|
| 149 |
+
print(WELCOME)
|
| 150 |
+
print(PIP_GREETING_CEDAR_COPPER)
|
| 151 |
+
print(footer())
|
shared/inference_client.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shared HF Inference Client + Cooldown
|
| 3 |
+
======================================
|
| 4 |
+
Lightweight wrapper around `huggingface_hub.InferenceClient` with:
|
| 5 |
+
|
| 6 |
+
- Per-call cooldown to prevent credit burn on live HF Spaces
|
| 7 |
+
- Async-friendly API
|
| 8 |
+
- Auto-fallback to procedural/story-template engines when inference fails
|
| 9 |
+
- Environment-driven config (works in HF Spaces and local)
|
| 10 |
+
|
| 11 |
+
The cooldown model:
|
| 12 |
+
- Each project has its own cooldown window (default 8s for cheap inference APIs)
|
| 13 |
+
- Within a session, after a successful inference, no new call can run until cooldown expires
|
| 14 |
+
- Failed inference does not start a cooldown (allow quick retry)
|
| 15 |
+
- `cooldown_active()` is the public check; FastAPI handlers short-circuit on active cooldown
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
import time
|
| 21 |
+
import logging
|
| 22 |
+
import threading
|
| 23 |
+
from dataclasses import dataclass, field
|
| 24 |
+
from typing import Optional, Dict, Any, Callable, List
|
| 25 |
+
|
| 26 |
+
log = logging.getLogger("inference")
|
| 27 |
+
|
| 28 |
+
# ── Environment knobs ─────────────────────────────────────────────────────────
|
| 29 |
+
# Override these in your Space's "Settings → Variables and secrets".
|
| 30 |
+
|
| 31 |
+
# The HF model id used for text generation (VibeThinker 1.5B, Gemma 4 12B, etc.)
|
| 32 |
+
INFERENCE_MODEL = os.environ.get(
|
| 33 |
+
"INFERENCE_MODEL",
|
| 34 |
+
"Qwen/Qwen2.5-1.5B-Instruct", # small, fast, free-tier friendly
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# Provider: "hf-inference" (free serverless), "together", "fal-ai", "replicate"
|
| 38 |
+
# Free HF inference works for many small models; otherwise use a paid provider.
|
| 39 |
+
INFERENCE_PROVIDER = os.environ.get("INFERENCE_PROVIDER", "hf-inference")
|
| 40 |
+
|
| 41 |
+
# Token — read from HF Space secrets at runtime.
|
| 42 |
+
HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN")
|
| 43 |
+
|
| 44 |
+
# Default cooldown between inferences, in seconds.
|
| 45 |
+
COOLDOWN_SECONDS = float(os.environ.get("INFERENCE_COOLDOWN_SECONDS", "8"))
|
| 46 |
+
|
| 47 |
+
# Per-project override (keyed by app name)
|
| 48 |
+
PROJECT_COOLDOWN_OVERRIDES = {
|
| 49 |
+
"tinybard": float(os.environ.get("TINYBARD_COOLDOWN_SECONDS", "6")),
|
| 50 |
+
"focusfriend": float(os.environ.get("FOCUSFRIEND_COOLDOWN_SECONDS", "10")),
|
| 51 |
+
"crittercalm": float(os.environ.get("CRITTERCALM_COOLDOWN_SECONDS", "12")),
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
# Max tokens to request (keeps costs bounded)
|
| 55 |
+
MAX_NEW_TOKENS = int(os.environ.get("INFERENCE_MAX_TOKENS", "220"))
|
| 56 |
+
# ── Cooldown registry ────────────────────────────────────────────────────────
|
| 57 |
+
@dataclass
|
| 58 |
+
class _CooldownState:
|
| 59 |
+
last_call: float = 0.0
|
| 60 |
+
lock: threading.Lock = field(default_factory=threading.Lock)
|
| 61 |
+
_states: Dict[str, _CooldownState] = {}
|
| 62 |
+
def _state(project: str) -> _CooldownState:
|
| 63 |
+
if project not in _states:
|
| 64 |
+
_states[project] = _CooldownState()
|
| 65 |
+
return _states[project]
|
| 66 |
+
def cooldown_seconds_for(project: str) -> float:
|
| 67 |
+
return PROJECT_COOLDOWN_OVERRIDES.get(project, COOLDOWN_SECONDS)
|
| 68 |
+
def cooldown_active(project: str) -> bool:
|
| 69 |
+
"""Return True if the project is currently in cooldown (cannot run inference)."""
|
| 70 |
+
state = _state(project)
|
| 71 |
+
now = time.time()
|
| 72 |
+
if now - state.last_call < cooldown_seconds_for(project):
|
| 73 |
+
return True
|
| 74 |
+
return False
|
| 75 |
+
def cooldown_remaining(project: str) -> float:
|
| 76 |
+
"""Seconds left in the cooldown window (0 if not in cooldown)."""
|
| 77 |
+
state = _state(project)
|
| 78 |
+
elapsed = time.time() - state.last_call
|
| 79 |
+
remaining = cooldown_seconds_for(project) - elapsed
|
| 80 |
+
return max(0.0, remaining)
|
| 81 |
+
def cooldown_status(project: str) -> dict:
|
| 82 |
+
"""Snapshot of cooldown state for the UI."""
|
| 83 |
+
return {
|
| 84 |
+
"active": cooldown_active(project),
|
| 85 |
+
"remaining_seconds": round(cooldown_remaining(project), 2),
|
| 86 |
+
"window_seconds": cooldown_seconds_for(project),
|
| 87 |
+
}
|
| 88 |
+
def _mark_called(project: str) -> None:
|
| 89 |
+
state = _state(project)
|
| 90 |
+
with state.lock:
|
| 91 |
+
state.last_call = time.time()
|
| 92 |
+
# ── Inference client wrapper ─────────────────────────────────────────────────
|
| 93 |
+
class InferenceResult:
|
| 94 |
+
"""A small wrapper so callers don't need to know which API returned text."""
|
| 95 |
+
def __init__(self, text: str, model: str, provider: str, latency_s: float):
|
| 96 |
+
self.text = text
|
| 97 |
+
self.model = model
|
| 98 |
+
self.provider = provider
|
| 99 |
+
self.latency_s = latency_s
|
| 100 |
+
|
| 101 |
+
def __repr__(self) -> str:
|
| 102 |
+
return f"InferenceResult(text={self.text[:50]!r}…, model={self.model!r}, latency={self.latency_s:.2f}s)"
|
| 103 |
+
def _get_client():
|
| 104 |
+
"""Lazy-load the InferenceClient to keep boot fast."""
|
| 105 |
+
from huggingface_hub import InferenceClient
|
| 106 |
+
return InferenceClient(
|
| 107 |
+
model=INFERENCE_MODEL,
|
| 108 |
+
token=HF_TOKEN, # provider kwarg removed for hf-inference default
|
| 109 |
+
|
| 110 |
+
)
|
| 111 |
+
def generate(
|
| 112 |
+
project: str,
|
| 113 |
+
messages: List[Dict[str, str]],
|
| 114 |
+
*,
|
| 115 |
+
max_new_tokens: Optional[int] = None,
|
| 116 |
+
temperature: float = 0.7,
|
| 117 |
+
) -> InferenceResult:
|
| 118 |
+
"""Run a chat-style inference call, with cooldown enforcement.
|
| 119 |
+
|
| 120 |
+
`messages` follows OpenAI chat format: [{"role": "user|assistant|system", "content": "..."}].
|
| 121 |
+
Returns InferenceResult with `.text` (string) on success, or raises on failure.
|
| 122 |
+
Caller is responsible for fallback handling.
|
| 123 |
+
"""
|
| 124 |
+
if cooldown_active(project):
|
| 125 |
+
remaining = cooldown_remaining(project)
|
| 126 |
+
raise RuntimeError(
|
| 127 |
+
f"cooldown active for {project!r}: {remaining:.1f}s remaining. "
|
| 128 |
+
f"This protects your HF/Modal credit budget."
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
max_new_tokens = max_new_tokens or MAX_NEW_TOKENS
|
| 132 |
+
client = _get_client()
|
| 133 |
+
start = time.time()
|
| 134 |
+
response = client.chat_completion(
|
| 135 |
+
messages=messages,
|
| 136 |
+
max_tokens=max_new_tokens,
|
| 137 |
+
temperature=temperature,
|
| 138 |
+
)
|
| 139 |
+
latency = time.time() - start
|
| 140 |
+
text = response.choices[0].message.content or ""
|
| 141 |
+
text = text.strip()
|
| 142 |
+
_mark_called(project)
|
| 143 |
+
return InferenceResult(
|
| 144 |
+
text=text,
|
| 145 |
+
model=INFERENCE_MODEL,
|
| 146 |
+
provider=INFERENCE_PROVIDER,
|
| 147 |
+
latency_s=latency,
|
| 148 |
+
)
|
| 149 |
+
def force_clear_cooldown(project: str) -> None:
|
| 150 |
+
"""Manual escape hatch (e.g. for testing or admin overrides)."""
|
| 151 |
+
_state(project).last_call = 0.0
|
| 152 |
+
# ── Convenience: build messages + format result ──────────────────────────────
|
| 153 |
+
def chat_messages(system: str, user: str, history: Optional[List[Dict[str, str]]] = None) -> List[Dict[str, str]]:
|
| 154 |
+
"""Build an OpenAI-style message list with optional prior turns.
|
| 155 |
+
|
| 156 |
+
`history` is in the same [{role, content}, ...] format. New turns are appended.
|
| 157 |
+
"""
|
| 158 |
+
msgs: List[Dict[str, str]] = [{"role": "system", "content": system}]
|
| 159 |
+
if history:
|
| 160 |
+
msgs.extend(history)
|
| 161 |
+
msgs.append({"role": "user", "content": user})
|
| 162 |
+
return msgs
|
| 163 |
+
__all__ = [
|
| 164 |
+
"InferenceResult",
|
| 165 |
+
"cooldown_active",
|
| 166 |
+
"cooldown_remaining",
|
| 167 |
+
"cooldown_seconds_for",
|
| 168 |
+
"cooldown_status",
|
| 169 |
+
"force_clear_cooldown",
|
| 170 |
+
"generate",
|
| 171 |
+
"chat_messages",
|
| 172 |
+
"INFERENCE_MODEL",
|
| 173 |
+
"INFERENCE_PROVIDER",
|
| 174 |
+
"MAX_NEW_TOKENS",
|
| 175 |
+
]
|
| 176 |
+
if __name__ == "__main__":
|
| 177 |
+
# Smoke test
|
| 178 |
+
for p in ("tinybard", "focusfriend", "crittercalm"):
|
| 179 |
+
print(p, "cooldown:", cooldown_status(p))
|
static/index.html
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>ᐴ TinyBard ᔔ — Aanishinaabe Mikinaak-Aki</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<header class="asp-banner">
|
| 11 |
+
<span class="syll">ᐴ</span>
|
| 12 |
+
<span class="glyph">☼</span>
|
| 13 |
+
<span class="title">TINYBARD</span>
|
| 14 |
+
<span class="glyph">☘</span>
|
| 15 |
+
<span class="subtitle">— a fire-fly storyteller in cedar and copper —</span>
|
| 16 |
+
<span class="syll">ᔔ</span>
|
| 17 |
+
</header>
|
| 18 |
+
<div class="crt-container">
|
| 19 |
+
<div class="crt-screen" id="screen">
|
| 20 |
+
<div class="scanlines"></div>
|
| 21 |
+
<div class="vignette"></div>
|
| 22 |
+
|
| 23 |
+
<div class="terminal" id="terminal">
|
| 24 |
+
<div class="terminal-header">
|
| 25 |
+
<span class="prompt">ᐴ TINYBARD v1.0 ᔔ · GIIZHI-AADIZOKED</span>
|
| 26 |
+
<span class="status" id="health-bar">NOOSISKAAZOWIN: <span id="health-val">100</span></span><button id="config-btn" style="float:right;background:none;border:1px solid var(--asp-amber,#FFB347);color:var(--asp-sun,#F2A93B);cursor:pointer;font-family:monospace;font-size:0.75rem;padding:0.15rem 0.4rem;border-radius:2px;">⚙ CONFIG</button>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<div class="terminal-body" id="output">
|
| 30 |
+
<div class="boot-sequence" id="boot">
|
| 31 |
+
<span class="line">☼ > INITIAINDIZOWIN / INITIALIZING NEURAL INTERFACE...</span>
|
| 32 |
+
<span class="line">☼ > AABAJICHIGANAN / LOADING NARRATIVE ENGINE...</span>
|
| 33 |
+
<span class="line">☼ > GIIWENAABIK / CONNECTING TO GRADIO SERVER...</span>
|
| 34 |
+
<span class="line success">☼ > MII-GIIWETA / CONNECTION ESTABLISHED</span>
|
| 35 |
+
<span class="line">ᐴ > INAABANDA'IWIN / SELECT GENRE TO BEGIN ᔔ</span>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
<div class="genre-selector" id="genre-selector">
|
| 40 |
+
<div class="genre-option" data-genre="fantasy">
|
| 41 |
+
<span class="icon">☼</span> AADIZOOKAAN / FANTASY
|
| 42 |
+
</div>
|
| 43 |
+
<div class="genre-option" data-genre="scifi">
|
| 44 |
+
<span class="icon">◈</span> ISHPIMING / SCI-FI
|
| 45 |
+
</div>
|
| 46 |
+
<div class="genre-option" data-genre="cyberpunk">
|
| 47 |
+
<span class="icon">◆</span> MASHKODEWAAZIBI / CYBERPUNK
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<div class="choices-container" id="choices" style="display: none;">
|
| 52 |
+
<!-- Choices injected here -->
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div class="input-line" id="input-line" style="display: none;">
|
| 56 |
+
<span class="prompt-char">ᐴ></span>
|
| 57 |
+
<input type="text" id="cmd-input" autocomplete="off" spellcheck="false" />
|
| 58 |
+
<span class="cursor">_</span>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<div class="terminal-footer">
|
| 62 |
+
<span>ᐴ TinyBard · FastAPI + Gradio + MCP · llama.cpp ᔔ</span>
|
| 63 |
+
<span id="model-status">☘ MODEL: AABAJICHIGE / LOADING...</span>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<div class="crt-reflection"></div>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<script type="module" src="/static/main.js"></script>
|
| 72 |
+
|
| 73 |
+
<!-- User config modal -->
|
| 74 |
+
<div id="tb-config-modal" class="tb-modal-overlay" style="display:none;">
|
| 75 |
+
<div class="tb-modal">
|
| 76 |
+
<button class="tb-close" id="tb-config-close">✕</button>
|
| 77 |
+
<h3>⚙ Inference Configuration</h3>
|
| 78 |
+
<p style="font-size:0.8rem;color:var(--asp-moss,#588157);">
|
| 79 |
+
Provide your own HuggingFace token to unlock models that need auth.
|
| 80 |
+
Your token lives in browser memory only — never sent anywhere except api.huggingface.co.
|
| 81 |
+
</p>
|
| 82 |
+
<label for="tb-model-input">Model ID</label>
|
| 83 |
+
<input type="text" id="tb-model-input" placeholder="Qwen/Qwen2.5-1.5B-Instruct" />
|
| 84 |
+
|
| 85 |
+
<label for="tb-token-input">HF Token (optional)</label>
|
| 86 |
+
<input type="password" id="tb-token-input" placeholder="hf_..." />
|
| 87 |
+
|
| 88 |
+
<button id="tb-config-save">Save & Reload</button>
|
| 89 |
+
<span id="tb-config-status" style="margin-left:0.5rem;color:var(--asp-moss,#588157);"></span>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
</body>
|
| 93 |
+
</html>
|
static/main.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* TinyBard — Frontend Client
|
| 3 |
+
* Connects to the gr.Server backend via @gradio/client.
|
| 4 |
+
* All game state is managed client-side and passed to the API.
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
const GRADIO_CLIENT_URL = window.location.origin;
|
| 8 |
+
|
| 9 |
+
// ---------------------------------------------------------------------------
|
| 10 |
+
// Game State
|
| 11 |
+
// ---------------------------------------------------------------------------
|
| 12 |
+
let gameState = {
|
| 13 |
+
genre: "",
|
| 14 |
+
step: 0,
|
| 15 |
+
health: 100,
|
| 16 |
+
history: [],
|
| 17 |
+
gameActive: false
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
// ---------------------------------------------------------------------------
|
| 21 |
+
// DOM refs
|
| 22 |
+
// ---------------------------------------------------------------------------
|
| 23 |
+
const output = document.getElementById("output");
|
| 24 |
+
const choicesEl = document.getElementById("choices");
|
| 25 |
+
const genreSelector = document.getElementById("genre-selector");
|
| 26 |
+
const inputLine = document.getElementById("input-line");
|
| 27 |
+
const cmdInput = document.getElementById("cmd-input");
|
| 28 |
+
const healthVal = document.getElementById("health-val");
|
| 29 |
+
const modelStatus = document.getElementById("model-status");
|
| 30 |
+
const boot = document.getElementById("boot");
|
| 31 |
+
|
| 32 |
+
// ---------------------------------------------------------------------------
|
| 33 |
+
// API client — uses FastAPI clean-JSON endpoints
|
| 34 |
+
// ---------------------------------------------------------------------------
|
| 35 |
+
async function checkModelStatus() {
|
| 36 |
+
try {
|
| 37 |
+
const resp = await fetch(`${GRADIO_CLIENT_URL}/api/model_status`);
|
| 38 |
+
if (!resp.ok) return;
|
| 39 |
+
const s = await resp.json();
|
| 40 |
+
const model = s.model || "inference";
|
| 41 |
+
const cd = s.cooldown || { active: false, remaining_seconds: 0, window_seconds: 0 };
|
| 42 |
+
if (cd.active) {
|
| 43 |
+
modelStatus.textContent = `☘ ${model} / COOLDOWN ${cd.remaining_seconds.toFixed(1)}s`;
|
| 44 |
+
modelStatus.style.color = "var(--asp-ember)";
|
| 45 |
+
} else if (model) {
|
| 46 |
+
modelStatus.textContent = `☘ ${model} / READY`;
|
| 47 |
+
modelStatus.style.color = "var(--asp-sun)";
|
| 48 |
+
} else {
|
| 49 |
+
modelStatus.textContent = "☘ NO MODEL / FALLBACK";
|
| 50 |
+
modelStatus.style.color = "var(--asp-frost)";
|
| 51 |
+
}
|
| 52 |
+
} catch {
|
| 53 |
+
modelStatus.textContent = "☘ MODEL: ?";
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// Poll model status every 2s so cooldown countdown updates
|
| 58 |
+
setInterval(checkModelStatus, 2000);
|
| 59 |
+
async function apiCall(endpoint, payload) {
|
| 60 |
+
// Use the FastAPI clean-JSON endpoints (returns a dict directly).
|
| 61 |
+
// /api/game/start -> start_game
|
| 62 |
+
// /api/game/choice -> make_choice
|
| 63 |
+
const path = endpoint === "/start_game"
|
| 64 |
+
? "/api/game/start"
|
| 65 |
+
: "/api/game/choice";
|
| 66 |
+
const resp = await fetch(`${GRADIO_CLIENT_URL}${path}`, {
|
| 67 |
+
method: "POST",
|
| 68 |
+
headers: { "Content-Type": "application/json" },
|
| 69 |
+
body: JSON.stringify(payload),
|
| 70 |
+
});
|
| 71 |
+
if (!resp.ok) {
|
| 72 |
+
throw new Error(`HTTP ${resp.status}`);
|
| 73 |
+
}
|
| 74 |
+
return await resp.json();
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// ---------------------------------------------------------------------------
|
| 78 |
+
// UI Helpers
|
| 79 |
+
// ---------------------------------------------------------------------------
|
| 80 |
+
function scrollToBottom() {
|
| 81 |
+
output.scrollTop = output.scrollHeight;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function appendOutput(html, className = "") {
|
| 85 |
+
const el = document.createElement("div");
|
| 86 |
+
el.className = className;
|
| 87 |
+
el.innerHTML = html;
|
| 88 |
+
output.appendChild(el);
|
| 89 |
+
scrollToBottom();
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function clearBoot() {
|
| 93 |
+
if (boot) boot.remove();
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function updateHealth(hp) {
|
| 97 |
+
gameState.health = hp;
|
| 98 |
+
healthVal.textContent = hp;
|
| 99 |
+
if (hp <= 25) healthVal.style.color = "#ff0040";
|
| 100 |
+
else if (hp <= 50) healthVal.style.color = "#ffb000";
|
| 101 |
+
else healthVal.style.color = "#00ff41";
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function showChoices(choices) {
|
| 105 |
+
choicesEl.innerHTML = "";
|
| 106 |
+
choicesEl.style.display = "flex";
|
| 107 |
+
|
| 108 |
+
choices.forEach((choice, i) => {
|
| 109 |
+
const btn = document.createElement("button");
|
| 110 |
+
btn.className = "choice-btn";
|
| 111 |
+
btn.textContent = choice;
|
| 112 |
+
btn.addEventListener("click", () => handleChoice(choice));
|
| 113 |
+
choicesEl.appendChild(btn);
|
| 114 |
+
});
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
function hideChoices() {
|
| 118 |
+
choicesEl.style.display = "none";
|
| 119 |
+
choicesEl.innerHTML = "";
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function showInput() {
|
| 123 |
+
inputLine.style.display = "flex";
|
| 124 |
+
cmdInput.focus();
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function hideInput() {
|
| 128 |
+
inputLine.style.display = "none";
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
// ---------------------------------------------------------------------------
|
| 132 |
+
// Game Logic
|
| 133 |
+
// ---------------------------------------------------------------------------
|
| 134 |
+
async function startGame(genre) {
|
| 135 |
+
gameState = { genre, step: 0, health: 100, history: [], gameActive: true };
|
| 136 |
+
genreSelector.style.display = "none";
|
| 137 |
+
clearBoot();
|
| 138 |
+
|
| 139 |
+
appendOutput(`<span class="narrator-prefix">> STARTING ${genre.toUpperCase()} ADVENTURE...</span>`, "line amber");
|
| 140 |
+
updateHealth(100);
|
| 141 |
+
|
| 142 |
+
try {
|
| 143 |
+
const data = await apiCall("/start_game", { genre });
|
| 144 |
+
|
| 145 |
+
gameState.step = data.step || 1;
|
| 146 |
+
gameState.history = data.history || [];
|
| 147 |
+
|
| 148 |
+
const storyEl = document.createElement("div");
|
| 149 |
+
storyEl.className = "story-text";
|
| 150 |
+
storyEl.textContent = data.story;
|
| 151 |
+
output.appendChild(storyEl);
|
| 152 |
+
|
| 153 |
+
if (data.game_over) {
|
| 154 |
+
endGame(data);
|
| 155 |
+
} else {
|
| 156 |
+
showChoices(data.choices);
|
| 157 |
+
}
|
| 158 |
+
} catch (e) {
|
| 159 |
+
appendOutput(`<span class="error">ERROR: ${e.message}</span>`, "line error");
|
| 160 |
+
console.error(e);
|
| 161 |
+
}
|
| 162 |
+
scrollToBottom();
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
async function handleChoice(choice) {
|
| 166 |
+
if (!gameState.gameActive) return;
|
| 167 |
+
|
| 168 |
+
hideChoices();
|
| 169 |
+
appendOutput(`<span class="player-action">> You chose: ${choice}</span>`, "player-action");
|
| 170 |
+
|
| 171 |
+
try {
|
| 172 |
+
const data = await apiCall("/make_choice", {
|
| 173 |
+
choice,
|
| 174 |
+
genre: gameState.genre,
|
| 175 |
+
step: gameState.step,
|
| 176 |
+
health: gameState.health,
|
| 177 |
+
history_json: JSON.stringify(gameState.history)
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
gameState.step = data.step || gameState.step + 1;
|
| 181 |
+
gameState.history = data.history || gameState.history;
|
| 182 |
+
updateHealth(data.health ?? gameState.health);
|
| 183 |
+
|
| 184 |
+
const storyEl = document.createElement("div");
|
| 185 |
+
storyEl.className = "story-text";
|
| 186 |
+
storyEl.textContent = data.story;
|
| 187 |
+
output.appendChild(storyEl);
|
| 188 |
+
|
| 189 |
+
if (data.game_over) {
|
| 190 |
+
endGame(data);
|
| 191 |
+
} else {
|
| 192 |
+
showChoices(data.choices);
|
| 193 |
+
}
|
| 194 |
+
} catch (e) {
|
| 195 |
+
appendOutput(`<span class="error">ERROR: ${e.message}</span>`, "line error");
|
| 196 |
+
console.error(e);
|
| 197 |
+
}
|
| 198 |
+
scrollToBottom();
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function endGame(data) {
|
| 202 |
+
gameState.gameActive = false;
|
| 203 |
+
hideChoices();
|
| 204 |
+
|
| 205 |
+
const isWin = data.health > 0;
|
| 206 |
+
const className = isWin ? "game-over-win" : "game-over-lose";
|
| 207 |
+
const label = isWin ? "★ VICTORY ★" : "☠ GAME OVER ☠";
|
| 208 |
+
|
| 209 |
+
appendOutput(`<div class="${className}">${label}<br><small>Final Health: ${data.health}</small></div>`, "");
|
| 210 |
+
|
| 211 |
+
// New game button
|
| 212 |
+
const btn = document.createElement("button");
|
| 213 |
+
btn.className = "new-game-btn";
|
| 214 |
+
btn.textContent = "[ NEW ADVENTURE ]";
|
| 215 |
+
btn.addEventListener("click", resetGame);
|
| 216 |
+
choicesEl.style.display = "flex";
|
| 217 |
+
choicesEl.appendChild(btn);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function resetGame() {
|
| 221 |
+
output.innerHTML = "";
|
| 222 |
+
hideChoices();
|
| 223 |
+
gameState = { genre: "", step: 0, health: 100, history: [], gameActive: false };
|
| 224 |
+
healthVal.textContent = "100";
|
| 225 |
+
healthVal.style.color = "#00ff41";
|
| 226 |
+
genreSelector.style.display = "flex";
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
// ---------------------------------------------------------------------------
|
| 230 |
+
// Event Listeners
|
| 231 |
+
// ---------------------------------------------------------------------------
|
| 232 |
+
document.querySelectorAll(".genre-option").forEach(el => {
|
| 233 |
+
el.addEventListener("click", () => {
|
| 234 |
+
const genre = el.dataset.genre;
|
| 235 |
+
startGame(genre);
|
| 236 |
+
});
|
| 237 |
+
});
|
| 238 |
+
|
| 239 |
+
cmdInput.addEventListener("keydown", (e) => {
|
| 240 |
+
if (e.key === "Enter" && cmdInput.value.trim()) {
|
| 241 |
+
handleChoice(cmdInput.value.trim());
|
| 242 |
+
cmdInput.value = "";
|
| 243 |
+
}
|
| 244 |
+
});
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
// ---------------------------------------------------------------------------
|
| 248 |
+
// User Config Modal
|
| 249 |
+
// ---------------------------------------------------------------------------
|
| 250 |
+
const configBtn = document.getElementById('config-btn');
|
| 251 |
+
const configModal = document.getElementById('tb-config-modal');
|
| 252 |
+
const configClose = document.getElementById('tb-config-close');
|
| 253 |
+
const configSave = document.getElementById('tb-config-save');
|
| 254 |
+
const modelInput = document.getElementById('tb-model-input');
|
| 255 |
+
const tokenInput = document.getElementById('tb-token-input');
|
| 256 |
+
const configStatus = document.getElementById('tb-config-status');
|
| 257 |
+
|
| 258 |
+
if (configBtn && configModal) {
|
| 259 |
+
configBtn.addEventListener('click', async () => {
|
| 260 |
+
const cfg = await fetch('/api/config').then(r => r.json());
|
| 261 |
+
modelInput.value = cfg.model || '';
|
| 262 |
+
tokenInput.value = '';
|
| 263 |
+
configStatus.textContent = '';
|
| 264 |
+
configModal.style.display = 'flex';
|
| 265 |
+
});
|
| 266 |
+
|
| 267 |
+
configClose.addEventListener('click', () => {
|
| 268 |
+
configModal.style.display = 'none';
|
| 269 |
+
});
|
| 270 |
+
|
| 271 |
+
configModal.addEventListener('click', (e) => {
|
| 272 |
+
if (e.target === configModal) configModal.style.display = 'none';
|
| 273 |
+
});
|
| 274 |
+
|
| 275 |
+
configSave.addEventListener('click', async () => {
|
| 276 |
+
const body = {};
|
| 277 |
+
if (modelInput.value.trim()) body.model = modelInput.value.trim();
|
| 278 |
+
if (tokenInput.value.trim()) body.hf_token = tokenInput.value.trim();
|
| 279 |
+
|
| 280 |
+
const resp = await fetch('/api/config', {
|
| 281 |
+
method: 'POST',
|
| 282 |
+
headers: { 'Content-Type': 'application/json' },
|
| 283 |
+
body: JSON.stringify(body),
|
| 284 |
+
});
|
| 285 |
+
const data = await resp.json();
|
| 286 |
+
configStatus.textContent = data.status === 'ok' ? '✓ Saved' : '✗ Failed';
|
| 287 |
+
configStatus.style.color = data.status === 'ok' ? 'var(--asp-sun)' : 'var(--asp-ember)';
|
| 288 |
+
setTimeout(() => { configModal.style.display = 'none'; }, 800);
|
| 289 |
+
});
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
// Boot
|
| 293 |
+
// ---------------------------------------------------------------------------
|
| 294 |
+
(async () => {
|
| 295 |
+
await checkModelStatus();
|
| 296 |
+
})();
|
static/style.css
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =========================================================================
|
| 2 |
+
TinyBard — Anishinaabe Solarpunk CRT Terminal
|
| 3 |
+
----------------------------------------------------------------------------
|
| 4 |
+
A retro CRT terminal reinterpreted through Anishinaabe + Solarpunk lenses.
|
| 5 |
+
Phosphor glow becomes "fire-fly light"; scanlines become "birch-bark rings";
|
| 6 |
+
the cabinet's amber becomes rising sun; the screen is the lake (Gichigami)
|
| 7 |
+
at dusk, framed in cedar and copper.
|
| 8 |
+
========================================================================= */
|
| 9 |
+
|
| 10 |
+
/* === ANISHINAABE-SOLARPUNK DESIGN TOKENS ============================== */
|
| 11 |
+
:root {
|
| 12 |
+
/* Solarpunk palette */
|
| 13 |
+
--asp-sky: #5BA4D9;
|
| 14 |
+
--asp-water: #1B4965;
|
| 15 |
+
--asp-ice: #BEE9E8;
|
| 16 |
+
--asp-frost: #CAF0F8;
|
| 17 |
+
--asp-sun: #F2A93B;
|
| 18 |
+
--asp-sunlight: #FFB347;
|
| 19 |
+
--asp-ember: #E76F51;
|
| 20 |
+
--asp-birch: #F5F1E8;
|
| 21 |
+
--asp-terra: #C8553D;
|
| 22 |
+
--asp-earth: #8B3A1F;
|
| 23 |
+
--asp-moss: #588157;
|
| 24 |
+
--asp-forest: #3D6A4A;
|
| 25 |
+
--asp-spruce: #1B4332;
|
| 26 |
+
--asp-night: #0F1A2C;
|
| 27 |
+
--asp-ash: #3A2E2A;
|
| 28 |
+
--asp-stone: #A89F91;
|
| 29 |
+
--asp-ink: #1A1F2E;
|
| 30 |
+
|
| 31 |
+
/* CRT / terminal legacy (re-anchored to solarpunk palette) */
|
| 32 |
+
--green: var(--asp-sun); /* phosphor -> rising sun amber */
|
| 33 |
+
--green-dim: #C97F1E; /* dim sun */
|
| 34 |
+
--green-dark: #5E3A0E; /* cedar-bark */
|
| 35 |
+
--green-glow: rgba(242, 169, 59, 0.18);
|
| 36 |
+
--amber: var(--asp-sunlight);
|
| 37 |
+
--red: var(--asp-ember);
|
| 38 |
+
--bg: var(--asp-night);
|
| 39 |
+
--terminal-bg: linear-gradient(160deg, #0F1A2C 0%, #1A2A1F 100%);
|
| 40 |
+
--scanline-opacity: 0.04;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 44 |
+
|
| 45 |
+
@font-face {
|
| 46 |
+
font-family: 'MonoFallback';
|
| 47 |
+
src: local('JetBrains Mono'), local('Fira Code'), local('SF Mono'), local('Menlo'), local('monospace');
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
body {
|
| 51 |
+
background:
|
| 52 |
+
radial-gradient(ellipse at top, #1B4965 0%, transparent 60%),
|
| 53 |
+
radial-gradient(ellipse at bottom right, #1B4332 0%, transparent 70%),
|
| 54 |
+
var(--bg);
|
| 55 |
+
font-family: 'MonoFallback', monospace;
|
| 56 |
+
color: var(--asp-birch);
|
| 57 |
+
min-height: 100vh;
|
| 58 |
+
display: flex;
|
| 59 |
+
flex-direction: column;
|
| 60 |
+
align-items: center;
|
| 61 |
+
justify-content: center;
|
| 62 |
+
overflow-x: hidden;
|
| 63 |
+
overflow-y: auto;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/* === ANISHINAABE-SOLARPUNK BANNER ===================================== */
|
| 67 |
+
.asp-banner {
|
| 68 |
+
width: 90vw;
|
| 69 |
+
max-width: 900px;
|
| 70 |
+
display: flex;
|
| 71 |
+
align-items: center;
|
| 72 |
+
justify-content: center;
|
| 73 |
+
gap: 0.7em;
|
| 74 |
+
padding: 12px 18px;
|
| 75 |
+
margin-top: 18px;
|
| 76 |
+
background: linear-gradient(95deg, var(--asp-sky) 0%, var(--asp-water) 100%);
|
| 77 |
+
color: var(--asp-birch);
|
| 78 |
+
border: 1px solid rgba(255, 179, 71, 0.3);
|
| 79 |
+
border-radius: 10px 10px 0 0;
|
| 80 |
+
font-family: Georgia, 'Iowan Old Style', serif;
|
| 81 |
+
letter-spacing: 0.5px;
|
| 82 |
+
text-shadow: 0 1px 2px rgba(15, 26, 44, 0.45);
|
| 83 |
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
| 84 |
+
}
|
| 85 |
+
.asp-banner .syll { font-size: 1.5em; opacity: 0.9; }
|
| 86 |
+
.asp-banner .title { font-size: 1.05em; font-weight: 600; }
|
| 87 |
+
.asp-banner .glyph {
|
| 88 |
+
display: inline-block;
|
| 89 |
+
transform: translateY(-1px);
|
| 90 |
+
font-size: 1.15em;
|
| 91 |
+
color: var(--asp-sunlight);
|
| 92 |
+
}
|
| 93 |
+
.asp-banner .subtitle {
|
| 94 |
+
color: var(--asp-frost);
|
| 95 |
+
font-size: 0.85em;
|
| 96 |
+
font-style: italic;
|
| 97 |
+
opacity: 0.85;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/* === CRT MONITOR (cedar-copper cabinet) ================================ */
|
| 101 |
+
.crt-container {
|
| 102 |
+
width: 90vw;
|
| 103 |
+
max-width: 900px;
|
| 104 |
+
height: 75vh;
|
| 105 |
+
max-height: 660px;
|
| 106 |
+
position: relative;
|
| 107 |
+
background:
|
| 108 |
+
linear-gradient(180deg, #8B3A1F 0%, #5E2710 100%);
|
| 109 |
+
border-radius: 18px 18px 30px 30px;
|
| 110 |
+
padding: 22px;
|
| 111 |
+
box-shadow:
|
| 112 |
+
0 0 80px rgba(91, 164, 217, 0.12),
|
| 113 |
+
inset 0 0 60px rgba(0, 0, 0, 0.5),
|
| 114 |
+
0 20px 60px rgba(0, 0, 0, 0.8);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/* decorative bezel rivets — medicine wheel */
|
| 118 |
+
.crt-container::before,
|
| 119 |
+
.crt-container::after {
|
| 120 |
+
content: "◈";
|
| 121 |
+
position: absolute;
|
| 122 |
+
top: 8px;
|
| 123 |
+
color: var(--asp-sunlight);
|
| 124 |
+
opacity: 0.6;
|
| 125 |
+
font-size: 0.9em;
|
| 126 |
+
}
|
| 127 |
+
.crt-container::before { left: 12px; }
|
| 128 |
+
.crt-container::after { right: 12px; }
|
| 129 |
+
|
| 130 |
+
.crt-screen {
|
| 131 |
+
width: 100%;
|
| 132 |
+
height: 100%;
|
| 133 |
+
background: var(--terminal-bg);
|
| 134 |
+
border-radius: 12px;
|
| 135 |
+
overflow: hidden;
|
| 136 |
+
position: relative;
|
| 137 |
+
border: 2px solid #1B4332;
|
| 138 |
+
box-shadow:
|
| 139 |
+
inset 0 0 80px rgba(27, 73, 50, 0.5),
|
| 140 |
+
inset 0 0 4px rgba(255, 179, 71, 0.2);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/* Scanlines — birch-bark rings reinterpreted */
|
| 144 |
+
.scanlines {
|
| 145 |
+
position: absolute;
|
| 146 |
+
top: 0; left: 0; right: 0; bottom: 0;
|
| 147 |
+
background: repeating-linear-gradient(
|
| 148 |
+
0deg,
|
| 149 |
+
transparent,
|
| 150 |
+
transparent 2px,
|
| 151 |
+
rgba(91, 164, 217, var(--scanline-opacity)) 2px,
|
| 152 |
+
rgba(91, 164, 217, var(--scanline-opacity)) 4px
|
| 153 |
+
);
|
| 154 |
+
pointer-events: none;
|
| 155 |
+
z-index: 10;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/* Vignette — horizon glow */
|
| 159 |
+
.vignette {
|
| 160 |
+
position: absolute;
|
| 161 |
+
top: 0; left: 0; right: 0; bottom: 0;
|
| 162 |
+
background: radial-gradient(
|
| 163 |
+
ellipse at 50% 30%,
|
| 164 |
+
rgba(242, 169, 59, 0.05) 0%,
|
| 165 |
+
transparent 50%,
|
| 166 |
+
rgba(15, 26, 44, 0.55) 100%
|
| 167 |
+
);
|
| 168 |
+
pointer-events: none;
|
| 169 |
+
z-index: 10;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
/* CRT reflection/glare — frost on glass */
|
| 173 |
+
.crt-reflection {
|
| 174 |
+
position: absolute;
|
| 175 |
+
top: 5%;
|
| 176 |
+
left: 5%;
|
| 177 |
+
width: 40%;
|
| 178 |
+
height: 30%;
|
| 179 |
+
background: linear-gradient(
|
| 180 |
+
135deg,
|
| 181 |
+
rgba(190, 233, 232, 0.05) 0%,
|
| 182 |
+
transparent 100%
|
| 183 |
+
);
|
| 184 |
+
pointer-events: none;
|
| 185 |
+
z-index: 20;
|
| 186 |
+
border-radius: 50%;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
/* === TERMINAL LAYOUT ================================================== */
|
| 190 |
+
.terminal {
|
| 191 |
+
width: 100%;
|
| 192 |
+
height: 100%;
|
| 193 |
+
display: flex;
|
| 194 |
+
flex-direction: column;
|
| 195 |
+
padding: 15px 20px;
|
| 196 |
+
position: relative;
|
| 197 |
+
z-index: 5;
|
| 198 |
+
color: var(--asp-birch);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.terminal-header {
|
| 202 |
+
display: flex;
|
| 203 |
+
justify-content: space-between;
|
| 204 |
+
align-items: center;
|
| 205 |
+
padding-bottom: 10px;
|
| 206 |
+
border-bottom: 1px solid rgba(91, 164, 217, 0.3);
|
| 207 |
+
margin-bottom: 10px;
|
| 208 |
+
font-size: 0.75rem;
|
| 209 |
+
color: var(--asp-frost);
|
| 210 |
+
text-transform: uppercase;
|
| 211 |
+
letter-spacing: 2px;
|
| 212 |
+
font-family: Georgia, serif;
|
| 213 |
+
}
|
| 214 |
+
.terminal-header .status {
|
| 215 |
+
color: var(--asp-sunlight);
|
| 216 |
+
text-shadow: 0 0 8px rgba(242, 169, 59, 0.35);
|
| 217 |
+
}
|
| 218 |
+
#health-val { color: var(--asp-sun); font-weight: bold; }
|
| 219 |
+
|
| 220 |
+
/* === TERMINAL OUTPUT ================================================== */
|
| 221 |
+
.terminal-body {
|
| 222 |
+
flex: 1;
|
| 223 |
+
overflow-y: auto;
|
| 224 |
+
overflow-x: hidden;
|
| 225 |
+
padding-right: 10px;
|
| 226 |
+
font-size: 0.88rem;
|
| 227 |
+
line-height: 1.65;
|
| 228 |
+
text-shadow: 0 0 6px rgba(242, 169, 59, 0.15);
|
| 229 |
+
scroll-behavior: smooth;
|
| 230 |
+
}
|
| 231 |
+
.terminal-body::-webkit-scrollbar { width: 6px; }
|
| 232 |
+
.terminal-body::-webkit-scrollbar-track { background: transparent; }
|
| 233 |
+
.terminal-body::-webkit-scrollbar-thumb {
|
| 234 |
+
background: rgba(91, 164, 217, 0.3);
|
| 235 |
+
border-radius: 3px;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.line {
|
| 239 |
+
display: block;
|
| 240 |
+
margin-bottom: 3px;
|
| 241 |
+
opacity: 0;
|
| 242 |
+
animation: typeIn 0.5s forwards;
|
| 243 |
+
}
|
| 244 |
+
.line.success { color: var(--asp-sunlight); }
|
| 245 |
+
.line.error { color: var(--asp-ember); }
|
| 246 |
+
.line.amber { color: var(--asp-sun); }
|
| 247 |
+
|
| 248 |
+
.story-text {
|
| 249 |
+
display: block;
|
| 250 |
+
margin: 14px 0;
|
| 251 |
+
padding: 12px 16px;
|
| 252 |
+
border-left: 2px solid var(--asp-sun);
|
| 253 |
+
background: rgba(91, 164, 217, 0.06);
|
| 254 |
+
border-radius: 0 4px 4px 0;
|
| 255 |
+
white-space: pre-wrap;
|
| 256 |
+
color: var(--asp-birch);
|
| 257 |
+
animation: fadeSlideIn 0.6s ease-out;
|
| 258 |
+
box-shadow: inset 0 0 20px rgba(15, 26, 44, 0.3);
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
.narrator-prefix {
|
| 262 |
+
color: var(--asp-sun);
|
| 263 |
+
font-weight: bold;
|
| 264 |
+
text-shadow: 0 0 8px rgba(242, 169, 59, 0.4);
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.player-action {
|
| 268 |
+
display: block;
|
| 269 |
+
color: var(--asp-frost);
|
| 270 |
+
margin: 5px 0;
|
| 271 |
+
font-style: italic;
|
| 272 |
+
border-left: 2px dashed rgba(190, 233, 232, 0.4);
|
| 273 |
+
padding-left: 8px;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.game-over-win {
|
| 277 |
+
color: var(--asp-sunlight);
|
| 278 |
+
text-shadow: 0 0 20px rgba(255, 179, 71, 0.6);
|
| 279 |
+
font-size: 1.15rem;
|
| 280 |
+
text-align: center;
|
| 281 |
+
padding: 20px;
|
| 282 |
+
animation: pulse 2s infinite;
|
| 283 |
+
font-family: Georgia, serif;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.game-over-lose {
|
| 287 |
+
color: var(--asp-ember);
|
| 288 |
+
text-shadow: 0 0 20px rgba(231, 111, 81, 0.5);
|
| 289 |
+
font-size: 1.15rem;
|
| 290 |
+
text-align: center;
|
| 291 |
+
padding: 20px;
|
| 292 |
+
animation: pulse 2s infinite;
|
| 293 |
+
font-family: Georgia, serif;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
/* === GENRE SELECTOR =================================================== */
|
| 297 |
+
.genre-selector {
|
| 298 |
+
display: flex;
|
| 299 |
+
gap: 15px;
|
| 300 |
+
padding: 15px 0;
|
| 301 |
+
justify-content: center;
|
| 302 |
+
flex-wrap: wrap;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.genre-option {
|
| 306 |
+
padding: 12px 25px;
|
| 307 |
+
border: 1px solid rgba(91, 164, 217, 0.4);
|
| 308 |
+
border-radius: 4px;
|
| 309 |
+
cursor: pointer;
|
| 310 |
+
transition: all 0.2s;
|
| 311 |
+
text-transform: uppercase;
|
| 312 |
+
letter-spacing: 1.5px;
|
| 313 |
+
font-size: 0.8rem;
|
| 314 |
+
color: var(--asp-frost);
|
| 315 |
+
background: rgba(15, 26, 44, 0.4);
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.genre-option:hover {
|
| 319 |
+
background: linear-gradient(95deg, rgba(242, 169, 59, 0.2) 0%, rgba(91, 164, 217, 0.2) 100%);
|
| 320 |
+
color: var(--asp-sunlight);
|
| 321 |
+
border-color: var(--asp-sun);
|
| 322 |
+
text-shadow: 0 0 12px rgba(242, 169, 59, 0.6);
|
| 323 |
+
box-shadow: 0 0 18px rgba(242, 169, 59, 0.3);
|
| 324 |
+
transform: translateY(-1px);
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
.genre-option .icon { margin-right: 8px; font-size: 1.1em; color: var(--asp-sun); }
|
| 328 |
+
|
| 329 |
+
/* === CHOICE BUTTONS =================================================== */
|
| 330 |
+
.choices-container {
|
| 331 |
+
display: flex;
|
| 332 |
+
flex-direction: column;
|
| 333 |
+
gap: 8px;
|
| 334 |
+
padding: 10px 0;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.choice-btn {
|
| 338 |
+
display: block;
|
| 339 |
+
padding: 10px 15px;
|
| 340 |
+
border: 1px solid rgba(91, 164, 217, 0.4);
|
| 341 |
+
border-radius: 3px;
|
| 342 |
+
background: transparent;
|
| 343 |
+
color: var(--asp-frost);
|
| 344 |
+
font-family: 'MonoFallback', monospace;
|
| 345 |
+
font-size: 0.82rem;
|
| 346 |
+
cursor: pointer;
|
| 347 |
+
text-align: left;
|
| 348 |
+
transition: all 0.15s;
|
| 349 |
+
text-shadow: 0 0 3px rgba(91, 164, 217, 0.25);
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.choice-btn:hover {
|
| 353 |
+
background: rgba(91, 164, 217, 0.1);
|
| 354 |
+
border-color: var(--asp-sun);
|
| 355 |
+
color: var(--asp-sunlight);
|
| 356 |
+
padding-left: 25px;
|
| 357 |
+
box-shadow: 0 0 12px rgba(242, 169, 59, 0.2);
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
.choice-btn::before {
|
| 361 |
+
content: '☼ ';
|
| 362 |
+
color: var(--asp-sun);
|
| 363 |
+
margin-right: 4px;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
/* === INPUT LINE ======================================================= */
|
| 367 |
+
.input-line {
|
| 368 |
+
display: flex;
|
| 369 |
+
align-items: center;
|
| 370 |
+
padding: 10px 0 5px;
|
| 371 |
+
border-top: 1px solid rgba(91, 164, 217, 0.3);
|
| 372 |
+
margin-top: 5px;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
.prompt-char {
|
| 376 |
+
color: var(--asp-sun);
|
| 377 |
+
margin-right: 8px;
|
| 378 |
+
font-weight: bold;
|
| 379 |
+
text-shadow: 0 0 8px rgba(242, 169, 59, 0.5);
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
#cmd-input {
|
| 383 |
+
flex: 1;
|
| 384 |
+
background: transparent;
|
| 385 |
+
border: none;
|
| 386 |
+
color: var(--asp-birch);
|
| 387 |
+
font-family: 'MonoFallback', monospace;
|
| 388 |
+
font-size: 0.88rem;
|
| 389 |
+
outline: none;
|
| 390 |
+
caret-color: transparent;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
.cursor {
|
| 394 |
+
color: var(--asp-sun);
|
| 395 |
+
text-shadow: 0 0 6px rgba(242, 169, 59, 0.6);
|
| 396 |
+
animation: blink 0.8s step-end infinite;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
/* === TERMINAL FOOTER ================================================== */
|
| 400 |
+
.terminal-footer {
|
| 401 |
+
display: flex;
|
| 402 |
+
justify-content: space-between;
|
| 403 |
+
padding-top: 8px;
|
| 404 |
+
border-top: 1px solid rgba(91, 164, 217, 0.2);
|
| 405 |
+
margin-top: 8px;
|
| 406 |
+
font-size: 0.65rem;
|
| 407 |
+
color: var(--asp-stone);
|
| 408 |
+
letter-spacing: 1.5px;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
#model-status { color: var(--asp-moss); }
|
| 412 |
+
|
| 413 |
+
/* === HEALTH BAR ====================================================== */
|
| 414 |
+
.health-bar-visual {
|
| 415 |
+
width: 100%;
|
| 416 |
+
height: 4px;
|
| 417 |
+
background: rgba(15, 26, 44, 0.6);
|
| 418 |
+
border-radius: 2px;
|
| 419 |
+
margin-top: 5px;
|
| 420 |
+
overflow: hidden;
|
| 421 |
+
border: 1px solid rgba(91, 164, 217, 0.3);
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
.health-bar-fill {
|
| 425 |
+
height: 100%;
|
| 426 |
+
background: linear-gradient(90deg, var(--asp-sun) 0%, var(--asp-sunlight) 100%);
|
| 427 |
+
transition: width 0.5s ease, background-color 0.5s;
|
| 428 |
+
border-radius: 2px;
|
| 429 |
+
box-shadow: 0 0 8px rgba(242, 169, 59, 0.5);
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
.health-bar-fill.low { background: linear-gradient(90deg, var(--asp-ember) 0%, var(--asp-terra) 100%); }
|
| 433 |
+
.health-bar-fill.mid { background: linear-gradient(90deg, var(--asp-sun) 0%, var(--asp-amber, #FFB347) 100%); }
|
| 434 |
+
|
| 435 |
+
/* === NEW GAME BUTTON ================================================= */
|
| 436 |
+
.new-game-btn {
|
| 437 |
+
display: inline-block;
|
| 438 |
+
padding: 10px 22px;
|
| 439 |
+
border: 1px solid rgba(91, 164, 217, 0.4);
|
| 440 |
+
border-radius: 3px;
|
| 441 |
+
background: transparent;
|
| 442 |
+
color: var(--asp-frost);
|
| 443 |
+
font-family: 'MonoFallback', monospace;
|
| 444 |
+
font-size: 0.78rem;
|
| 445 |
+
cursor: pointer;
|
| 446 |
+
text-transform: uppercase;
|
| 447 |
+
letter-spacing: 1.5px;
|
| 448 |
+
margin-top: 10px;
|
| 449 |
+
transition: all 0.2s;
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
.new-game-btn:hover {
|
| 453 |
+
background: linear-gradient(95deg, rgba(242, 169, 59, 0.2) 0%, rgba(91, 164, 217, 0.2) 100%);
|
| 454 |
+
color: var(--asp-sunlight);
|
| 455 |
+
border-color: var(--asp-sun);
|
| 456 |
+
box-shadow: 0 0 16px rgba(242, 169, 59, 0.35);
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
/* === ANIMATIONS ====================================================== */
|
| 460 |
+
@keyframes typeIn {
|
| 461 |
+
from { opacity: 0; transform: translateY(5px); }
|
| 462 |
+
to { opacity: 1; transform: translateY(0); }
|
| 463 |
+
}
|
| 464 |
+
@keyframes fadeSlideIn {
|
| 465 |
+
from { opacity: 0; transform: translateX(-10px); }
|
| 466 |
+
to { opacity: 1; transform: translateX(0); }
|
| 467 |
+
}
|
| 468 |
+
@keyframes blink {
|
| 469 |
+
50% { opacity: 0; }
|
| 470 |
+
}
|
| 471 |
+
@keyframes pulse {
|
| 472 |
+
0%, 100% { opacity: 1; }
|
| 473 |
+
50% { opacity: 0.65; }
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
/* CRT power-on (sun rising) */
|
| 477 |
+
.crt-screen {
|
| 478 |
+
animation: sunRise 0.7s ease-out;
|
| 479 |
+
}
|
| 480 |
+
@keyframes sunRise {
|
| 481 |
+
0% { opacity: 0; transform: scaleY(0.01); box-shadow: inset 0 0 0 rgba(242, 169, 59, 0); }
|
| 482 |
+
40% { opacity: 1; transform: scaleY(0.01); }
|
| 483 |
+
60% { transform: scaleY(1.05); }
|
| 484 |
+
80% { transform: scaleY(0.98); }
|
| 485 |
+
100% { transform: scaleY(1); box-shadow: inset 0 0 80px rgba(27, 73, 50, 0.5); }
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
/* === MOBILE ========================================================== */
|
| 489 |
+
@media (max-width: 600px) {
|
| 490 |
+
.crt-container {
|
| 491 |
+
width: 95vw;
|
| 492 |
+
height: 78vh;
|
| 493 |
+
padding: 12px;
|
| 494 |
+
border-radius: 14px;
|
| 495 |
+
}
|
| 496 |
+
.genre-selector { flex-direction: column; gap: 8px; }
|
| 497 |
+
.genre-option { text-align: center; }
|
| 498 |
+
.terminal-body { font-size: 0.78rem; }
|
| 499 |
+
.asp-banner { font-size: 0.85em; }
|
| 500 |
+
}
|