Family Home Lab: portal, dsh (chat+plugins), transcriber, music/media tools, home dash

This commit is contained in:
2026-08-26 11:30:34 +10:00
commit 1110dbc978
62 changed files with 4237 additions and 0 deletions

18
dsh/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DSH_PORT=8000
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd --create-home appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

355
dsh/app.py Normal file
View File

@@ -0,0 +1,355 @@
"""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:]}
except Exception:
return {"id": sid or "main", "name": "Conversation", "messages": []}
def save_session(sid, msgs):
_sdir()
sid = (sid or "main")
with _history_lock:
d = load_session(sid)
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()] = "") -> 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)
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": 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)
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}"}
@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"]}
@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}

76
dsh/docker-compose.yml Normal file
View File

@@ -0,0 +1,76 @@
# DeepSeek Harness — one chat instance per family member.
# Deploy on .13 at /home/sam/Docker/Containers/dsh/ (this compose + app live in
# repo folder `dsh/`). Build once, run 4 replicas on host ports 3081-3084.
#
# docker compose build # builds image `dsh:latest` from dsh/
# docker compose up -d
#
# Kids instances (jo/harry/finn) run no shell/bash tooling by default (this
# build ships none, so the restriction is inherent).
name: dsh
x-dsh-common: &common
image: dsh:latest
restart: unless-stopped
environment: &env-base
DSH_LLM_BASE: http://192.168.20.13:20129/v1
DSH_LLM_MODEL: auto/best-chat
DSH_HISTORY_DIR: /data
DSH_VISION_KEY: ${DSH_VISION_KEY:-}
DSH_VISION_MODEL: openai/gpt-5
volumes: &vol
- /mnt/data/family-home-lab/dsh/__USER__/workspace:/workspace
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
services:
dsh-sam:
build: . # build the shared image once (keeping dsh:latest)
<<: *common
container_name: dsh-sam
environment:
<<: *env-base
DSH_USER: sam
DSH_SLOT: Sam&rsquo;s workspace
ports: ["3081:8000"]
volumes:
- /mnt/data/family-home-lab/dsh/sam/workspace:/workspace
- /mnt/data/family-home-lab/dsh/sam/history:/data
dsh-jo:
<<: *common
container_name: dsh-jo
environment:
<<: *env-base
DSH_USER: jo
DSH_SLOT: Jo&rsquo;s space
ports: ["3082:8000"]
volumes:
- /mnt/data/family-home-lab/dsh/jo/workspace:/workspace
- /mnt/data/family-home-lab/dsh/jo/history:/data
dsh-harry:
<<: *common
container_name: dsh-harry
environment:
<<: *env-base
DSH_USER: harry
DSH_SLOT: Harry&rsquo;s space
ports: ["3083:8000"]
volumes:
- /mnt/data/family-home-lab/dsh/harry/workspace:/workspace
- /mnt/data/family-home-lab/dsh/harry/history:/data
dsh-finn:
<<: *common
container_name: dsh-finn
environment:
<<: *env-base
DSH_USER: finn
DSH_SLOT: Finn&rsquo;s space
ports: ["3084:8000"]
volumes:
- /mnt/data/family-home-lab/dsh/finn/workspace:/workspace
- /mnt/data/family-home-lab/dsh/finn/history:/data

5
dsh/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
jinja2==3.1.5
httpx==0.28.1
python-multipart==0.0.20

41
dsh/static/app.css Normal file
View File

@@ -0,0 +1,41 @@
:root {
--canvas: #f6f5f4;
--panel: #ffffff;
--ink: #111;
--accent: #213183; /* deep indigo, matches console login */
--hairline: #e6e6e6;
font-family: -apple-system, system-ui, "Segoe UI", Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body { display: flex; flex-direction: column; background: var(--canvas); color: var(--ink); }
.top {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; background: var(--panel); border-bottom: 1px solid var(--hairline);
}
.logo { font-weight: 700; letter-spacing: -0.3px; }
.who { color: #615d59; font-size: 13px; }
.model { color: var(--accent); }
.log {
flex: 1; overflow-y: auto; max-width: 860px; width: 100%;
margin: 0 auto; padding: 20px; display: flex; flex-direction: column; gap: 12px;
}
.msg { display: flex; }
.msg.user { justify-content: flex-end; }
.bubble {
max-width: 78%; padding: 10px 14px; border-radius: 12px; white-space: pre-wrap;
line-height: 1.5; background: var(--panel); border: 1px solid var(--hairline);
}
.msg.user .bubble { background: var(--accent); color: #fff; border: none; }
.composer {
display: flex; gap: 10px; max-width: 860px; width: 100%;
margin: 0 auto; padding: 12px 20px 20px;
}
.composer input {
flex: 1; padding: 12px 14px; border: 1px solid var(--hairline); border-radius: 999px;
font-size: 15px;
}
.composer button {
padding: 12px 22px; border: none; border-radius: 999px; background: var(--accent);
color: #fff; cursor: pointer; font-size: 15px;
}

207
dsh/templates/chat.html Normal file
View File

@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DeepSeek Harness — {{ DSH_USER }}</title>
<link rel="stylesheet" href="/static/app.css">
<style>
html, body { height: 100%; margin: 0; }
body { display: flex; flex-direction: column; }
header.top { flex: none; }
.app { display: flex; flex: 1; min-height: 0; }
#sidebar {
width: 240px; flex: none; border-right: 1px solid var(--hairline, #e6e6e6);
display: flex; flex-direction: column; background: var(--canvas-soft, #f7f7f7);
}
#sidebar h2 { font: 600 12px/1.3 var(--font-family, sans-serif); color: var(--ink-faint, #999); margin: 12px 12px 6px; }
#newBtn { margin: 6px 12px 8px; padding: 7px 10px; border: 1px solid #ddd; border-radius: 8px; background: #fff; cursor: pointer; text-align: left; }
#sessions { list-style: none; margin: 0; padding: 0 8px; overflow: auto; }
#sessions li {
display: flex; align-items: center; gap: 6px; padding: 7px 8px; margin: 2px 0;
border-radius: 8px; cursor: pointer; font-size: 13px; color: var(--ink-secondary, #333);
}
#sessions li.sel { background: var(--primary, #0075de); color: #fff; }
#sessions li .t { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#sessions li .n { font-size: 11px; opacity: .7; }
#sessions li .del { background: none; border: none; cursor: pointer; color: inherit; opacity: .5; font-size: 13px; }
#sessions li .del:hover { opacity: 1; }
.chat-col { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.log { flex: 1; overflow: auto; }
</style>
</head>
<body>
<header class="top">
<div class="logo">DeepSeek Harness</div>
<div class="who">{{ DSH_SLOT }} · {{ DSH_USER }} · <span class="model">{{ DSH_MODEL }}</span></div>
</header>
<div class="app">
<aside id="sidebar">
<h2>Conversations</h2>
<button id="newBtn" type="button">+ New conversation</button>
<ul id="sessions"></ul>
</aside>
<div class="chat-col">
<main id="log" class="log" aria-live="polite"></main>
<form id="chat" class="composer" autocomplete="off">
<input id="input" type="text" placeholder="Type your message…" autofocus />
<button type="submit">Send</button>
<input type="file" id="imgIn" accept="image/*" title="Attach an image" style="max-width:150px;font-size:12px">
</form>
<div class="composer" style="display:flex;gap:8px;flex-wrap:wrap">
<input id="urlInput" type="text" placeholder="Paste a URL to summarise…" style="flex:2;min-width:220px;border:1px solid #ddd;border-radius:8px;padding:6px 10px" />
<button type="button" id="sumWeb" style="border:1px solid #ddd;border-radius:8px;padding:6px 10px;cursor:pointer">Summarise page</button>
<input id="fileInput" type="text" placeholder="Workspace file (e.g. notes.md)…" style="flex:2;min-width:180px;border:1px solid #ddd;border-radius:8px;padding:6px 10px" />
<button type="button" id="sumDocs" style="border:1px solid #ddd;border-radius:8px;padding:6px 10px;cursor:pointer">Summarise file</button>
<input id="genInput" type="text" placeholder="Describe an image to generate…" style="flex:2;min-width:200px;border:1px solid #ddd;border-radius:8px;padding:6px 10px" />
<button type="button" id="genImg" style="border:1px solid #ddd;border-radius:8px;padding:6px 10px;cursor:pointer">Generate image</button>
</div>
</div>
</div>
<script>
const logEl = document.getElementById('log');
const form = document.getElementById('chat');
const input = document.getElementById('input');
const listEl = document.getElementById('sessions');
let curSid = localStorage.getItem('dshSid') || null;
function addBubble(role, text) {
const row = document.createElement('div');
row.className = 'msg ' + (role === 'user' ? 'user' : 'bot');
const b = document.createElement('div');
b.className = 'bubble';
b.textContent = text || '';
row.appendChild(b);
logEl.appendChild(row);
logEl.scrollTop = logEl.scrollHeight;
return b;
}
function appendChar(bubble, ch) { bubble.textContent += ch; logEl.scrollTop = logEl.scrollHeight; }
function showGreeting() {
if (!logEl.childElementCount) addBubble('bot', 'Hi {{ DSH_USER }} 👋 Ask me anything — pick a conversation on the left or start a new one.');
}
function renderMessages(msgs) {
logEl.innerHTML = '';
(msgs || []).forEach(m => { if (m.role === 'user') addBubble('user', m.content); else if (m.role === 'assistant') addBubble('bot', m.content); });
showGreeting();
}
function renderList(sessions) {
listEl.innerHTML = '';
sessions.forEach(s => {
const li = document.createElement('li');
if (s.id === curSid) li.className = 'sel';
const t = document.createElement('span'); t.className = 't'; t.textContent = s.name || 'Conversation';
const n = document.createElement('span'); n.className = 'n'; n.textContent = s.count;
const d = document.createElement('button'); d.className = 'del'; d.textContent = '✕'; d.title = 'Delete conversation';
d.addEventListener('click', (e) => { e.stopPropagation(); deleteSession(s.id); });
li.append(t, n, d);
li.addEventListener('click', () => openSession(s.id));
listEl.appendChild(li);
});
}
async function loadSessions() {
try {
const r = await fetch('/api/sessions'); const d = await r.json();
renderList(d.sessions || []);
if (!curSid || !(d.sessions || []).some(s => s.id === curSid)) {
curSid = (d.sessions && d.sessions[0] && d.sessions[0].id) || null;
}
if (curSid) { localStorage.setItem('dshSid', curSid); openSession(curSid); }
else showGreeting();
} catch (_) { showGreeting(); }
}
async function openSession(id) {
curSid = id; localStorage.setItem('dshSid', id);
try {
const r = await fetch('/api/sessions/' + id); const d = await r.json();
renderMessages(d.messages);
} catch (_) { renderMessages([]); }
renderList((await (await fetch('/api/sessions')).json()).sessions || []);
}
async function newSession() {
const d = await (await fetch('/api/sessions', { method: 'POST' })).json();
curSid = d.id; localStorage.setItem('dshSid', d.id);
renderMessages([]); loadSessions();
}
async function deleteSession(id) {
await fetch('/api/sessions/' + id + '/delete', { method: 'POST' });
if (curSid === id) { curSid = null; localStorage.removeItem('dshSid'); }
loadSessions();
}
document.getElementById('newBtn').addEventListener('click', newSession);
async function runTool(endpoint, fieldId, label, bodyKey) {
const val = document.getElementById(fieldId).value.trim(); if (!val) return;
addBubble('user', label + ': ' + val); addBubble('bot', '');
const bubble = logEl.lastElementChild.querySelector('.bubble'); bubble.textContent = '…thinking';
try {
const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ [bodyKey]: val }) });
const data = await res.json();
bubble.textContent = data.ok ? (data.summary || '') : '[error: ' + (data.error || 'unknown') + ']';
} catch (err) { bubble.textContent = '[error: ' + err + ']'; }
logEl.scrollTop = logEl.scrollHeight;
}
document.getElementById('sumWeb').addEventListener('click', () => runTool('/api/summarize/web', 'urlInput', 'Summarise page', 'url'));
document.getElementById('sumDocs').addEventListener('click', () => runTool('/api/summarize/docs', 'fileInput', 'Summarise file', 'path'));
document.getElementById('genImg').addEventListener('click', async () => {
const val = document.getElementById('genInput').value.trim(); if (!val) return;
addBubble('user', 'Generate: ' + val); addBubble('bot', '');
const b = logEl.lastElementChild.querySelector('.bubble'); b.textContent = '…generating';
try {
const res = await fetch('/api/tool/image', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ prompt: val }) });
const data = await res.json();
if (data.ok) { b.textContent = ''; const im = document.createElement('img'); im.src = data.b64; im.style.cssText = 'max-width:320px;border-radius:8px;display:block'; b.appendChild(im); const cap = document.createElement('div'); cap.textContent = 'saved: ' + data.path; b.appendChild(cap); }
else b.textContent = '[error: ' + (data.error || 'unknown') + ']';
} catch (err) { b.textContent = '[error: ' + err + ']'; }
logEl.scrollTop = logEl.scrollHeight;
});
const imgIn = document.getElementById('imgIn');
window.__img = null;
imgIn.addEventListener('change', () => {
const f = imgIn.files[0]; if (!f) return;
const r = new FileReader();
r.onload = () => { window.__img = r.result; imgIn.title = f.name + ' ✓'; };
r.readAsDataURL(f);
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const text = input.value.trim();
const img = window.__img || '';
if (!text && !img) return;
if (!curSid) { await newSession(); }
input.value = ''; window.__img = null; imgIn.value = '';
addBubble('user', text || '(attached image)');
if (img) { const im = document.createElement('img'); im.src = img; im.style.cssText = 'max-width:180px;border-radius:8px;display:block;margin-top:6px'; logEl.lastElementChild.querySelector('.bubble').appendChild(im); }
addBubble('bot', '');
const bubble = logEl.lastElementChild.querySelector('.bubble');
try {
const body = new URLSearchParams({ message: text, session_id: curSid });
if (img) body.set('image', img);
const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body });
const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = '';
while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const parts = buf.split('\n\n'); buf = parts.pop(); for (const part of parts) { if (!part.startsWith('data: ')) continue; try { const data = JSON.parse(part.slice(6)); if (data.c) appendChar(bubble, data.c); else if (data.e) appendChar(bubble, '\n[error: ' + data.e + ']'); } catch (_) {} } }
addBubble('bot', '').remove();
} catch (err) { appendChar(bubble, '\n[connection error]'); }
loadSessions(); // reflect updated ordering/count + rename
});
loadSessions();
</script>
</body>
</html>