Files
family_home_lab/dsh/app.py

373 lines
14 KiB
Python

"""DeepSeek Harness (dsh) — per-user chat instance.
A single-user FastAPI chat web app that streams answers from OmniRoute
(http://192.168.20.13:20129/v1, OpenAI-compatible). One instance per family
member on .13 (sam:3081, jo:3082, harry:3083, finn:3084). No cross-user
account system — the console routes each user to their own instance.
Iframe-friendly so the console can embed it inline:
- sets its own CSP `frame-ancestors https://console.lab.audasmedia.com.au`
- does NOT send X-Frame-Options: DENY
"""
from __future__ import annotations
import html as _html
import json
import os
import base64
import re
import threading
from pathlib import Path
from typing import Annotated
from uuid import uuid4
from datetime import datetime, timezone
import httpx
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
BASE_DIR = Path(__file__).resolve().parent
# --- config from env ---
USER_NAME = os.getenv("DSH_USER", "user")
DEFAULT_SLOT = os.getenv("DSH_SLOT", "Welcome to DeepSeek Harness")
LLM_BASE = os.getenv("DSH_LLM_BASE", "http://192.168.20.13:20129/v1")
LLM_MODEL = os.getenv("DSH_LLM_MODEL", "auto/best-chat")
LLM_KEY = os.getenv("DSH_LLM_KEY", "")
SYSTEM_PROMPT = os.getenv(
"DSH_SYSTEM_PROMPT",
f"You are {USER_NAME}'s helpful DeepSeek assistant on the family home lab. "
"Be clear, safe and concise. Never expose server file contents or system "
"secrets. If asked something unsafe, decline politely.",
)
# --- vision (image ingest) -> OpenRouter gpt-5 (works; OmniRoute auto/best-vision
# image route is currently unreliable). Sent as a separate provider call. ---
VISION_BASE = os.getenv("DSH_VISION_BASE", "https://openrouter.ai/api/v1")
VISION_MODEL = os.getenv("DSH_VISION_MODEL", "openai/gpt-5")
VISION_KEY = os.getenv("DSH_VISION_KEY", "")
IMAGE_MODEL = os.getenv("DSH_IMAGE_MODEL", "openai/gpt-image-1")
# --- per-user conversations (multiple sessions, persist in the history volume) ---
HISTORY_LIMIT = 60
HISTORY_DIR = os.getenv("DSH_HISTORY_DIR", "/workspace")
SESSIONS_DIR = os.path.join(HISTORY_DIR, "sessions")
_history_lock = threading.Lock()
def _spath(sid):
safe = re.sub(r"[^A-Za-z0-9_-]", "", sid or "") or "main"
return os.path.join(SESSIONS_DIR, f"{safe}.json")
def _sdir():
os.makedirs(SESSIONS_DIR, exist_ok=True)
def list_sessions():
_sdir()
out = []
for f in os.listdir(SESSIONS_DIR):
if not f.endswith(".json"):
continue
try:
with open(os.path.join(SESSIONS_DIR, f), encoding="utf-8") as fh:
d = json.load(fh)
out.append({
"id": d.get("id", f[:-5]),
"name": d.get("name", "Conversation")[:40],
"updated": d.get("updated", ""),
"count": len(d.get("messages", [])),
})
except Exception:
continue
out.sort(key=lambda x: x["updated"] or "", reverse=True)
return out
def load_session(sid):
try:
with open(_spath(sid), encoding="utf-8") as fh:
d = json.load(fh)
msgs = [m for m in d.get("messages", []) if m.get("role") in ("user", "assistant")]
return {"id": d.get("id", sid or "main"), "name": d.get("name", "Conversation"),
"messages": msgs[-HISTORY_LIMIT:],
"model": d.get("model") or LLM_MODEL}
except Exception:
return {"id": sid or "main", "name": "Conversation", "messages": [],
"model": LLM_MODEL}
def save_session(sid, msgs, model=None):
_sdir()
sid = (sid or "main")
with _history_lock:
d = load_session(sid)
if model:
d["model"] = model
d["messages"] = msgs[-HISTORY_LIMIT:]
d["updated"] = datetime.now(timezone.utc).isoformat()
if not d.get("name") or d["name"] in ("Conversation", "New conversation"):
for m in msgs:
if m.get("role") == "user" and m.get("content"):
d["name"] = m["content"][:40]
break
d["id"] = sid
try:
with open(_spath(sid), "w", encoding="utf-8") as fh:
json.dump(d, fh, ensure_ascii=False)
except Exception as exc: # noqa: BLE001
print(f"[dsh] session save failed: {exc}")
app = FastAPI(title=f"DeepSeek Harness — {USER_NAME}", docs_url=None)
# --- plugin-style tools (Phase 1: web + document summaries) ---
WORKSPACE_DIR = os.getenv("DSH_WORKSPACE", "/workspace")
_SUM_HEAD = (
"You are a friendly family assistant. Summarise the given content into "
"clear, concise bullet points in plain language. Keep it under ~200 words."
)
def _strip_html(s: str) -> str:
s = re.sub(r"(?is)<(script|style|head|noscript|svg)[^>]*>.*?</\1>", " ", s)
s = re.sub(r"(?s)<[^>]+>", " ", s)
return _html.unescape(re.sub(r"\s+", " ", s)).strip()
async def _complete(messages) -> str:
"""Non-streaming OmniRoute call for summarisation tools."""
payload = {"model": LLM_MODEL, "messages": messages, "stream": False, "temperature": 0.3}
headers = {"Authorization": f"Bearer {LLM_KEY}"} if LLM_KEY else {}
url = f"{LLM_BASE.rstrip('/')}/chat/completions"
async with httpx.AsyncClient(timeout=180) as client:
r = await client.post(url, json=payload, headers=headers)
r.raise_for_status()
data = r.json()
return data["choices"][0]["message"]["content"]
@app.post("/api/summarize/web")
async def summarize_web(url: Annotated[str, Form()]) -> dict:
"""Fetch a URL and summarise its text content via the LLM."""
try:
async with httpx.AsyncClient(timeout=40, follow_redirects=True) as client:
r = await client.get(url, headers={"User-Agent": "Mozilla/5.0 (dsh-summarizer)"})
r.raise_for_status()
text = _strip_html(r.text)
if not text:
return {"ok": False, "error": "Could not extract readable text."}
content = text[:60000]
summary = await _complete([
{"role": "system", "content": _SUM_HEAD},
{"role": "user", "content": f"URL: {url}\n\nCONTENT:\n{content}\n\nSummarise the key points."},
])
return {"ok": True, "source": url, "summary": summary}
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
@app.post("/api/summarize/docs")
async def summarize_docs(path: Annotated[str, Form()]) -> dict:
"""Read a workspace file (txt/md/json/log) and summarise it via the LLM."""
ws = str(Path(WORKSPACE_DIR).resolve())
fp = (Path(ws) / path.lstrip("/")).resolve()
if not str(fp).startswith(ws):
return {"ok": False, "error": "Path must stay inside your workspace."}
if not fp.is_file():
return {"ok": False, "error": f"File not found: {path}"}
try:
data = fp.read_text(encoding="utf-8", errors="replace")[:60000]
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": f"Could not read: {exc}"}
summary = await _complete([
{"role": "system", "content": _SUM_HEAD},
{"role": "user", "content": f"FILE: {path}\n\nCONTENT:\n{data}\n\nSummarise the key points."},
])
return {"ok": True, "source": path, "summary": summary}
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
templates.env.globals["DSH_USER"] = USER_NAME
templates.env.globals["DSH_SLOT"] = DEFAULT_SLOT
templates.env.globals["DSH_MODEL"] = LLM_MODEL
# Frame-embedding policy: allow the console (and same origin) to frame us.
FRAME_POLICY = os.getenv("DSH_FRAME_POLICY", "https://console.lab.audasmedia.com.au")
@app.middleware("http")
async def frame_headers(request, call_next):
response = await call_next(request)
# Allow embedding from the console; refuse nothing else explicitly.
# (CSP frame-ancestors is the modern control; no X-Frame-Options DENY.)
response.headers.setdefault("Content-Security-Policy",
f"frame-ancestors 'self' {FRAME_POLICY}")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
return response
@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "chat.html", {})
@app.post("/api/chat")
async def chat(message: Annotated[str, Form()],
image: Annotated[str, Form()] = "",
session_id: Annotated[str, Form()] = "",
model: Annotated[str, Form()] = "") -> StreamingResponse:
"""Stream an answer as SSE. When `image` is a data:image URI the vision
model (OpenRouter) is used instead of the text LLM (image ingest)."""
use_vision = bool(image and image.startswith("data:image/"))
async def event_stream():
sid = session_id or "main"
session = load_session(sid)
if model and model in MODEL_CHOICES:
session["model"] = model
hist = session["messages"]
hist.append({"role": "user", "content": message})
if use_vision:
base = VISION_BASE
key = VISION_KEY
payload = {
"model": VISION_MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": [
{"type": "text", "text": message or "Describe this image."},
{"type": "image_url", "image_url": {"url": image}},
]},
],
"stream": True,
"temperature": 0.4,
}
else:
base = LLM_BASE
key = LLM_KEY
payload = {
"model": session.get("model") or LLM_MODEL,
"messages": [{"role": "system", "content": SYSTEM_PROMPT}] + hist,
"stream": True,
"temperature": 0.7,
}
headers = {"Authorization": f"Bearer {key}"} if key else {}
url = f"{base.rstrip('/')}/chat/completions"
full: list[str] = []
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("POST", url, json=payload, headers=headers) as r:
if r.status_code != 200:
body = (await r.aread())[:300].decode("utf-8", "replace")
yield f"data: {json.dumps({'e': f'{r.status_code} {body}'})}\n\n"
return
async for line in r.aiter_lines():
if not line or not line.startswith("data: "):
continue
data = line[6:].strip()
if data == "[DONE]":
break
try:
obj = json.loads(data)
delta = obj["choices"][0]["delta"].get("content")
except Exception:
continue
if delta:
full.append(delta)
yield f"data: {json.dumps({'c': delta})}\n\n"
except Exception as exc: # noqa: BLE001
yield f"data: {json.dumps({'e': str(exc)})}\n\n"
return
if full:
hist.append({"role": "assistant", "content": "".join(full)})
save_session(sid, hist, session.get("model")) # always persist user turn + chosen model
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.post("/api/tool/image")
async def generate_image(prompt: Annotated[str, Form()]) -> dict:
"""Generate an image from a prompt via OpenRouter (gpt-image-1); save to workspace."""
if not VISION_KEY:
return {"ok": False, "error": "No image API key configured."}
prompt = prompt.strip()[:500]
if not prompt:
return {"ok": False, "error": "Prompt required."}
try:
headers = {"Authorization": f"Bearer {VISION_KEY}"}
url = f"{VISION_BASE.rstrip('/')}/images/generations"
payload = {"model": IMAGE_MODEL, "prompt": prompt, "n": 1, "response_format": "b64_json"}
async with httpx.AsyncClient(timeout=180) as client:
r = await client.post(url, json=payload, headers=headers)
r.raise_for_status()
b64 = r.json()["data"][0].get("b64_json")
if not b64:
return {"ok": False, "error": "No image returned."}
img_dir = Path(WORKSPACE_DIR).resolve() / "images"
img_dir.mkdir(parents=True, exist_ok=True)
tag = uuid4().hex[:8]
fname = img_dir / f"{tag}.png"
fname.write_bytes(base64.b64decode(b64))
return {"ok": True, "path": f"images/{tag}.png", "b64": "data:image/png;base64," + b64}
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
MODEL_CHOICES = ["auto/best-chat", "auto/best-fast", "auto/best-reasoning",
"auto/best-coding", "auto/best-vision"]
@app.get("/api/models")
async def models_list() -> dict:
return {"ok": True, "default": LLM_MODEL, "current": MODEL_CHOICES}
@app.get("/api/sessions")
async def sessions_list() -> dict:
return {"ok": True, "sessions": list_sessions()}
@app.post("/api/sessions")
async def sessions_new() -> dict:
_sdir()
new_id = uuid4().hex[:12]
now = datetime.now(timezone.utc).isoformat()
try:
with open(_spath(new_id), "w", encoding="utf-8") as fh:
json.dump({"id": new_id, "name": "New conversation", "messages": [],
"created": now, "updated": now}, fh)
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": str(exc)}
return {"ok": True, "id": new_id}
@app.get("/api/sessions/{sid}")
async def sessions_get(sid: str) -> dict:
s = load_session(sid)
return {"ok": True, "id": s["id"], "name": s["name"], "messages": s["messages"],
"model": s.get("model") or LLM_MODEL}
@app.post("/api/sessions/{sid}/delete")
async def sessions_delete(sid: str) -> dict:
try:
p = _spath(sid)
if os.path.exists(p):
os.remove(p)
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": str(exc)}
return {"ok": True}
@app.get("/healthz")
async def healthz() -> dict:
return {"ok": True, "user": USER_NAME, "model": LLM_MODEL}