Family Home Lab: portal, dsh (chat+plugins), transcriber, music/media tools, home dash
This commit is contained in:
18
dsh/Dockerfile
Normal file
18
dsh/Dockerfile
Normal 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
355
dsh/app.py
Normal 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
76
dsh/docker-compose.yml
Normal 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’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’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’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’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
5
dsh/requirements.txt
Normal 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
41
dsh/static/app.css
Normal 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
207
dsh/templates/chat.html
Normal 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>
|
||||
Reference in New Issue
Block a user