diff --git a/chat/app.py b/chat/app.py index 66990d8..0ac28ef 100644 --- a/chat/app.py +++ b/chat/app.py @@ -1,6 +1,37 @@ -from fastapi import FastAPI +from __future__ import annotations +from contextlib import asynccontextmanager +from pathlib import Path -app = FastAPI(title="chat") +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from chat.config import load_settings +from chat.db.migrate import apply_migrations + +# Trigger handler registration: +import chat.state.entities # noqa: F401 +import chat.state.edges # noqa: F401 +import chat.state.memory # noqa: F401 +import chat.state.world # noqa: F401 + +from chat.web.bots import router as bots_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + settings = load_settings() + settings.db_path.parent.mkdir(parents=True, exist_ok=True) + apply_migrations(settings.db_path) + app.state.settings = settings + yield + + +app = FastAPI(title="chat", lifespan=lifespan) + +STATIC_DIR = Path(__file__).resolve().parent / "static" +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + +app.include_router(bots_router) @app.get("/health") diff --git a/chat/static/app.css b/chat/static/app.css new file mode 100644 index 0000000..96a0296 --- /dev/null +++ b/chat/static/app.css @@ -0,0 +1,37 @@ +* { box-sizing: border-box; } +body { + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; + margin: 0; + color: #1c1c1c; + background: #fafafa; +} +.topbar { + padding: 12px 24px; + border-bottom: 1px solid #e5e5e5; + background: #fff; +} +.brand { font-weight: 600; text-decoration: none; color: inherit; } +.container { max-width: 720px; margin: 24px auto; padding: 0 16px; } +h1 { margin-top: 0; } +.page-header { display: flex; align-items: center; justify-content: space-between; } +.btn, button { + display: inline-block; padding: 8px 14px; + border: 1px solid #444; background: #1c1c1c; color: #fff; + border-radius: 4px; text-decoration: none; cursor: pointer; + font: inherit; +} +.bot-form label { display: block; margin-bottom: 14px; } +.bot-form label span { display: block; font-weight: 600; margin-bottom: 4px; } +.bot-form input[type=text], .bot-form textarea { + width: 100%; padding: 6px 8px; font: inherit; + border: 1px solid #ccc; border-radius: 3px; background: #fff; +} +.bot-form small { display: block; color: #666; margin-top: 2px; } +.bot-list { list-style: none; padding: 0; } +.bot-list li { padding: 8px 0; border-bottom: 1px solid #eee; } +.muted { color: #666; } +.error { + padding: 8px 12px; border: 1px solid #c33; background: #fdecea; + color: #a00; border-radius: 3px; +} +code { font-family: ui-monospace, "SF Mono", Menlo, monospace; } diff --git a/chat/templates/base.html b/chat/templates/base.html new file mode 100644 index 0000000..6c86104 --- /dev/null +++ b/chat/templates/base.html @@ -0,0 +1,18 @@ + + +
+ + +{{ error }}
+{% endif %} + +{% endblock %} diff --git a/chat/templates/bot_list.html b/chat/templates/bot_list.html new file mode 100644 index 0000000..f3b4562 --- /dev/null +++ b/chat/templates/bot_list.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Bots - chat{% endblock %} +{% block content %} +No bots yet. Create your first bot.
+{% endif %} +{% endblock %} diff --git a/chat/web/__init__.py b/chat/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/web/bots.py b/chat/web/bots.py new file mode 100644 index 0000000..bbf077d --- /dev/null +++ b/chat/web/bots.py @@ -0,0 +1,116 @@ +from __future__ import annotations +import sqlite3 +from pathlib import Path +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import RedirectResponse, HTMLResponse +from fastapi.templating import Jinja2Templates + +from chat.eventlog.log import append_event +from chat.eventlog.projector import project +from chat.state.entities import list_bots + +TEMPLATES = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates")) + +router = APIRouter() + +REQUIRED_FIELDS = ("id", "name", "persona", "initial_relationship_to_you", "kickoff_prose") + + +def get_conn(request: Request): + settings = request.app.state.settings + db_path: Path = settings.db_path + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path, check_same_thread=False) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + try: + yield conn + conn.commit() + finally: + conn.close() + + +def _split_voice_samples(text: str) -> list[str]: + if not text or not text.strip(): + return [] + # Split on a line containing only "---" (with optional surrounding whitespace). + parts: list[str] = [] + buf: list[str] = [] + for line in text.splitlines(): + if line.strip() == "---": + if buf: + parts.append("\n".join(buf).strip()) + buf = [] + continue + buf.append(line) + if buf: + parts.append("\n".join(buf).strip()) + return [p for p in parts if p] + + +def _split_traits(text: str) -> list[str]: + if not text or not text.strip(): + return [] + items: list[str] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + if "," in line: + items.extend(p.strip() for p in line.split(",")) + else: + items.append(line) + return [t for t in items if t] + + +@router.get("/bots", response_class=HTMLResponse) +async def bots_list(request: Request, conn=Depends(get_conn)): + bots = list_bots(conn) + return TEMPLATES.TemplateResponse(request, "bot_list.html", {"bots": bots}) + + +@router.get("/bots/new", response_class=HTMLResponse) +async def bot_form(request: Request): + return TEMPLATES.TemplateResponse(request, "bot_form.html", {"values": {}, "error": None}) + + +@router.post("/bots/new") +async def bot_create( + request: Request, + id: str = Form(""), + name: str = Form(""), + persona: str = Form(""), + voice_samples: str = Form(""), + traits: str = Form(""), + backstory: str = Form(""), + initial_relationship_to_you: str = Form(""), + kickoff_prose: str = Form(""), + conn=Depends(get_conn), +): + values = { + "id": id, + "name": name, + "persona": persona, + "voice_samples": voice_samples, + "traits": traits, + "backstory": backstory, + "initial_relationship_to_you": initial_relationship_to_you, + "kickoff_prose": kickoff_prose, + } + missing = [f for f in REQUIRED_FIELDS if not values[f].strip()] + if missing: + raise HTTPException(status_code=400, detail=f"missing required: {', '.join(missing)}") + + payload = { + "id": id.strip(), + "name": name.strip(), + "persona": persona.strip(), + "voice_samples": _split_voice_samples(voice_samples), + "traits": _split_traits(traits), + "backstory": backstory.strip(), + "initial_relationship_to_you": initial_relationship_to_you.strip(), + "kickoff_prose": kickoff_prose.strip(), + } + append_event(conn, kind="bot_authored", payload=payload) + project(conn) + return RedirectResponse(url=f"/bots/{payload['id']}/kickoff", status_code=303) diff --git a/pyproject.toml b/pyproject.toml index c0593a9..b72bc90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "tiktoken>=0.7", "jinja2>=3.1", "aiosqlite>=0.20", + "python-multipart>=0.0.9", ] [project.optional-dependencies] diff --git a/tests/test_bot_authoring.py b/tests/test_bot_authoring.py new file mode 100644 index 0000000..5b8a82d --- /dev/null +++ b/tests/test_bot_authoring.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from chat.app import app + + +@pytest.fixture +def client(tmp_path, monkeypatch): + config_path = tmp_path / "config.toml" + config_path.write_text('featherless_api_key = "test"\n') + monkeypatch.setenv("CHAT_CONFIG_PATH", str(config_path)) + monkeypatch.setenv("CHAT_DB_PATH", str(tmp_path / "test.db")) + with TestClient(app) as c: + yield c + + +def test_get_new_bot_form_renders(client): + response = client.get("/bots/new") + assert response.status_code == 200 + body = response.text.lower() + assert "