commit 1110dbc978d689dc4e573567dde28b20366b09e1 Author: Sam Rolfe Date: Wed Aug 26 11:30:34 2026 +1000 Family Home Lab: portal, dsh (chat+plugins), transcriber, music/media tools, home dash diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..eb3e678 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.gitignore +.github +.pi +.agents +*.md +.env +.env.* +!portal/requirements.txt +!portal/ +deploy/ +ai/ +video/ +image/ +audio/ +docs/ +chat/ \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..070f72e --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Family Home Lab — environment template +# Copy to `.env` (git-ignored) and fill real values. Never commit `.env`. + +# --- PostgreSQL --- +POSTGRES_USER=fhl +POSTGRES_PASSWORD=CHANGE_ME_db +POSTGRES_DB=fhl + +# --- Sessions / first-run admin --- +SESSION_SECRET=CHANGE_ME_openssl_rand_hex_32 +ADMIN_USERNAME=sam +ADMIN_PASSWORD=CHANGE_ME_admin +ADMIN_FULLNAME=Sam + +# --- Garage (S3) --- +# Generate with: docker run --rm dxflrs/garage:v1.0.1 secret generate +GARAGE_RPC_SECRET=CHANGE_ME_rpc +GARAGE_ADMIN_TOKEN=CHANGE_ME_admin_api +GARAGE_METRICS_TOKEN=CHANGE_ME_metrics +# First S3 access key pair (main account "portal"). Create via garage CLI after +# first boot, then paste here. +S3_ACCESS_KEY=CHANGE_ME_access +S3_SECRET_KEY=CHANGE_ME_secret +S3_REGION=garage + +# --- Tool container images (verify before enabling profile "tools") --- +IMAGE_PHOTOPEA=texasbullphotography/photopea:latest +IMAGE_VIDEO=lscr.io/linuxserver/webtop:latest +IMAGE_AUDIO=lscr.io/linuxserver/webtop:latest \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e813fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Local overrides / secrets — never commit. +.env +*.pem +*.key +.venv/ +__pycache__/ +*.pyc +.pi/ +.deploy-history/*.bak +*.log +.DS_Store diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..ae5b1a8 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,41 @@ +#Overview +- Build User based console landing page. Users are family. Sam, Jo, Harry, Finn. +- Console provides single access and login point to access different ai tools and provide overview to users. +- Selected tools either show in-line, window or link to page. +##Role +- You are devops, web developer software engineer. +- You will refine the plans in "S3" and "Home Lab" links below. These are general plans that need to be adapted to my network and system and refined. Offering best advice for local family system. +- Recommendations and changes should be Linux, Opensource, Maintained with a view to resume enhancement for employment balanced with realistic local useability. +- Do not build the "DeepSeek Harness". That will be handled by another agent. You can and must update that obsidian note to reflect this system, and requirements from this parent interface. Such as user login or session handline, iframe issues etc. +- Plan should be a "plan.md" file. I will add tasks or changes into todo.txt which is a TUXEDO todo list that you can interact with. +- Create subfolders in this directory for each tool, eg video, image, docs etc. NOT DeepSeek harness though. +- Obsidian docs for "S3", "Home Lab" will be kept upto date to reflect plan. +- Plan will include technology, location, deployment, user access. +- Once the plan is complete we can design. +- You will ensure I have enabled design skills. +- You will ask questions to confirm the plan. +- Once the design and plan is approved you can build. +- You will ask questions during build where unsure on major issues. +- When stuck alert seek guidance. +#Job details +- See "Home Lab" below for generalized plan. This must be updated to meet my system needs and refined. +- Tools include media editing - video, image, audio, AI tool, and future documents and chat. +- Tools should be accessible from their own url. +- Also show inline in iframe if possible. +- Tools are in docker containers with webui. +- Plan how to integrate "S3" storage, see "S3" below, plan must be adapted to my system from generalized note - consider the best location, particularly drives. See "Backup System" below. +##Resources +- "S3" +/home/sam/obsidian/000 daily/FAMILY S3 Storage Integration & Blueprint.md + +- "Home Lab" +/home/sam/obsidian/000 daily/FAMILY Enterprise Homelab AI Multimedia Suite. Systems Architecture & Blueprint.md + +- "DeepSeek Harness" +/home/sam/obsidian/000 daily/FAMILY DeepSeek Harness (dsh) Home Lab Setup.md + +- "Home network map" +/home/sam/obsidian/300 areas/360 Dev-Ops Network Computers/Home Network Map Overview.md + +- "Backup System" +/home/sam/obsidian/300 areas/360 Dev-Ops Network Computers/Backup System — Borg, Kopia & Restic.md diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..d61699c --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,144 @@ +# DESIGN.md — Family Home Lab Console + +> Design system for the family console portal (`console.home.lab`). +> Base system: **Notion marketing design language** (awesome-design-md) — warm daylight document calm, one structural blue, decorative sticker palette. +> Stack context: server-rendered FastAPI + Jinja2 + **HTMX**. No SPA build chain; tokens map to CSS custom properties in `portal/static/tokens.css`. + +## Brand Personality + +The console reads as a **well-organised family desk**: warm paper-soft canvas, near-black confident type, exactly one dependable blue for actions. Each tool gets its own sticker colour so family members can find "their" tools at a glance — colour decorates, never structures. The login screen is the single dark indigo "night band" moment; everything after login is daylight. + +## Colors + +### Surface +| Token | Value | Use | +|---|---|---| +| `{colors.canvas-soft}` | `#f6f5f4` | App canvas / page background | +| `{colors.canvas}` / `{colors.surface}` | `#ffffff` | Cards, panels, nav bar, inputs, iframes wells | +| `{colors.hairline}` | `#e6e6e6` | 1px borders, dividers | + +### Text +| Token | Value | Use | +|---|---|---| +| `{colors.ink}` | `#000000` (~95% alpha rendered) | Headings, primary text | +| `{colors.ink-secondary}` | `#31302e` | Body copy | +| `{colors.ink-muted}` | `#615d59` | Supporting text, descriptions | +| `{colors.ink-faint}` | `#a39e98` | Captions, metadata, placeholders | + +### Structural accents +| Token | Value | Use | +|---|---|---| +| `{colors.primary}` | `#0075de` | Login/submit buttons, inline links, active tab, focus rings — the only action colour | +| `{colors.primary-active}` | `#005bab` | Pressed state of primary buttons | +| `{colors.on-primary}` | `#ffffff` | Text on primary fills | +| `{colors.secondary}` | `#213183` | Deep indigo — **login screen background and header greeting band only** | + +### Sticker palette (decorative ONLY — never paints CTAs or structure) + +Assigned permanently to tool categories: + +| Token | Value | Category assignment | +|---|---|---| +| `{colors.accent-sky}` | `#62aef0` | Chat (dsh instances) | +| `{colors.accent-purple}` | `#d6b6f6` | Image tools (Photopea) | +| `{colors.accent-pink}` | `#ff64c8` | Video tools | +| `{colors.accent-orange}` | `#dd5b00` | Audio tools | +| `{colors.accent-teal}` | `#2a9d99` | Documents (future) | +| `{colors.accent-green}` | `#1aae39` | Status ticks ("online", success toasts) | +| `{colors.accent-purple-deep}` | `#391c57` | Deep shade within illustrations only | +| `{colors.accent-orange-deep}` | `#793400` | Deep shade within illustrations only | + +Semantic status reuses stickers: green = online/saved, orange = busy/starting, faint grey = offline. + +## Typography + +Font: **Inter** (self-hosted woff2 in `static/fonts/`; substitute for NotionInter). Fallbacks: `-apple-system, system-ui, "Segoe UI", Helvetica, Arial`. OpenType `lnum` enabled on numerals. + +| Token | Size | Weight | LH | Tracking | Use | +|---|---|---|---|---|---| +| `{typography.display-1}` | 48px | 700 | 1.05 | −1.5px | Login headline ("Good evening, Sam") on indigo band | +| `{typography.heading-1}` | 32px | 700 | 1.1 | −0.8px | Dashboard greeting, section headlines | +| `{typography.heading-2}` | 22px | 700 | 1.27 | −0.25px | Tool category headings | +| `{typography.title}` | 17px | 600 | 1.4 | −0.125px | Tool card names | +| `{typography.body-md}` | 15px | 400 | 1.5 | 0 | Default body | +| `{typography.body-sm}` | 14px | 400 | 1.4 | 0 | Dense UI, table rows | +| `{typography.button}` | 15px | 500 | 1.4 | 0 | Button labels | +| `{typography.caption}` | 13px | 400 | 1.43 | 0 | Metadata, timestamps | +| `{typography.eyebrow}` | 12px | 600 | 1.33 | +0.125px | Category pills, badges | + +Principle: heavy 700 headlines with explicit negative tracking vs calm 400 body. No decorative typography. + +## Layout + +- Base unit **8px**. Tokens: xxs 4 · xs 8 · sm 12 · md 16 · lg 24 · xl 28 · xxl 32. +- Dashboard container: centred, max-width **1200px**, generous gutters (`{spacing.xxl}` desktop). +- Tool grid: responsive auto-grid — 3-up desktop → 2-up tablet (≤840px) → single column mobile (≤600px). +- Whitespace groups; sections separated by large vertical gaps, no rules. +- Touch targets ≥44×44px on mobile. +- Iframe/embed view: full-width white well below a slim sticky tool bar. + +## Elevation + +| Level | Treatment | Use | +|---|---|---| +| 0 Flat | Hairline border only | Default tool cards | +| 1 Soft | Layered micro-shadow: `rgba(0,0,0,.01) 0 .175px 1px, rgba(0,0,0,.02) 0 .8px 3px, rgba(0,0,0,.027) 0 2px 8px, rgba(0,0,0,.04) 0 4px 18px` | Hovered/raised cards, floating elements | +| 2 Elevated | Deeper stack ending `rgba(0,0,0,.05) 0 23px 52px` | Modals, popovers | + +Never hard drop-shadows. + +## Shapes + +| Token | Value | Use | +|---|---|---| +| `{rounded.xs}` | 4px | Text inputs, chips | +| `{rounded.sm}` | 5px | Menu items, list rows, status pills | +| `{rounded.md}` | 8px | Utility buttons, smaller cards | +| `{rounded.lg}` | 12px | Tool cards, iframe wells, modals | +| `{rounded.full}` | 9999px | Pill CTAs, avatar circles, icon buttons | + +Inputs stay tight (4px); CTAs pill. Never swap. + +## Components + +### Screens +1. **Login screen** (`ex-auth-form-card` + hero-band): full-viewport deep indigo `{colors.secondary}` band; white display-1 greeting; centred white auth card (`{rounded.lg}`, Level-2 shadow) with username + password `text-input`s and one `button-primary` "Sign in". Subtle starfield/sticker dots decoration. This is the page's only dark moment. +2. **Dashboard** (post-login): sticky slim `nav-bar` (white, wordmark left, user avatar + logout right); heading-1 time-aware greeting ("Good morning, Sam"); tool grid grouped by category with eyebrow pills in category sticker colours; each tool a `tool-card`. +3. **Tool embed view**: sticky tool bar (white, back arrow, tool title, open-in-new-tab `button-utility`) above a full-width white iframe well (`{rounded.lg}`, hairline). Falls back to link-out card if the tool forbids framing. +4. **Admin panel** (Sam only): same chrome; user management rows, add-user form, service status list. + +### Core primitives +- **`nav-bar`** — white surface, body-sm links, md padding, hairline bottom edge. +- **`button-primary`** — blue fill, on-primary text, pill, brief `scale(0.97)` press → `{colors.primary-active}`. +- **`button-secondary`** — white, ink text, pill, Level-1 shadow. +- **`button-utility`** — white, ink text, `{rounded.md}`, `4px 14px` padding, hairline border. +- **`tool-card`** — white, `{rounded.lg}`, padding lg, hairline (Level-0); hover lifts to Level-1. Contains: 40px circular icon tile filled with the category's sticker colour (white glyph), title typography, caption description, status dot (green/orange/faint), and launch affordance (inline-embed vs new-tab shown as small caption chip). +- **`category-pill`** — eyebrow type, white surface, category sticker-coloured text + dot, pill, `4px 8px`. +- **`text-input`** — white, ink text, body-sm, 1px `rgb(221,221,221)` border, `{rounded.xs}`, 6px+ padding; focus adds Level-1 shadow + blue ring. +- **`status-dot`** — 8px circle: `{colors.accent-green}` online, `{colors.accent-orange}` starting, `{colors.ink-faint}` offline. +- **`modal-card`** — feature-card chrome + Level-2 shadow (confirmations, add-user dialog). +- **`toast`** — card shape + medium shadow, green tick sticker for success. + +## HTMX Behaviour Notes +- Tool grid refreshes via `hx-get` polling every 30s (status dots update without reload). +- Login posts via standard form (full redirect — session cookie set server-side). +- Embed view swaps into `
` with `hx-push-url` so browser back works. +- Loading states use a subtle opacity pulse on cards (never spinners-on-stickers). + +## Do's and Don'ts + +### Do +- Keep the app on `{colors.canvas-soft}`; cards/fields pure white. +- Reserve `{colors.primary}` strictly for sign-in, submit, links, active state. +- Give each tool category its fixed sticker colour (see table) used only in icon tiles and category pills. +- Apply negative tracking explicitly on headings. +- One dark moment per session: the login screen. +- Hairlines + layered micro-shadows for depth. + +### Don't +- Don't paint buttons or structural fills with sticker colours. +- Don't introduce a second accent alongside `{colors.primary}`. +- Don't round inputs like pills or vice versa. +- Don't use heavy drop-shadows. +- Don't repeat dark bands inside the dashboard. +- Don't render pages on clinical pure-white backgrounds. diff --git a/ai/README.md b/ai/README.md new file mode 100644 index 0000000..fce0576 --- /dev/null +++ b/ai/README.md @@ -0,0 +1,20 @@ +# AI Tools + +Unified entry point for the lab's AI/LLM services. + +| Service | URL | Role | +|---|---|---| +| OmniRoute dashboard | `http://192.168.20.13:20128` (`omniroute.home.lab`) | LLM gateway UI — all routing happens here | +| DeepSeek Harness (dsh) | `https://dsh-.lab.audasmedia.com.au` | Per-user assistant instances (built by the **dsh agent**, not us) | + +Category colour: sky `#62aef0` (chat) / sky for ai stack. + +## Integration contract (from plan.md §6) +- dsh instances must be reachable at `dsh-{sam,jo,harry,finn}.lab.audasmedia.com.au` via Caddy subdomains (not subpaths). +- Instances should be iframe-friendly (correct CSP / X-Frame-Options via Caddy) so the console can embed them inline. +- Session handling stays inside dsh; the console only routes the entry point. +- Optional one-time-token handoff from the console is to be designed with the dsh agent. + +## Console wiring +- `portal/tools.py` exposes the dsh tool; `_dsh_for_user()` rewrites the URL per logged-in user. +- Admin panel (Sam) manages user→instance mapping. \ No newline at end of file diff --git a/audio/README.md b/audio/README.md new file mode 100644 index 0000000..849f023 --- /dev/null +++ b/audio/README.md @@ -0,0 +1,21 @@ +# Audio Lab + +Browser-streamed desktop for audio editing (Audacity via LinuxServer webtop; Zrythm optional phase 2). + +> ⚠️ **Image to verify** — same LSIO webtop caveat as `video/` (plan §4). + +| Fact | Value | +|---|---| +| URL (public) | `https://audio.lab.audasmedia.com.au` | +| Port (internal) | `.13:3000` | +| Launch mode | New tab (`mode: window`) | +| Category colour | orange `#dd5b00` | +| Storage | `/mnt/data/family-home-lab/shared-media` (read-only bind) | + +## Deployment +`docker-compose.yml` → `audio-editor` service under `profile: tools`. + +## Verify +- [ ] Port 3000 conflict check on `.13` (Open WebUI being retired — confirm free) +- [ ] Correct LSIO webtop image + Audacity bundled +- [ ] Caddy entry applied on `.35` \ No newline at end of file diff --git a/chat/README.md b/chat/README.md new file mode 100644 index 0000000..7ce1c29 --- /dev/null +++ b/chat/README.md @@ -0,0 +1,8 @@ +# Chat + +Future phase — the per-user conversational layer is provided by **DeepSeek Harness (dsh)** +instances (built by the dsh agent), not a separate chat container here. + +- In the console this shows as the **Chat & AI** section (sky `#62aef0`). +- See `ai/README.md` for the integration contract. +- Category colour is assigned but no service ships from this folder yet. \ No newline at end of file diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md new file mode 100644 index 0000000..d3ccc25 --- /dev/null +++ b/deploy/DEPLOYMENT.md @@ -0,0 +1,63 @@ +# Deployment notes — Family Home Lab + +## Domains +- Public zone `lab.audasmedia.com.au` → `144.6.86.11` (router) → `.35` Caddy. +- **No Pi-hole local records needed.** `*.home.lab` is deprecated (not a registered TLD). + +## Caddy (.35) +1. SSH: `ssh sam@192.168.20.35` +2. Locate the running Caddy config (Docker container on .35). +3. Append `deploy/caddy/Caddyfile.snippet` contents to the Caddyfile. +4. Reload: `docker exec caddy reload --config /etc/caddy/Caddyfile` +5. Verify Caddy can obtain Let's Encrypt certs (ports 80/443 forwarded on router — already working for omniroute/gitea domains). + +## Host (.13) +1. Compose project: `/home/sam/Docker/Containers/family-home-lab/` (created during build). +2. Data: `/mnt/data/family-home-lab/` (garage-data, garage-meta, shared-media, dsh/). +3. Backup: add `/mnt/data/family-home-lab/` to Borg sources in `/etc/nixos/backup.nix`. + +## Ports (on .13) +| Port | Service | +|---|---| +| 8500 | Portal (FastAPI) | +| 8487 | Photopea (→ container 8887) | +| 8083 | Video editor (kdenlive) — 8081 was taken by airflow-webserver | +| 8084 | Audio editor (audacity) — 3000 was taken by a NixOS service | +| 3900/3902 | Garage (S3/admin) | +| 3081–3084 | dsh instances (other agent) | + +## Order of operations +1. Build portal + compose stack (this repo) → `docker compose up -d` on .13 +2. Apply Caddy snippet on .35 +3. Test `https://console.lab.audasmedia.com.au` +4. Deploy tool containers one at a time, verifying each URL +5. dsh agent deploys its instances and coordinates token handoff + +## First run (portal) +Portal auto-creates the DB tables and, if the users table is empty, seeds the +admin from `ADMIN_USERNAME`/`ADMIN_PASSWORD` in `.env` (Sam, by default). + +```bash +cd /home/sam/Docker/Containers/family-home-lab +docker compose up -d +# first admin already created on startup; create the rest in the UI at /admin +``` + +## Garage provisioning (S3 buckets + access keys) +After Garage is up, create buckets/keys from inside the container: + +```bash +# env for the garage CLI +G=('docker compose exec -T garage garage --config /etc/garage.toml') + +# main service key for the portal +KEY_ID=$($G key import --name portal - <<< "$(cat .env | grep S3_ACCESS | cut -d= -f2)") + +# buckets +for b in sam jo harry finn shared-media; do $G bucket create "$b"; done +# allow the portal key to access every bucket +for b in sam jo harry finn shared-media; do $G bucket allow --read --write "$b" --key portal; done +``` + +> Symmetry: ensure S3_ACCESS_KEY/S3_SECRET_KEY in `.env` match what `key import` +> registers, else portal uploads will 403. diff --git a/deploy/caddy/Caddyfile.snippet b/deploy/caddy/Caddyfile.snippet new file mode 100644 index 0000000..ac7c4d3 --- /dev/null +++ b/deploy/caddy/Caddyfile.snippet @@ -0,0 +1,50 @@ +# Caddyfile additions for the Family Home Lab console +# Apply on .35 (sam-ubuntu1, 192.168.20.35) — append to the existing Caddyfile +# and reload: docker exec caddy reload --config /etc/caddy/Caddyfile +# +# Domains use the public zone lab.audasmedia.com.au (already resolves to +# 144.6.86.11 -> router -> .35). No Pi-hole local records required. + +# --- Family Console (portal) --- +console.lab.audasmedia.com.au { + reverse_proxy 192.168.20.13:8500 +} + +# --- Image Lab (Photopea) --- +photo.lab.audasmedia.com.au { + reverse_proxy 192.168.20.13:8487 +} + +# --- Video Lab --- +# host port 8083 (8081 was taken by airflow-webserver on .13) +video.lab.audasmedia.com.au { + reverse_proxy 192.168.20.13:8083 +} + +# --- Audio Lab --- +# host port 8084 (3000 was taken by a NixOS service on .13) +audio.lab.audasmedia.com.au { + reverse_proxy 192.168.20.13:8084 +} + +# --- Garage S3 API --- +s3.lab.audasmedia.com.au { + reverse_proxy 192.168.20.13:3900 +} + +# --- DeepSeek Harness instances (built by dsh agent) --- +dsh-sam.lab.audasmedia.com.au { + reverse_proxy 192.168.20.13:3081 +} + +dsh-jo.lab.audasmedia.com.au { + reverse_proxy 192.168.20.13:3082 +} + +dsh-harry.lab.audasmedia.com.au { + reverse_proxy 192.168.20.13:3083 +} + +dsh-finn.lab.audasmedia.com.au { + reverse_proxy 192.168.20.13:3084 +} diff --git a/deploy/pi-dash-sync.sh b/deploy/pi-dash-sync.sh new file mode 100755 index 0000000..aae9f47 --- /dev/null +++ b/deploy/pi-dash-sync.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Copy Pi dashboard agent-session JSONs from the pi host to .13 so the portal's +# admin Pi Dashboard view (/admin/pi) can render them. Run this on the host +# where `pi` runs (sam-4screen-desktop). Automate with cron, e.g. every 5 min: +# */5 * * * * /home/sam/home_network/custom_tools/family_home_lab/deploy/pi-dash-sync.sh >> /tmp/pi-dash-sync.log 2>&1 +set -e +DST="sam@192.168.20.13:/mnt/data/family-home-lab/pi-dashboard" +ssh sam@192.168.20.13 "mkdir -p /mnt/data/family-home-lab/pi-dashboard" +if command -v rsync >/dev/null 2>&1; then + rsync -a --delete /home/sam/.pi/agent/dashboard/*.json "$DST"/ +else + scp -q /home/sam/.pi/agent/dashboard/*.json "$DST"/ +fi +echo "pi dashboard synced: $(ls /home/sam/.pi/agent/dashboard/*.json 2>/dev/null | wc -l) files" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5d359ea --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,225 @@ +# Family Home Lab — core stack + tool containers +# Deploy on .13: cd /home/sam/Docker/Containers/family-home-lab && docker compose up -d +# Tool containers (photo/video/audio) are behind profile "tools": +# docker compose --profile tools up -d +# (Their images need verification per plan.md §4 — see each tool's README.) + +name: family-home-lab + +x-fhl-logging: &logging + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +services: + # ----- Core stack ----- + postgres: + image: pgvector/pgvector:pg16 + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - pgdata:/var/lib/postgresql/data + networks: [fhl-net] + <<: *logging + # No host port published: reachable only inside fhl-net. .13 already runs its + # own postgres on 5432/5433/5434; we avoid the conflict entirely. + + redis: + image: redis:7-alpine + restart: unless-stopped + command: redis-server --appendonly yes + volumes: + - redisdata:/data + networks: [fhl-net] + <<: *logging + + rabbitmq: + image: rabbitmq:3-management-alpine + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest + RABBITMQ_DEFAULT_VHOST: / + volumes: + - rabbitmqdata:/var/lib/rabbitmq + networks: [fhl-net] + <<: *logging + + garage: + image: dxflrs/garage:v1.0.1 + restart: unless-stopped + volumes: + - ./garage/garage.toml:/etc/garage.toml:ro + - /mnt/data/family-home-lab/garage-data:/var/lib/garage/data + - /mnt/data/family-home-lab/garage-meta:/var/lib/garage/meta + ports: + - "3900:3900" # S3 API + - "3902:3902" # admin / web + environment: + GARAGE_RPC_SECRET: ${GARAGE_RPC_SECRET} + GARAGE_ADMIN_TOKEN: ${GARAGE_ADMIN_TOKEN} + GARAGE_METRICS_TOKEN: ${GARAGE_METRICS_TOKEN} + networks: [fhl-net] + <<: *logging + + portal: + build: + context: . + dockerfile: portal/Dockerfile + restart: unless-stopped + ports: + - "8500:8500" + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + CELERY_BROKER_URL: amqp://guest:guest@rabbitmq:5672// + REDIS_URL: redis://redis:6379/0 + S3_ENDPOINT_URL: http://garage:3900 + S3_REGION: ${S3_REGION} + S3_ACCESS_KEY: ${S3_ACCESS_KEY} + S3_SECRET_KEY: ${S3_SECRET_KEY} + SESSION_SECRET: ${SESSION_SECRET} + ADMIN_USERNAME: ${ADMIN_USERNAME} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + ADMIN_FULLNAME: ${ADMIN_FULLNAME} + PI_DASHBOARD_DIR: /pi-dashboard + volumes: + - /mnt/data/family-home-lab/pi-dashboard:/pi-dashboard:ro + depends_on: + - postgres + - redis + - rabbitmq + - garage + networks: [fhl-net] + <<: *logging + + worker: + build: + context: . + dockerfile: portal/Dockerfile + restart: unless-stopped + command: celery -A portal.tasks.celery_app worker --loglevel=info + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + CELERY_BROKER_URL: amqp://guest:guest@rabbitmq:5672// + REDIS_URL: redis://redis:6379/0 + S3_ENDPOINT_URL: http://garage:3900 + S3_REGION: ${S3_REGION} + S3_ACCESS_KEY: ${S3_ACCESS_KEY} + S3_SECRET_KEY: ${S3_SECRET_KEY} + SESSION_SECRET: ${SESSION_SECRET} + depends_on: + - postgres + - redis + - rabbitmq + - garage + networks: [fhl-net] + <<: *logging + + transcriber: + build: + context: . + dockerfile: transcriber/Dockerfile + restart: unless-stopped + command: celery -A portal.tasks.celery_app worker -Q transcription --concurrency=1 --loglevel=info + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + CELERY_BROKER_URL: amqp://guest:guest@rabbitmq:5672// + REDIS_URL: redis://redis:6379/0 + S3_ENDPOINT_URL: http://garage:3900 + S3_REGION: ${S3_REGION} + S3_ACCESS_KEY: ${S3_ACCESS_KEY} + S3_SECRET_KEY: ${S3_SECRET_KEY} + SESSION_SECRET: ${SESSION_SECRET} + depends_on: + - postgres + - redis + - rabbitmq + - garage + networks: [fhl-net] + <<: *logging + + transcriber-mus: + build: + context: . + dockerfile: transcriber-mus/Dockerfile + restart: unless-stopped + command: celery -A portal.tasks.celery_app worker -Q transcription-mus --concurrency=1 --loglevel=info + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + CELERY_BROKER_URL: amqp://guest:guest@rabbitmq:5672// + REDIS_URL: redis://redis:6379/0 + S3_ENDPOINT_URL: http://garage:3900 + S3_REGION: ${S3_REGION} + S3_ACCESS_KEY: ${S3_ACCESS_KEY} + S3_SECRET_KEY: ${S3_SECRET_KEY} + SESSION_SECRET: ${SESSION_SECRET} + HUGGINGFACE_TOKEN: ${HUGGINGFACE_TOKEN:-} + HF_TOKEN: ${HUGGINGFACE_TOKEN:-} # huggingface_hub/transformers read this + depends_on: + - postgres + - redis + - rabbitmq + - garage + networks: [fhl-net] + <<: *logging + + # ----- Tool containers (profile "tools") ----- + # Images per Obsidian blueprint: Photopea shtse8, KdenLive + Audacity via + # LinuxServer standalone apps. Host ports reassigned (8081 taken by airflow, + # 3000 taken by a NixOS service) -> video 8083, audio 8084. + photopea: + image: ${IMAGE_PHOTOPEA:-shtse8/photopea:1.0} + profiles: ["tools"] + restart: unless-stopped + ports: + - "8487:8887" + networks: [fhl-net] + <<: *logging + + video-editor: + image: ${IMAGE_VIDEO:-lscr.io/linuxserver/kdenlive:latest} + profiles: ["tools"] + restart: unless-stopped + environment: + PUID: 1000 + PGID: 100 + TZ: Australia/Perth + ports: + - "8083:3000" + volumes: + - video-config:/config + - /mnt/data/family-home-lab/shared-media:/media:ro + networks: [fhl-net] + <<: *logging + + audio-editor: + image: ${IMAGE_AUDIO:-lscr.io/linuxserver/audacity:latest} + profiles: ["tools"] + restart: unless-stopped + environment: + PUID: 1000 + PGID: 100 + TZ: Australia/Perth + ports: + - "8084:3000" + volumes: + - audio-config:/config + - /mnt/data/family-home-lab/shared-media:/media:ro + networks: [fhl-net] + <<: *logging + +volumes: + pgdata: + redisdata: + rabbitmqdata: + video-config: + audio-config: + +networks: + fhl-net: + driver: bridge \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..0c87810 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,8 @@ +# Docs + +Future phase — document/library tools (e.g. paperless-ngx, BookStack, or an internal wiki). + +- Category colour assigned: teal `#2a9d99`. +- No service ships from this folder yet; design decisions belong in plan.md/AGENT.md + when a tool is chosen. +- User permission flag (`can_docs`) already exists in the portal user model, default off. \ No newline at end of file diff --git a/docs/audio-plan.md b/docs/audio-plan.md new file mode 100644 index 0000000..7ffc780 --- /dev/null +++ b/docs/audio-plan.md @@ -0,0 +1,60 @@ +# Audio & Music — Implementation Plan (Draft) + +> For Sam's son (music student). Goal: give the family lab a remote music-production +> workspace (DAWs) and an **audio → sheet-music / MIDI transcription** pipeline. +> Installing extra software in containers is approved. + +## 1. Feasibility of the proposed tools (verified) + +| Tool | What it is | Remote-app container? | Fit | +|---|---|---|---| +| **Zrythm** | Open-source DAW (GPLv3), Pro-grade: mixed, automation, piano roll, module lanes | ❌ no public image **exists**. Installable into a LinuxServer **webtop** container via its official installer (zrythm.org `install.sh` / apt repo). | ✅ son's main DAW | +| **LMMS** | FL-Studio-style open-source DAW (beats / MIDI / virtual instruments) | ✅ `apt install lmms` inside a webtop container (Ubuntu). | ✅ secondary / beats | +| **Spotify Basic Pitch** | Audio→**MIDI** neural net (polyphonic, pitch bends). `pip install basic-pitch`. Best on a single instrument. | Runs as a **Celery task** (no UI). | ✅ fast, lightweight first-pass MIDI | +| **MuScriptor** (Kyutai + Mirelo) | State-of-the-art **multi-instrument** transcription → **MIDI + MusicXML + engraved PDF + guitar tabs** in one run. `muscriptor transcribe audio.wav --format sheets`. | CLI / heavy model (1.4B). | ✅ the "sheet music" engine you described | +| **MuseScore** | Open-source notation/engraver (MuScriptor uses it internally). | `apt install musescore`, also headless CLI. | ✅ fallback: MIDI→MusicXML→PDF locally | + +## 2. Key caveats to decide on + +- **MuScriptor licensing:** code is **MIT**, but model weights are **CC BY-NC 4.0** (non-commercial). That's fine for personal/family use — but not for anything commercial. +- **MuScriptor resources:** 1.4B-parameter model. Feasible on CPU but **slow**; a GPU makes it practical. We have no GPU confirmed on `.13` → treat MuScriptor as best-effort/beta, queue it in Celery, or throttle. +- **Basic Pitch scale:** fast on CPU, but "best on one instrument at a time". Good default for quick single-part transcription. +- **Webtop note:** the LinuxServer webtop terminal grants root inside the container. Safe on the trusted LAN behind Caddy; don't expose it to the public internet. + +## 3. Proposed architecture + +``` +Family-music user (son/family) + │ console.lab.audasmedia.com.au (portal) + ├── "Music (LMMS)" → lmms.lab.audasmedia.com.au (webtop:8086? ) remote DAW + ├── "Zrythm" → zrythm.lab.audasmedia.com.au (webtop:8087? ) remote DAW + └── "Transcriber" → console form: pick audio (Garage) / upload + │ enqueue Celery task + ▼ + worker (existing family-home-lab-worker) + ├─ Basic Pitch → MIDI (fast, single-instrument) + └─ MuScriptor → MIDI+MusicXML+PDF+tabs (beta, slow) + ▼ + writes results back to Garage bucket + shared-media + ▼ + Transcriber page lists/plays/downloads results +``` + +- Input & output live in Garage (`sam`/…/`shared-media` buckets), backed by Borg. +- Uses the **existing** RabbitMQ+Celery worker — no new queue. +- DAWs are separate webtop containers on **free host ports** with their own Caddy rows. + +## 4. Blocker found on the DAWs (ports) +Originlab snippet used **:3001** for zrythm — but that port is **taken by langfuse**. +We'll use fresh free ports (e.g. **8085/8086/8087**) and correct Caddy/nearby URLs. + +## 5. Suggested build order (each is independently useful) +1. **Transcriber (Basic Pitch)** — quick win: upload/pick audio → MIDI; show result in console. (Easy, CPU, no license.) +2. **LMMS remote DAW** — `apt install lmms` on a webtop container, free port + Caddy row. +3. **Zrythm remote DAW** — custom webtop image with Zrythm installed. +4. **MuScriptor sheet-music** — add the multi-instrument + MusicXML/PDF stage (needs HF login/license; beta if no GPU). + +## 6. Open decisions (need your call) +- Which transcriber to prioritize: **Basic Pitch** (fast, MIDI) vs **MuScriptor** (full sheet music, heavier) — or pair them. +- LMMS and/or Zrythm both? (I suggest both — they cover different use cases.) +- Confirm we may use the webtop image which gives container-root to the terminal. \ No newline at end of file diff --git a/docs/dsh-plugin-plan.md b/docs/dsh-plugin-plan.md new file mode 100644 index 0000000..266d468 --- /dev/null +++ b/docs/dsh-plugin-plan.md @@ -0,0 +1,45 @@ +# dsh plugins — plan (docs / summarize / web / images) + +Goal: give each family dsh instance practical "plugin-style" capabilities: +handle documents, summarize web pages & docs, ingest images, output images, +and edit images from text. Decide how to deliver them on our lightweight +FastAPI+SSE dsh. + +## Approach: extend our dsh (not adopt the official harness) +Our dsh is intentionally a small FastAPI+SSE chat (OmniRoute `auto/best-chat`). +The official DeepSeek Harness + its ~11k-plugin ecosystem is heavier and +prerelease. We keep our app and add plugin-style capabilities natively, phased. + +Capabilities map: +| # | Capability | Mechanism | Phase | +|---|------------|-----------|-------| +| 1 | Summarize a web page | dsh fetches URL (httpx) → text → LLM summary. Add `/api/tool/web` or a "summarize URL" box. | 1 | +| 2 | Summarize a document | read a file from `/workspace` (txt/md; PDF via pypdf) → LLM summary. Add "Summarize file" button + `/api/tool/docs`. | 1 | +| 3 | Handle / attach docs | pass referenced workspace file contents into the prompt (file picker, cap size). | 1–2 | +| 4 | Ingest images (understand) | need a **vision** model on OmniRoute (e.g. qwen-vl / gpt-4o). Send image as data-URI in OpenAI vision content format. `DSH_LLM_VISION_MODEL`. | 2 | +| 5 | Output images (generate) | OmniRoute is LLM-only → wire an image API (OpenRouter image, or internal ComfyUI/SD). `/api/tool/image`. | 3 | +| 6 | Edit image from text | image-editing model (instruction-based) on the image API. | 3 | + +## Phase 1 — text capabilities (no new infra) +- [ ] Web summarize: `POST /api/tool/web {url}` → httpx fetch → strip HTML → LLM summary (stream). UI: a "Paste URL to summarize" box. +- [ ] Docs summarize: `POST /api/tool/docs {path}` (relative to /workspace) → read txt/md (pypdf for PDFs) → LLM summary. +- [ ] Chat context: pick a workspace file → prepend its contents to the message (cap ~8k tokens). + +## Phase 2 — image ingest (vision) +- [ ] Find/configure a vision model on OmniRoute (qwen2.5-vl, gpt-4o, or similar). Set `DSH_LLM_VISION_MODEL`. +- [ ] Image upload in chat.html (`` + base64) → send OpenAI vision `content` array with `image_url` data-URI. +- [ ] Handle docs as images (scan/photo) → describe/OCR via the vision model. + +## Phase 3 — image output & edit +- [ ] Wire an image-generation API (OpenRouter `gpt-image-1`/`flux`, or internal ComfyUI/SD webui). +- [ ] `/api/tool/image` — text→image; stream/provide a URL or return a base64 image to display in chat. +- [ ] Image edit — instruction/ref edit endpoint (gpt-image edit or SD img2img) from an uploaded image + text. +- [ ] Save images into the user's S3 bucket (`image/…`) so they're kept. + +## Open questions +- Vision + image models on OmniRoute: confirm availability/ids (check `/v1/models`). +- Kids' instances: should image output be gated (cost/appropriateness)? Probably gate image-gen to sam initially. +- Marker: keep everything streaming + iframe-friendly CSP. + +## Sources +- docs/dsh-plugins.md — shortlist from github topic: WeKnora (docs→RAG), modlens (vision), open-design. diff --git a/docs/dsh-plugins.md b/docs/dsh-plugins.md new file mode 100644 index 0000000..98756c4 --- /dev/null +++ b/docs/dsh-plugins.md @@ -0,0 +1,60 @@ +# DeepSeek Harness (dsh) — plugin shortlist + +Pulled 2026-08-25 from https://github.com/topics/dsh-plugin (the topic feed is +noisy, so this is the curated subset relevant to the family home lab). + +**Start here:** `awesome-dsh-plugin/awesome-dsh-plugin` — the curated index of +dsh plugins (categorized, maintained). Everything below is notable from the +topic feed itself. + +## Knowledge / Docs / Summarize (matches "Docs, Links — summarize" todo) +- **Tencent/WeKnora** — open-source knowledge platform: turns raw documents + into a queryable RAG layer. Good fit for family docs/wiki summarisation. +- **volcengine/OpenViking** — self-evolving context/memory DB for agents + (unify memory + knowledge). +- **distilly** — distill "how they think" into reusable skills for any agent + (good for teaching the kids' assistants skills). +- **nocobase/nocobase** — AI + no-code platform (if we want a plugin that + builds data apps). + +## Image / Media (matches "Image ingest/create" todo) +- **liustack/modlens** — vision plugin: lets dsh "see" images (ingest/understand). +- **freestylefly/awesome-gpt-image-2** — industrial prompt engine + 530+ + reverse-engineered templates for image generation. +- **Nagi-ovo/voyager** — enhancement suite (Gemini/AI Studio/Claude/ChatGPT, + multimodal). +- **nexu-io/open-design** — design plugin (open-source alternative to Claude + Design; good for Sam's portfolio work). + +## Memory +- **EverMind-AI/EverOS** — portable, local-first, Markdown-native memory layer + for agents. +- **MemTensor/MemOS** — ultra-persistent memory OS for LLM agents. +- **agentscope-ai/ReMe** — "Remember Me, Refine Me" memory management kit. + +## UI / Desktop +- **anywhere-labs/dsh-desktop** — modern desktop client for the dsh plugin + ecosystem ("everything is a plugin, the desktop is a plugin"). +- **zhu1090093659/dsh-web** — dsh Web plugin aggregator pack. +- **crafter-station/petdex** — public gallery of animated pets for agents + (fun for the kids' instances). + +## Dev / Agent tooling (power users / Sam) +- **esengine/DeepSeek-Reasonix** — DeepSeek-native coding agent for the + terminal. +- **ruvnet/ruflo** — original agent "meta-harness" (multi-player swarms — + advanced). +- **tt-a1i/archify** — agent skill for architecture/workflow/sequence + diagrams. +- **yjh051108/dsh-routing-suite** — injector + router-standard kit for dsh. +- **xiaobright/dsh-anchored-standard** — two-phase dsh preset bootstrap. + +## Notes +- Topic feed total is ~11k repos; most are non-dsh / gamed. Use the + awesome-dsh-plugin index before installing anything. +- Our own dsh is a lightweight FastAPI+SSE chat; plugins from the ecosystem + target the official DeepSeek Harness client — wiring them in means either + adopting the official harness or adapting individual ideas into our app. +- todo.txt already tracks: "DeepSeek Plugins: Docs/Links summarize" and + "Image ingest/create" — pick candidates from Knowledge/Docs and Image/Media + above. diff --git a/docs/websites.md b/docs/websites.md new file mode 100644 index 0000000..8b28030 --- /dev/null +++ b/docs/websites.md @@ -0,0 +1,55 @@ +# Family Home Lab — website / service inventory +> For another AI session: the services behind the home lab and their addresses. +> All public *.lab.audasmedia.com.au are reachable off-LAN (own logins except +> the no-login tools that sit behind Caddy basic-auth). *.home.lab are LAN-only. + +## Public — *.lab.audasmedia.com.au +console.lab # Family Home Lab portal (login) +photo.lab / video.lab / audio.lab / lmms.lab # media editor tools (basic-auth) +dsh-sam.lab dsh-jo.lab dsh-harry.lab dsh-finn.lab # DeepSeek chat (basic-auth) +chat.lab # family group chat +pb.chat.lab # chat admin (admin-only, basic-auth) +wikijs.lab lynx.lab sequence.lab # portfolio / resume +sam-developer.lab sam-devops.lab sam-iot-electronics.lab sam-pursuits.lab +omniroute.lab # AI router dashboard (basic-auth) +photo-filter.lab # photo utility (basic-auth) +silverbullet.lab hedgedoc.lab flatnotes.lab vikunja.lab bookstack.lab dokuwiki.lab trillium.lab # notes/docs +affine.lab penpot.lab # design / docs +nocodb.lab homebox.lab toora.lab linkace.lab # data / apps +gitea.lab # git +apprise.lab ntfy.lab # notifications +immich.lab jellyfin.lab jellyseerr.lab audiobookshelf.lab # media +nextcloud.lab paperless.lab pingvin.lab filebrowser.lab # storage / files +shopping.lab owl.lab # household +grafana.lab uptimekuma.lab worldmonitor.lab # monitoring +n8n.lab prefect.lab openweb.lab # automation / AI +firefly.lab # personal finance +vaultwarden.lab # password manager +sonarr.lab radarr.lab readarr.lab lidarr.lab sabnzbd.lab qbittorrent.lab spotweb.lab headphones.lab # media download stack +freshrss.lab ghost.lab kanboard.lab # rss / blog / tasks +proxmox.lab proxmox_backup.lab # virtualization (own login) +supabase.lab # dev (LangChain) + +## Internal — *.home.lab (LAN only) +proxmox.home proxmox_backup.home portainer.home # admin +homeassistant.home # home automation +grafana.home influxdb.home librenms.home watchyourlan.home phpipam.home +dozzle.home uptimekuma.home kopia.home restic.home # infra / monitoring / backup +mopidy.home snapcast.home # audio +archon.home langfuse.home supabase.home openweb.home # AI / dev +pihole.home pihole2.home # DNS +sams.home # home file server +games.home # retro games (emulatorjs) +react-server.home laravel-server.home t3_stack_react.home sams_home_network.home # dev demos + +## Other public domains +where-woof.com admin.where-woof.com # family location +homeassistant.lab.quickweb.com.au # HA mirror + +## auth model +- No-login tools behind Caddy basic-auth: photo/video/audio/lmms, dsh-*, omniroute, + photo-filter, dozzle, sams, pb.chat, ntfy, plus apps left on basic-auth (toora, + homebox, phpipam, watchyourlan, librenms, kopia, backrest, filebrowser, ...). +- Apps with own login (basic-auth REMOVED so their mobile apps/APIs work): homeassistant, + nextcloud, immich, audiobookshelf, vikunja, proxmox(+backup), portainer, grafana, + uptimekuma, supabase. diff --git a/dsh/Dockerfile b/dsh/Dockerfile new file mode 100644 index 0000000..d3d8a1e --- /dev/null +++ b/dsh/Dockerfile @@ -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"] \ No newline at end of file diff --git a/dsh/app.py b/dsh/app.py new file mode 100644 index 0000000..e3afdc3 --- /dev/null +++ b/dsh/app.py @@ -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)[^>]*>.*?", " ", 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} \ No newline at end of file diff --git a/dsh/docker-compose.yml b/dsh/docker-compose.yml new file mode 100644 index 0000000..3069f08 --- /dev/null +++ b/dsh/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/dsh/requirements.txt b/dsh/requirements.txt new file mode 100644 index 0000000..38359bb --- /dev/null +++ b/dsh/requirements.txt @@ -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 \ No newline at end of file diff --git a/dsh/static/app.css b/dsh/static/app.css new file mode 100644 index 0000000..df2f7e4 --- /dev/null +++ b/dsh/static/app.css @@ -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; +} \ No newline at end of file diff --git a/dsh/templates/chat.html b/dsh/templates/chat.html new file mode 100644 index 0000000..4829c5d --- /dev/null +++ b/dsh/templates/chat.html @@ -0,0 +1,207 @@ + + + + + + DeepSeek Harness — {{ DSH_USER }} + + + + +
+ +
{{ DSH_SLOT }} · {{ DSH_USER }} · {{ DSH_MODEL }}
+
+ +
+ + +
+
+ +
+ + + +
+
+ + + + + + +
+
+
+ + + + \ No newline at end of file diff --git a/garage/garage.toml b/garage/garage.toml new file mode 100644 index 0000000..996101d --- /dev/null +++ b/garage/garage.toml @@ -0,0 +1,28 @@ +# Garage v1.x configuration — single-node family instance. +# +# WARNING: the three tokens below ARE secrets. Generate unique values before +# first launch and keep this file out of git: +# docker run --rm dxflrs/garage:v1.0.1 secret generate (x3) +# Then place the outputs here and as GARAGE_ADMIN_TOKEN/GARAGE_METRICS_TOKEN +# in .env for parity. + +metadata_dir = "/var/lib/garage/meta" +data_dir = "/var/lib/garage/data" +db_engine = "lmdb" +block_size = 1048576 +replication_mode = "none" + +# Single-node RPC listener + shared secret (patched from .env at deploy) +rpc_bind_addr = "[::]:3901" +rpc_secret = "___GARAGE_RPC_SECRET___" + +[s3_api] +s3_region = "garage" +api_bind_addr = "[::]:3900" +root_domain = ".s3.lab.audasmedia.com.au" +s3_website_enabled = false + +[admin] +api_bind_addr = "[::]:3902" +admin_token = "___GARAGE_ADMIN_TOKEN___" +metrics_token = "___GARAGE_METRICS_TOKEN___" \ No newline at end of file diff --git a/image/README.md b/image/README.md new file mode 100644 index 0000000..c69d822 --- /dev/null +++ b/image/README.md @@ -0,0 +1,20 @@ +# Image Lab — Photopea + +Browser-based photo & PSD editing (self-hosted). + +| Fact | Value | +|---|---| +| URL (public) | `https://photo.lab.audasmedia.com.au` | +| Port (internal) | `.13:8487` | +| Launch mode | New tab (`mode: window`) | +| Category colour | purple `#d6b6f6` | +| Storage | reads/writes against Garage via the portal / S3 client | + +## Deployment +Included in `docker-compose.yml` under `profile: tools`. +Enable with the `--profile tools` flag once the image is verified. + +## Verify +- [ ] Image `texasbullphotography/photopea:latest` is maintained / suitable +- [ ] Caddy entry applied on `.35` (see `deploy/caddy/Caddyfile.snippet`) +- [ ] Path to Garage bucket wired (`sam`/`jo`/... / `shared-media`) \ No newline at end of file diff --git a/music/docker-compose.yml b/music/docker-compose.yml new file mode 100644 index 0000000..c9a0c2d --- /dev/null +++ b/music/docker-compose.yml @@ -0,0 +1,38 @@ +# Music DAW containers (LinuxServer webtop desktop in the browser). +# Deploy on .13 at /home/sam/Docker/Containers/music/ (this repo folder `music/`). +# docker compose build (builds music-lmms from music/lmms/) +# docker compose up -d +name: music + +services: + lmms: + build: ./lmms + image: music-lmms:latest + container_name: lmms + restart: unless-stopped + environment: + PUID: 1000 + PGID: 100 + TZ: Australia/Perth + ports: + - "8085:3000" + volumes: + - ./lmms-config:/config + - /mnt/data/family-home-lab/shared-media:/media:ro + shm_size: 1gb + logging: + driver: json-file + options: { max-size: "10m", max-file: "3" } + + # Zrythm added after its image is prepared (music/zrythm). + # zrythm: + # build: ./zrythm + # image: music-zrythm:latest + # container_name: zrythm + # restart: unless-stopped + # environment: { PUID: 1000, PGID: 100, TZ: Australia/Perth } + # ports: ["8086:3000"] + # volumes: + # - ./zrythm-config:/config + # - /mnt/data/family-home-lab/shared-media:/media:ro + # shm_size: 1gb \ No newline at end of file diff --git a/music/lmms/Dockerfile b/music/lmms/Dockerfile new file mode 100644 index 0000000..8de0bf9 --- /dev/null +++ b/music/lmms/Dockerfile @@ -0,0 +1,10 @@ +# LMMS (FL-Studio-style DAW) inside a LinuxServer webtop (browser KasmVNC desktop). +FROM lscr.io/linuxserver/webtop:ubuntu-xfce + +# Install LMMS + MIDI support packages (approved to install extra software). +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + lmms \ + fluidsynth fluid-soundfont-gm \ + alsa-utils \ + && rm -rf /var/lib/apt/lists/* \ No newline at end of file diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..805df71 --- /dev/null +++ b/plan.md @@ -0,0 +1,153 @@ +# Family Home Lab Console — Plan + +> Adapted from Obsidian notes: [[FAMILY Enterprise Homelab AI Multimedia Suite]], [[FAMILY S3 Storage Integration & Blueprint]], [[FAMILY DeepSeek Harness (dsh) Home Lab Setup]], [[Home Network Map Overview]], [[Backup System — Borg, Kopia & Restic]]. +> +> **Status: FINAL v3 — approved. Build phase: repo-local code complete (2026-08-24), deployment pending.** + +--- + +## 1. Goal + +A single family console ("landing page") at a local URL where each of the four users +(**Sam, Jo, Harry, Finn**) logs in and gets: + +- A personalised dashboard of available AI + media tools +- One-click launch of each tool (inline iframe where possible, else window/new tab) +- Per-user session handling and tool permissions + +The console is also the parent interface that the DeepSeek Harness instances hang off +(dsh itself is built by another agent — we only define the integration contract here). + +## 2. Where it runs (adapted to my network) + +| Concern | Generic blueprint said | Adapted decision | +|---|---|---| +| Host machine | unspecified | **`.13` nixos-desktop** — always-on server, existing Docker host (15.5 GB RAM), already runs Open WebUI/OmniRoute/Langfuse | +| Reverse proxy | dedicated Caddy container in compose stack | **Reuse existing Caddy on `.35`** — it already proxies `*.home.lab`; no duplicate proxy/ports 80/443 conflict | +| Local DNS | ~~Pi-hole records~~ **Not needed** — using public domain `*.lab.audasmedia.com.au` (real TLD, always resolves; `*.home.lab` failed as it's not a registered TLD) | +| LLM routing | deploy omniroute container in stack | **Reuse existing OmniRoute** `.13:20128/20129` | +| Object storage | deploy Garage in stack | **Fresh Garage deployment in our own stack**, data on **`/mnt/data/family-home-lab/garage/`** (new subdir; Takeout landing zone untouched). Old `garage-garage-1` test instance retired after data check | + +### Directory layout (on .13) + +```text +/home/sam/Docker/Containers/family-home-lab/ # compose project (matches existing convention) +├── docker-compose.yml +├── .env # secrets (git-ignored) +├── garage/garage.toml +├── portal/ # custom FastAPI app +│ ├── main.py # async web controller +│ ├── auth.py # user login/session handling +│ ├── database.py # PostgreSQL + pgvector +│ ├── tasks.py # background workers (Celery) +│ ├── templates/ # HTMX/Jinja2 frontend +│ └── static/ +/mnt/data/family-home-lab/ # heavy data (NOT backed by root drive) +├── garage/garage-data # S3 object data +├── garage-meta/ +└── shared-media/ +``` + +Repo lives here (`family_home_lab` working dir) with subfolders per tool category: +`video/ image/ audio/ docs/ ai/ chat/` — **no dsh folder** (owned by other agent). + +Queue: RabbitMQ container deployed fresh inside this stack (verified none exists on network). + +## 3. Core stack + +| Component | Technology | Rationale | +|---|---|---| +| Console portal | FastAPI + Jinja2 + HTMX | lightweight, employable skillset, no SPA build chain needed | +| Auth | per-user accounts (bcrypt password hash), signed HTTP-only session cookies; admin panel for Sam to manage users | simple, self-contained; no external IdP overhead for 4 users | +| Database | `pgvector/pgvector:pg16` | user profiles + preferences now; semantic memory vectors later | +| Task queue | Celery + Redis (downgrade from blueprint's RabbitMQ unless queue UI is wanted) | lighter on 15.5 GB RAM shared with existing services; swap-in RabbitMQ is trivial if wanted for resume value | +| Object storage | **Garage v1.x** S3-compatible | pure OSS Rust S3; buckets per user + `shared-media`; boto3 from portal | +| Proxy/DNS | existing Caddy (.35) + Pi-hole records | consistent with rest of network | + +### URLs (final — public domain, no local DNS needed) + +All domains resolve via public DNS `lab.audasmedia.com.au` → `144.6.86.11` → router → `.35` Caddy → internal hosts. + +| Domain | Target | Tool | +|---|---|---| +| `console.lab.audasmedia.com.au` | .35 → `.13:8500` | The family console (this project) | +| `photo.lab.audasmedia.com.au` | .13 photopea container | Image editing | +| `video.lab.audasmedia.com.au` | .13 video editor container | Video editing | +| `audio.lab.audasmedia.com.au` | .13 audio container | Audio editing | +| `s3.lab.audasmedia.com.au` | .13 Garage API | S3 endpoint (internal/API use) | +| dsh instances | `dsh-.lab.audasmedia.com.au` | Built by other agent — see §6 | + +## 4. Tool catalogue (phase 1) + +Each tool = its own subfolder here, own URL, dockerised with WebUI, mounted against +Garage buckets or bind-mounted media dirs. + +- **Image:** Photopea (self-hosted) +- **Video:** KdenLive via LinuxServer webtop/KasmVNC image ⚠️ *verify current image name — LSIO deprecated some standalone rdesktop apps* +- **Audio:** Audacity via LinuxServer webtop image (Zrythm optional phase 2) +- **AI chat:** OmniRoute dashboard (`.13:20128`, `omniroute.home.lab`) linked/inlined. ~~Open WebUI~~ — dropped; dsh instances will replace it. +- **Docs / Chat:** future phases (dsh per-user instances are the chat layer) + +## 5. S3 storage integration (final) + +- Fresh **Garage v1.x** container in our stack. `data_dir = /mnt/data/family-home-lab/garage-data`, `metadata_dir = /mnt/data/family-home-lab/garage-meta`. Ports 3900 (S3 API) / 3902 (web) — old test instance stopped first to free them. +- Buckets: `sam`, `jo`, `harry`, `finn`, `shared-media`. +- Portal uploads/downloads via boto3 (S3v4 signatures); tools either bind-mount or use rclone/s3fs mounts where they can't talk S3 natively. +- Backups: add `/mnt/data/family-home-lab/` to .13's existing Borg job source list → covered by rsync→.35→.23 chain. +- `/mnt/data`'s existing Takeout dirs (`01_keep/02_review/03_delete/takeout/.thumbs`) are never touched. + +## 6. DeepSeek Harness integration contract (NOT building dsh) + +Update the dsh Obsidian note to require: + +- Each dsh instance reachable at `dsh-.home.lab` via .35 Caddy (subdomains, not subpaths — avoids WebSocket/path issues). +- Console links to the correct instance after login; optionally auto-auth via one-time token passed in query param (to be designed with the dsh agent). +- dsh containers must be iframe-friendly (correct CSP/X-Frame-Options headers set by Caddy) if embedded inline in the console. +- Session handling stays inside dsh; the console only handles entry-point routing. + +## 7. Deployment + +1. Stop/retire old `garage-garage-1` test instance (after `du` sanity check of its volume) ✅ done +2. Apply Caddyfile entries on .35 from `deploy/caddy/Caddyfile.snippet` (tracked via filechanges extension) +3. `docker compose up -d` from `/home/sam/Docker/Containers/family-home-lab/` on .13 +4. Create Garage buckets + access keys; first-run admin setup creates Sam's admin account, then Jo/Harry/Finn +5. Add `/mnt/data/family-home-lab/` to Borg backup source list on .13 + +> **DNS note:** public domain `*.lab.audasmedia.com.au` is used — no Pi-hole local records required. `*.home.lab` domains are deprecated (not a registered TLD; resolution unreliable). + +## 8. Resolved decisions + +1. Queue: **RabbitMQ** (own container in our stack; verified no conflict — Mosquitto :1883 was the false memory) +2. Video/audio editors: browser-streamed desktop containers ✓ +3. Portal port: **8500** on .13 ✓ +4. Auth: username + password (bcrypt), signed session cookies, all four users ✓ +5. Phase-1 tools: Photopea, video editor, audio editor, OmniRoute link ✓ (Open WebUI dropped — dsh replaces) +6. Storage: fresh Garage on `/mnt/data/family-home-lab/`; old test instance retired after `du` check ✓ +7. Compose location: `/home/sam/Docker/Containers/family-home-lab/` ✓ +8. Frontend: FastAPI + Jinja2 + **HTMX**, server-rendered, no SPA build chain ✓ + +## 9. Verified facts (live checks) +- .13 containers include: omniroute (:20128/29), garage (:3900/3902), n8n, airflow, langfuse, litellm (:4000), mosquitto (:1883), pihole (:53/:8080), pocketbase (:8090), prefect (:4200), minio (:9010/:9090), postgres (:5432/:5433/:5434). No RabbitMQ. Port **8500 free** ✓. + +## 10. Build status (repo-local, 2026-08-24) + +| Item | State | +|---|---| +| `portal/` FastAPI app (main/auth/db/tasks/tools/config) | ✅ written & compiles | +| Templates + `tokens.css`/`app.css` (9 pages, HTMX polling) | ✅ written, all render | +| `docker-compose.yml` (portal/db/redis/rabbitmq/garage/worker) | ✅ written, `config -q` valid | +| Tool containers (photo/video/audio) | ✅ media tools LIVE: photo/video/audio.lab.audasmedia.com.au (Photopea/KdenLive/Audacity) | +| `garage/garage.toml` + `.env.example` | ✅ written + applied live | +| First-run admin bootstrap (env) | ✅ implemented + verified | +| Local smoke test (login→dashboard→admin→add-user) | ✅ passed — caught+fixed 2 bugs (session_scope, non-fatal broker call) | +| Deploy to .13 + Garage buckets/keys | ✅ core stack live on console.lab.audasmedia.com.au | +| Caddy rules on .35 | ✅ console + photo/video/audio + dsh-sam/jo/harry/finn applied & reloaded | +| DeepSeek Harness | ✅ built here (dsh/) + 4 instances live: dsh-{user}.lab.audasmedia.com.au → .13:3081-3084 (auto/best-chat via OmniRoute) | +| Tool page workspace files | ✅ /tool/{id} lists user's S3 bucket + shared-media | +| Borg backup path on .13 | ⏳ needs sudo edit to backup.nix (snippet provided) | + +**Deployed 2026-08-24:** console live + media tools live. Buckets `sam, jo-media, harry, finn, shared-media` (S3 needs ≥3-char names → `jo`→`jo-media`). Media host ports reassigned: **video 8083** (8081=taken by airflow), **audio 8084** (3000=taken by a NixOS service); photopea 8487. Images: shtse8/photopea:1.0, lscr.io/linuxserver/kdenlive, lscr.io/linuxserver/audacity. OmniRoute console link uses proper URL omniroute.lab.audasmedia.com.au. + +## Audio / GPU follow-ups (2026-08-25) +- Zrythm DAW: install NATIVELY on NixOS via `pkgs.zrythm` (no docker/webtop — no apt/AppImage). Access via **RustDesk** (RustDesk server already runs on .35: hbbs/hbbr). DAW audio latency over remote is ok for composing, not live multitrack recording. +- AMD **RX 6600** being added to **.13**: configure `amdgpu` driver in NixOS so containers/ML can use it (MuScriptor/Basic Pitch/vision speedup). Currently .13 has no working NVIDIA/AMD driver or container runtime (nvidia-smi fails; docker only has runc). diff --git a/portal/Dockerfile b/portal/Dockerfile new file mode 100644 index 0000000..0ef75c6 --- /dev/null +++ b/portal/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY portal/requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# Whole repo so `portal` is importable as a package (relative imports). +COPY . /app/ + +RUN useradd --create-home appuser && chown -R appuser:appuser /app +USER appuser + +EXPOSE 8500 + +CMD ["uvicorn", "portal.main:app", "--host", "0.0.0.0", "--port", "8500", "--proxy-headers"] \ No newline at end of file diff --git a/portal/__init__.py b/portal/__init__.py new file mode 100644 index 0000000..2f3b18b --- /dev/null +++ b/portal/__init__.py @@ -0,0 +1 @@ +"""Family Home Lab console package.""" \ No newline at end of file diff --git a/portal/auth.py b/portal/auth.py new file mode 100644 index 0000000..367bee5 --- /dev/null +++ b/portal/auth.py @@ -0,0 +1,111 @@ +"""Authentication: bcrypt password hashing + signed HTTP-only session cookies. + +Sessions are stateless — a signed, timestamped cookie (itsdangerous). No +server-side store needed for 4 users. The signature prevents forgery; the +timestamp bounds cookie lifetime. + +NOTE: this module intentionally avoids a hard FastAPI dipendency import so it +can also be reused by workers/scripts. +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Annotated + +from fastapi import Depends, Request +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from itsdangerous import ( + BadSignature, + SignatureExpired, + URLSafeTimedSerializer, +) + +from .config import settings +from .database import SessionLocal, User, get_user_by_username + +# ---- Password hashing (bcrypt directly; passlib is unmaintained and logs a +# bogus warning with bcrypt>=4.1) ---- +import bcrypt + +serializer = URLSafeTimedSerializer(settings.SECRET_KEY, salt="family-home-lab-session") + +# Used to keep the HTTPBasic dependency from actually forcing a browser prompt; +# we implement form-based auth and only lean on the dep for docs/debugging. +_basic_auth = HTTPBasic(auto_error=False) + + +def hash_password(plain: str) -> str: + return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(plain: str, hashed: str) -> bool: + try: + return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8")) + except ValueError: + return False + + +def create_session_token(username: str, *, ttl: int | None = None) -> str: + ttl = ttl or settings.SESSION_TTL + return serializer.dumps({"sub": username, "iat": int(datetime.now(timezone.utc).timestamp())}) + + +def set_session_cookie(request: Request, response, username: str) -> None: + token = create_session_token(username) + max_age = settings.SESSION_TTL + response.set_cookie( + key=settings.SESSION_COOKIE, + value=token, + max_age=max_age, + httponly=True, + samesite="lax", + secure=settings.is_secure, + path="/", + ) + + +def clear_session_cookie(request: Request, response) -> None: + response.delete_cookie(settings.SESSION_COOKIE, path="/") + + +def decode_session_token(token: str) -> dict | None: + try: + data = serializer.loads(token, max_age=settings.SESSION_TTL) + if isinstance(data, dict) and "sub" in data: + return data + except SignatureExpired: + return None + except BadSignature: + return None + return None + + +def get_username_from_request(request: Request) -> str | None: + token = request.cookies.get(settings.SESSION_COOKIE) + if not token: + return None + data = decode_session_token(token) + return data.get("sub") if data else None + + +async def current_user_dep( + request: Request, + basic: Annotated[HTTPBasicCredentials | None, Depends(_basic_auth)] = None, +) -> User: + """Resolve the logged-in user from the session cookie. + + Falls back to validating HTTP Basic credentials (useful for API/headless + clients and health checks). + """ + username = get_username_from_request(request) + if username is None and basic is not None: + username = basic.username + + if username is None: + return None # handled as unauthenticated by caller + + async with SessionLocal() as session: + user = await get_user_by_username(session, username) + if user is not None and user.is_active: + return user + return None \ No newline at end of file diff --git a/portal/config.py b/portal/config.py new file mode 100644 index 0000000..3c2fc09 --- /dev/null +++ b/portal/config.py @@ -0,0 +1,79 @@ +"""Application configuration. + +All values come from environment variables (populated by docker-compose or a +local .env). No secrets are committed to the repo. +""" +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent + + +def _bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +class Settings: + # --- General --- + APP_NAME: str = os.getenv("APP_NAME", "Family Home Lab") + DEBUG: bool = _bool("DEBUG", False) + SECRET_KEY: str = os.getenv( + "SESSION_SECRET", "change-me-in-prod-use-openssl-rand-hex" + ) + SESSION_COOKIE: str = os.getenv("SESSION_COOKIE", "fhl_session") + # How long a session cookie stays valid (seconds). 7 days. + SESSION_TTL: int = int(os.getenv("SESSION_TTL", str(7 * 24 * 3600))) + + # --- First-run admin bootstrap (breed: env only, never committed) --- + ADMIN_USERNAME: str = os.getenv("ADMIN_USERNAME", "sam") + ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "") + ADMIN_FULLNAME: str = os.getenv("ADMIN_FULLNAME", "Sam") + + # --- Database (pgvector pg16) --- + DATABASE_URL: str = os.getenv( + "DATABASE_URL", "postgresql+asyncpg://fhl:fhl@postgres:5432/fhl" + ) + + # --- Task queue (Celery + RabbitMQ broker, Redis backend) --- + # Final decision (plan.md §8 #1): own RabbitMQ container in our stack. + REDIS_URL: str = os.getenv("REDIS_URL", "redis://redis:6379/0") + CELERY_BROKER_URL: str = os.getenv( + "CELERY_BROKER_URL", "amqp://guest:guest@rabbitmq:5672//" + ) + + # --- Object storage (Garage S3) --- + S3_ENDPOINT_URL: str = os.getenv("S3_ENDPOINT_URL", "http://garage:3900") + S3_REGION: str = os.getenv("S3_REGION", "garage") + S3_ACCESS_KEY: str = os.getenv("S3_ACCESS_KEY", "") + S3_SECRET_KEY: str = os.getenv("S3_SECRET_KEY", "") + + # --- Tool catalogue endpoint for opening tools --- + SECTION_COLORS: dict[str, str] = { + "chat": "#62aef0", # accent-sky + "image": "#d6b6f6", # accent-purple + "video": "#ff64c8", # accent-pink + "audio": "#dd5b00", # accent-orange + "docs": "#2a9d99", # accent-teal + "ai": "#62aef0", # accent-sky (fallback for ai stack) + "media": "#5fbf7a", # media & entertainment + "data": "#c07af0", # data & links + "storage": "#e0a63c", # storage & backups + "home": "#41b6c6", # home & iot + "home-mgmt": "#3fbfa8", # home management + "dev": "#e07b39", # development + "portfolio": "#b089d6", # portfolio / resume + "infra": "#9aa4ad", # infrastructure + "network": "#7db2f0", # home network + } + + @property + def is_secure(self) -> bool: + return not self.DEBUG + + +settings = Settings() \ No newline at end of file diff --git a/portal/database.py b/portal/database.py new file mode 100644 index 0000000..fa1ab31 --- /dev/null +++ b/portal/database.py @@ -0,0 +1,117 @@ +"""Database models and async engine (pgvector / pg16). + +SQLAlchemy 2.0 async with asyncpg. The users table stores profile + tool +selection; the preferences table leaves room for per-user preferences and +future semantic-memory vectors (pgvector column commented out deliberately so +nothing depends on the extension at first launch). +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import ( + Boolean, + DateTime, + ForeignKey, + MetaData, + String, + func, + select, +) +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + +from .config import settings + +# Recommend enabling pgvector: CREATE EXTENSION IF NOT EXISTS vector; +# then uncomment the `embedding` column on ToolPreference. +naming_convention = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +class Base(DeclarativeBase): + metadata = MetaData(naming_convention=naming_convention) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column(String(64), unique=True, index=True) + full_name: Mapped[str] = mapped_column(String(128)) + password_hash: Mapped[str] = mapped_column(String(256)) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False) + can_chat: Mapped[bool] = mapped_column(Boolean, default=True) + can_image: Mapped[bool] = mapped_column(Boolean, default=True) + can_video: Mapped[bool] = mapped_column(Boolean, default=True) + can_audio: Mapped[bool] = mapped_column(Boolean, default=True) + can_docs: Mapped[bool] = mapped_column(Boolean, default=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + preferences: Mapped[list["ToolPreference"]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"" + + +class ToolPreference(Base): + """Per-user saved preference for a tool slot (e.g. favourite editor).""" + + __tablename__ = "tool_preferences" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + tool_id: Mapped[str] = mapped_column(String(64), index=True) + value: Mapped[str] = mapped_column(String(512), default="") + + # Semantic memory vector (optional, pgvector). + # embedding: Mapped[Vector | None] = mapped_column(Vector(1536), nullable=True) + + user: Mapped[User] = relationship(back_populates="preferences") + + +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, + pool_pre_ping=True, +) +SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def init_db() -> None: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def get_session() -> AsyncSession: # FastAPI dependency + async with SessionLocal() as session: + yield session + + +from contextlib import asynccontextmanager + + +@asynccontextmanager +async def session_scope(): + """Explicit async context manager for service/route use (not a FastAPI dep).""" + async with SessionLocal() as session: + yield session + + +async def get_user_by_username(session: AsyncSession, username: str) -> User | None: + result = await session.execute(select(User).where(User.username == username)) + return result.scalar_one_or_none() \ No newline at end of file diff --git a/portal/main.py b/portal/main.py new file mode 100644 index 0000000..88ddd9c --- /dev/null +++ b/portal/main.py @@ -0,0 +1,503 @@ +"""Family Home Lab console — FastAPI + Jinja2 + HTMX. + +Screens: + /login POST form -> sets session cookie, redirects + / dashboard (tool grid) + /tool/{id} embed view (iframe well) or link-out + /admin user + service management (Sam only) + /api/status HTMX-polled status endpoint (30s) + +Every page renders against the design tokens in static/tokens.css. +""" +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +MELB = ZoneInfo("Australia/Melbourne") +import json +import os +from pathlib import Path +from typing import Annotated + +from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile +from fastapi.concurrency import run_in_threadpool +from fastapi.responses import HTMLResponse, RedirectResponse, Response +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from sqlalchemy import select + +from . import auth +from .config import settings +from .database import ( + session_scope, + User, + get_session, + get_user_by_username, + init_db, +) +from .tasks import ensure_user_bucket, transcribe_audio, transcribe_sheetmusic +from .s3 import list_prefix, list_recent, s3_bucket_name, s3_client +from .tools import section_color, section_label, tools_for_user + +logger = logging.getLogger(__name__) + +BASE_DIR = Path(__file__).resolve().parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) +templates.env.globals["settings"] = settings + +app = FastAPI(title=settings.APP_NAME, docs_url=None if not settings.DEBUG else "/docs") +app.mount( + "/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static" +) + + +# Per-user tool URL for the dsh chat instance placeholder; actual contract is +# defined in plan.md §6 to be handed to the dsh agent. +# (dsh link specialization lives in portal/tools.py::_dsh_for_user) + + +@app.on_event("startup") +async def on_startup() -> None: + await init_db() + await _seed_admin_if_empty() + + +async def _seed_admin_if_empty() -> None: + """First-run: if the users table is empty, create the admin from env.""" + from sqlalchemy import func + + admin_user = settings.ADMIN_USERNAME + admin_pass = settings.ADMIN_PASSWORD + admin_name = settings.ADMIN_FULLNAME + if not admin_user or not admin_pass: + logger.warning("ADMIN_USERNAME/ADMIN_PASSWORD not set — skipping seed") + return + + async with session_scope() as session: + count = await session.scalar(select(func.count()).select_from(User)) + if count: + return + admin = User( + username=admin_user, + full_name=admin_name or admin_user, + password_hash=auth.hash_password(admin_pass), + is_admin=True, + can_chat=True, + can_image=True, + can_video=True, + can_audio=True, + is_active=True, + ) + session.add(admin) + await session.commit() + logger.info("Seeded initial admin account %r", admin_user) + + +def _greeting(now: datetime) -> str: + h = now.astimezone(MELB).hour + if h < 5: + return "Up late" + if h < 12: + return "Good morning" + if h < 18: + return "Good afternoon" + return "Good evening" + + +async def _current_user(request: Request) -> User | None: + """Resolve the logged-in user from the session cookie (async-safe).""" + user = await auth.current_user_dep(request) + return user + + +@app.get("/login", response_class=HTMLResponse) +async def login_page( + request: Request, + next: str = "/", +) -> HTMLResponse: + return templates.TemplateResponse( + request, + "login.html", + {"greeting": _greeting(datetime.now(timezone.utc)), "next": next}, + ) + + +@app.post("/login") +async def login_submit( + request: Request, + username: Annotated[str, Form()], + password: Annotated[str, Form()], + next: str = Form("/"), +) -> HTMLResponse: + async with session_scope() as session: + user = await get_user_by_username(session, username.strip()) + + if user is None or not auth.verify_password(password, user.password_hash): + return templates.TemplateResponse( + request, + "login.html", + { + "greeting": _greeting(datetime.now(timezone.utc)), + "error": "Incorrect username or password.", + "next": next, + }, + status_code=401, + ) + if not user.is_active: + return templates.TemplateResponse( + request, + "login.html", + { + "greeting": _greeting(datetime.now(timezone.utc)), + "error": "This account is disabled.", + "next": next, + }, + status_code=403, + ) + + response = RedirectResponse(next or "/", status_code=303) + auth.set_session_cookie(request, response, user.username) + return response + + +@app.get("/logout") +async def logout(request: Request): + response = RedirectResponse("/login", status_code=303) + auth.clear_session_cookie(request, response) + return response + + +@app.get("/", response_class=HTMLResponse) +async def dashboard(request: Request) -> HTMLResponse: + user = await _current_user(request) + if user is None: + return RedirectResponse("/login", status_code=303) + groups = tools_for_user(user) + now = datetime.now(timezone.utc) + return templates.TemplateResponse( + request, + "dashboard.html", + { + "user": user, + "greeting": _greeting(now), + "sections": [ + { + "label": section_label(cat), + "color": section_color(cat), + "tools": group, + } + for cat, group in groups + ], + }, + ) + + +@app.get("/tool/{tool_id}", response_class=HTMLResponse) +async def tool_embed(request: Request, tool_id: str) -> HTMLResponse: + user = await _current_user(request) + if user is None: + return RedirectResponse("/login", status_code=303) + groups = tools_for_user(user) + tool = next((t for _, g in groups for t in g if t.id == tool_id), None) + if tool is None: + return templates.TemplateResponse( + request, "not_found.html", {"user": user}, status_code=404 + ) + # Workspace files: the user's bucket + the shared pool (does not block the + # event loop, degrades to empty list on any S3 error). + user_bucket = s3_bucket_name(user.username) + my_files = await run_in_threadpool(list_recent, user_bucket, 20) + shared_files = await run_in_threadpool(list_recent, "shared-media", 20) + ctx = { + "user": user, + "tool": tool, + "files": my_files, + "shared_files": shared_files, + "user_bucket": user_bucket, + } + if tool.mode == "window": + # Link-out fallback for tools that forbid framing. + return templates.TemplateResponse(request, "linkout.html", ctx) + return templates.TemplateResponse(request, "embed.html", ctx) + + +@app.get("/admin", response_class=HTMLResponse) +async def admin_panel(request: Request) -> HTMLResponse: + user = await _current_user(request) + if user is None: + return RedirectResponse("/login", status_code=303) + if not user.is_admin: + return templates.TemplateResponse( + request, "forbidden.html", {"user": user}, status_code=403 + ) + async with session_scope() as session: + result = await session.scalars(select(User).order_by(User.username)) + users = list(result) + return templates.TemplateResponse( + request, "admin.html", {"user": user, "users": users} + ) + + + +@app.get("/account/password", response_class=HTMLResponse) +async def change_password_page(request: Request) -> HTMLResponse: + user = await _current_user(request) + if user is None: + return RedirectResponse("/login", status_code=303) + return templates.TemplateResponse( + request, "change_password.html", {"user": user} + ) + + +@app.post("/account/password") +async def change_password( + request: Request, + current: Annotated[str, Form()], + password: Annotated[str, Form()], + confirm: Annotated[str, Form()], +) -> HTMLResponse: + user = await _current_user(request) + if user is None: + return RedirectResponse("/login", status_code=303) + error = None + try: + ok_current = auth.verify_password(current, user.password_hash) + except Exception: + ok_current = False + if password != confirm: + error = "New passwords do not match." + elif not ok_current: + error = "Current password is incorrect." + elif len(password) < 8: + error = "New password must be at least 8 characters." + else: + async with session_scope() as session: + u = await get_user_by_username(session, user.username) + if u is not None: + u.password_hash = auth.hash_password(password) + await session.commit() + return RedirectResponse("/?pw=1", status_code=303) + return templates.TemplateResponse( + request, "change_password.html", {"user": user, "error": error}, + status_code=400, + ) + + +@app.post("/admin/users") +async def admin_add_user( + request: Request, + username: Annotated[str, Form()], + full_name: Annotated[str, Form()], + password: Annotated[str, Form()], +) -> HTMLResponse: + """Admin: create a user and enqueue their S3 bucket provisioning.""" + admin = await _current_user(request) + if admin is None: + return RedirectResponse("/login", status_code=303) + if not admin.is_admin: + raise HTTPException(status_code=403, detail="Admins only") + + async with session_scope() as session: + existing = await get_user_by_username(session, username.strip()) + if existing is not None: + return templates.TemplateResponse( + request, + "admin.html", + {"user": admin, "error": "Username already exists."}, + status_code=400, + ) + user = User( + username=username.strip(), + full_name=full_name.strip() or username.strip(), + password_hash=auth.hash_password(password), + ) + session.add(user) + await session.commit() + new_username = user.username + # Provision bucket in background. User creation must never fail because a + # background broker hiccup ; log instead so provisioning can be retried. + try: + ensure_user_bucket.delay(new_username) + except Exception: + logger.warning("Could not enqueue bucket provisioning for %r (broker down?)", new_username) + return RedirectResponse("/admin", status_code=303) + + + +@app.get("/admin/pi", response_class=HTMLResponse) +async def admin_pi(request: Request) -> HTMLResponse: + """Admin: Pi Dashboard — agent session cards sourced from ~/.pi/agent/dashboard. + + Files are synced from the pi host into /pi-dashboard (mounted ro). Shows + session name, machine, status, last tool, last-seen, cost estimate. + """ + user = await _current_user(request) + if user is None: + return RedirectResponse("/login", status_code=303) + if not user.is_admin: + raise HTTPException(status_code=403, detail="Admins only") + pi_dir = Path(os.getenv("PI_DASHBOARD_DIR", "/pi-dashboard")) + sessions = [] + if pi_dir.is_dir(): + for f in sorted(pi_dir.glob("*.json")): + try: + sessions.append(json.loads(f.read_text(encoding="utf-8"))) + except Exception: + continue + sessions.sort(key=lambda s: (s.get("last_seen_at") or ""), reverse=True) + return templates.TemplateResponse( + request, "admin_pi.html", {"user": user, "sessions": sessions, "pi_dir": str(pi_dir)} + ) + +@app.post("/admin/users/rename") +async def admin_rename_user( + request: Request, + old_username: Annotated[str, Form()], + new_username: Annotated[str, Form()], +) -> HTMLResponse: + """Admin: rename a user's login username (keeps password, perms, bucket name).""" + admin = await _current_user(request) + if admin is None: + return RedirectResponse("/login", status_code=303) + if not admin.is_admin: + raise HTTPException(status_code=403, detail="Admins only") + old_username = old_username.strip() + new_username = new_username.strip() + error = None + async with session_scope() as session: + target = await get_user_by_username(session, old_username) + if target is None: + error = f"User {old_username!r} not found." + elif new_username == old_username: + error = "No change." + elif await get_user_by_username(session, new_username) is not None: + error = f"Username {new_username!r} is already taken." + else: + target.username = new_username + await session.commit() + logger.info("Admin %s renamed user %s -> %s", admin.username, old_username, new_username) + async with session_scope() as session: + result = await session.scalars(select(User).order_by(User.username)) + users = list(result) + return templates.TemplateResponse( + request, "admin.html", {"user": admin, "users": users, "error": error}, + status_code=400 if error else 200, + ) + + +_AUDIO_EXT = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aiff", ".aif", ".opus"} + + +@app.get("/transcriber", response_class=HTMLResponse) +async def transcriber_page(request: Request) -> HTMLResponse: + user = await _current_user(request) + if user is None: + return RedirectResponse("/login", status_code=303) + bucket = s3_bucket_name(user.username) + all_keys = await run_in_threadpool(list_prefix, bucket, "", 400) + audio_files = [k for k in all_keys + if k.lower().endswith(tuple(_AUDIO_EXT))] + transcriptions = [k for k in all_keys + if k.startswith("transcriptions/") or k.startswith("sheetmusic/")] + return templates.TemplateResponse( + request, "transcriber.html", + {"user": user, "bucket": bucket, "audio_files": audio_files, + "transcriptions": transcriptions, "status": request.query_params.get("status", "")}, + ) + + +@app.post("/transcriber/transcribe") +async def transcriber_transcribe( + request: Request, + key: Annotated[str, Form()], +) -> HTMLResponse: + user = await _current_user(request) + if user is None: + return RedirectResponse("/login", status_code=303) + bucket = s3_bucket_name(user.username) + try: + transcribe_audio.delay(bucket, key, user.username) + except Exception: + logger.warning("could not enqueue transcription (broker down?)", exc_info=True) + return RedirectResponse("/transcriber?status=started", status_code=303) + + +@app.post("/transcriber/upload") +async def transcriber_upload( + request: Request, + file: Annotated[UploadFile, File()], +) -> HTMLResponse: + user = await _current_user(request) + if user is None: + return RedirectResponse("/login", status_code=303) + import uuid + bucket = s3_bucket_name(user.username) + safe = (file.filename or "audio").replace(" ", "_") + key = f"audio/{uuid.uuid4().hex[:8]}-{safe}" + content = await file.read() + await run_in_threadpool( + lambda: s3_client().put_object(Bucket=bucket, Key=key, Body=content) + ) + try: + transcribe_audio.delay(bucket, key, user.username) + except Exception: + logger.warning("could not enqueue transcription after upload", exc_info=True) + return RedirectResponse("/transcriber?status=started", status_code=303) + + +@app.get("/transcriber/download") +async def transcriber_download(request: Request, key: str): + user = await _current_user(request) + if user is None: + return RedirectResponse("/login", status_code=303) + bucket = s3_bucket_name(user.username) + try: + data = await run_in_threadpool( + lambda: ( + s3_client().get_object(Bucket=bucket, Key=key)["Body"] + .read() + ) + ) + except Exception: + return HTMLResponse("not found", status_code=404) + fname = key.rsplit("/", 1)[-1] + return Response(content=data, media_type="audio/midi") + + + +@app.post("/transcriber/sheetmusic") +async def transcriber_sheetmusic( + request: Request, + key: Annotated[str, Form()], +) -> HTMLResponse: + user = await _current_user(request) + if user is None: + return RedirectResponse("/login", status_code=303) + bucket = s3_bucket_name(user.username) + try: + transcribe_sheetmusic.delay(bucket, key, user.username) + except Exception: + logger.warning("could not enqueue sheet-music transcription", exc_info=True) + return RedirectResponse("/transcriber?status=started-mus", status_code=303) + + +@app.get("/api/status") +async def api_status(request: Request) -> HTMLResponse: + """HTMX-polled snippet: updates each tool card's status dot (not full page).""" + user = await _current_user(request) + if user is None: + return HTMLResponse("", status_code=401) + groups = tools_for_user(user) + # Status is static for now; swap `status` for a live probe later. + return templates.TemplateResponse( + request, + "partials/_status.html", + {"sections": [{"tools": g} for _, g in groups]}, + ) + + +@app.get("/healthz") +async def healthz() -> dict: + return {"ok": True, "app": settings.APP_NAME} \ No newline at end of file diff --git a/portal/requirements.txt b/portal/requirements.txt new file mode 100644 index 0000000..0e96c08 --- /dev/null +++ b/portal/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +jinja2==3.1.5 +httpx==0.28.1 +itsdangerous==2.2.0 +bcrypt==4.2.1 +sqlalchemy[asyncio]==2.0.36 +asyncpg==0.30.0 +celery==5.4.0 +redis==5.2.1 +boto3==1.35.79 +python-multipart==0.0.20 \ No newline at end of file diff --git a/portal/s3.py b/portal/s3.py new file mode 100644 index 0000000..a3947a4 --- /dev/null +++ b/portal/s3.py @@ -0,0 +1,66 @@ +"""S3 (Garage) helpers shared by the web layer and Celery workers.""" +from __future__ import annotations + +from datetime import datetime, timezone + +import boto3 +from botocore.config import Config + +from .config import settings + +_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) + + +def s3_client(): + """Garage S3 client (S3v4 signatures).""" + return boto3.client( + "s3", + endpoint_url=settings.S3_ENDPOINT_URL, + region_name=settings.S3_REGION or None, + aws_access_key_id=settings.S3_ACCESS_KEY or None, + aws_secret_access_key=settings.S3_SECRET_KEY or None, + config=Config(signature_version="s3v4"), + ) + + +def s3_bucket_name(username: str) -> str: + """Valid S3 bucket name for a user. + + S3 names must be >= 3 chars, lowercase, only letters/numbers/dots/hyphens. + Short usernames (e.g. "jo") get a suffix so the bucket is legal. + """ + u = (username or "").strip().lower() + if not u: + return "shared-media" + return u if len(u) >= 3 else f"{u}-media" + + +def list_recent(bucket: str, max_keys: int = 20) -> list[dict]: + """Return the newest objects in a bucket, newest first. + + Returns an empty list on any error (unreachable/misconfigured/empty), so the + UI can degrade gracefully. + """ + try: + resp = s3_client().list_objects_v2(Bucket=bucket, MaxKeys=max_keys) + except Exception: # noqa: BLE001 - degrade to "no files" + return [] + objects = [ + { + "key": o.get("Key"), + "size": o.get("Size", 0), + "last_modified": o.get("LastModified"), + } + for o in resp.get("Contents", []) + ] + objects.sort(key=lambda x: x["last_modified"] or _EPOCH, reverse=True) + return objects + + +def list_prefix(bucket: str, prefix: str, max_keys: int = 200) -> list[str]: + """List object keys under a prefix (best-effort).""" + try: + resp = s3_client().list_objects_v2(Bucket=bucket, Prefix=prefix, MaxKeys=max_keys) + except Exception: # noqa: BLE001 + return [] + return [o.get("Key") for o in resp.get("Contents", [])] \ No newline at end of file diff --git a/portal/static/app.css b/portal/static/app.css new file mode 100644 index 0000000..9f62637 --- /dev/null +++ b/portal/static/app.css @@ -0,0 +1,330 @@ +/* ============================================================ + Family Home Lab — Components & Layout + Builds strictly from tokens.css variables per DESIGN.md. + ============================================================ */ + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: var(--font-family); + background: var(--canvas-soft); + color: var(--ink-secondary); + font: var(--type-body-md); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +a { color: var(--primary); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* -------------------------------------------------- Login */ +.login-screen { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--secondary); + color: var(--surface); + position: relative; + overflow: hidden; + padding: var(--space-lg); +} +/* Subtle starfield/sticker dots */ +.login-screen::before { + content: ""; + position: absolute; inset: 0; + background-image: + radial-gradient(rgba(255,255,255,.16) 1px, transparent 1.5px), + radial-gradient(rgba(255,255,255,.1) 1px, transparent 1.5px); + background-size: 44px 44px, 71px 71px; + background-position: 0 0, 17px 21px; + pointer-events: none; +} +.login-wrap { position: relative; z-index: 1; width: 100%; max-width: 400px; } +.login-greeting { + margin: 0 0 var(--space-xxl); + font: var(--type-display-1); + color: var(--surface); + letter-spacing: -1.5px; +} +.auth-card { + background: var(--surface); + border-radius: var(--rounded-lg); + box-shadow: var(--shadow-level-2); + padding: var(--space-xl) var(--space-xl); + color: var(--ink-secondary); +} +.auth-title { font: var(--type-title); margin: 0 0 var(--space-md); color: var(--ink); } + +/* -------------------------------------------------- Form bits */ +.form-field { margin-bottom: var(--space-md); } +.form-field label { + display: block; + font: var(--type-eyebrow); + letter-spacing: 0.125px; + color: var(--ink-muted); + margin-bottom: var(--space-xs); +} +.text-input { + width: 100%; + padding: 10px 12px; + font: var(--type-body-sm); + color: var(--ink); + border: 1px solid rgb(221, 221, 221); + border-radius: var(--rounded-xs); + background: var(--surface); +} +.text-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: var(--shadow-level-1), 0 0 0 3px rgba(0, 117, 222, 0.18); +} +.form-error { + background: #fdecec; + color: #b3261e; + border: 1px solid #f5c6c4; + border-radius: var(--rounded-sm); + padding: var(--space-sm); + font: var(--type-body-sm); + margin-bottom: var(--space-md); +} + +/* -------------------------------------------------- Buttons */ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-xs); + border: none; + cursor: pointer; + font: var(--type-button); + text-decoration: none; + transition: transform .08s ease, background .15s ease, box-shadow .15s ease; +} +.button:active { transform: scale(0.97); } +button.button, input[type="submit"].button { font-family: inherit; } + +.button-primary { + background: var(--primary); + color: var(--on-primary); + border-radius: var(--rounded-full); + padding: 12px 28px; +} +.button-primary:hover { background: var(--primary-active); color: var(--on-primary); text-decoration: none; } + +.button-secondary { + background: var(--surface); + color: var(--ink); + border-radius: var(--rounded-full); + padding: 11px 26px; + box-shadow: var(--shadow-level-1); + border: 1px solid var(--hairline); +} +.button-secondary:hover { box-shadow: var(--shadow-level-1); } + +.button-utility { + background: var(--surface); + color: var(--ink); + border-radius: var(--rounded-md); + padding: 4px 14px; + border: 1px solid var(--hairline); + font: var(--type-body-sm); +} +.button-utility:hover { background: var(--canvas-soft); } + +.width-full { width: 100%; } + +/* -------------------------------------------------- Nav bar */ +.nav-bar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-lg); + background: var(--surface); + border-bottom: 1px solid var(--hairline); + padding: var(--space-sm) var(--space-xxl); +} +.nav-wordmark { + font: var(--type-heading-2); + color: var(--ink); + letter-spacing: -0.25px; + text-decoration: none; +} +.nav-wordmark:hover { text-decoration: none; } +.nav-right { display: flex; align-items: center; gap: var(--space-md); } +.nav-user { display: flex; align-items: center; gap: var(--space-xs); font: var(--type-body-sm); color: var(--ink-secondary); } +.nav-avatar { + width: 28px; height: 28px; + border-radius: var(--rounded-full); + background: var(--primary); + color: var(--on-primary); + display: flex; align-items: center; justify-content: center; + font: var(--type-button); +} + +/* -------------------------------------------------- Layout */ +.page { max-width: var(--container-max); margin: 0 auto; padding: var(--space-xxl) var(--space-lg); } +.greeting { font: var(--type-heading-1); color: var(--ink); margin: 0 0 var(--space-xxl); } + +.section { margin-bottom: var(--space-xl); } +.section-head { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-md); } +.category-pill { + display: inline-flex; + align-items: center; + gap: 6px; + font: var(--type-eyebrow); + letter-spacing: 0.125px; + background: var(--surface); + border-radius: var(--rounded-full); + padding: 4px 8px; + border: 1px solid var(--hairline); +} +.pill-dot { width: 8px; height: 8px; border-radius: var(--rounded-full); } +.section-title { font: var(--type-heading-2); color: var(--ink); margin: 0; } + +/* -------------------------------------------------- Tool grid */ +.tool-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-lg); +} +@media (max-width: 840px) { .tool-grid { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 600px) { .tool-grid { grid-template-columns: 1fr; } } + +.tool-card { + display: flex; + flex-direction: column; + gap: var(--space-sm); + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--rounded-lg); + padding: var(--space-lg); + text-decoration: none; + color: var(--ink-secondary); + transition: box-shadow .18s ease, transform .18s ease; +} +.tool-card:hover { + box-shadow: var(--shadow-level-1); + transform: translateY(-1px); + text-decoration: none; +} +.tool-card-top { display: flex; align-items: flex-start; justify-content: space-between; } +.tool-icon { + width: 40px; height: 40px; + border-radius: var(--rounded-full); + display: flex; align-items: center; justify-content: center; + color: var(--surface); + font: var(--type-title); + flex: none; +} +.tool-card .tool-name { font: var(--type-title); color: var(--ink); margin: 0; } +.tool-card .tool-desc { font: var(--type-body-sm); color: var(--ink-muted); margin: 0; flex: 1; } +.tool-card-bottom { display: flex; align-items: center; justify-content: space-between; } +.launch-chip { font: var(--type-caption); color: var(--ink-faint); } +.tag-chip { + font: var(--type-caption); + color: var(--ink-muted); + border: 1px solid var(--hairline); + border-radius: var(--rounded-full); + padding: 1px 8px; + background: var(--surface-2); +} +.tag-chip-login { color: var(--sticker-orange); border-color: color-mix(in srgb, var(--sticker-orange) 40%, transparent); } +.tag-chip-admin { color: var(--ink-muted); border-style: dashed; } + +/* catalogue search */ +.dash-search { + max-width: 480px; + margin-bottom: var(--space-lg); +} +.tool-grid .tool-card.hidden { display: none; } +.section.hidden-section { display: none; } + +/* status dot levels */ +.status-dot { + width: 8px; height: 8px; border-radius: var(--rounded-full); display: inline-block; +} +.status-online { background: var(--sticker-green); } +.status-starting { background: var(--sticker-orange); } +.status-offline { background: var(--ink-faint); } + +/* loading pulse for polling */ +.tool-grid.htmx-request .tool-card { opacity: .6; transition: opacity .2s; } + +/* -------------------------------------------------- Tool embed */ +.tool-bar { + position: sticky; top: 52px; z-index: 15; + display: flex; align-items: center; justify-content: space-between; + gap: var(--space-md); + background: var(--surface); + border-bottom: 1px solid var(--hairline); + padding: var(--space-sm) var(--space-xxl); +} +.embed-well { + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--rounded-lg); + overflow: hidden; + height: calc(100vh - 170px); + box-shadow: var(--shadow-level-1); +} +.embed-well iframe { width: 100%; height: 100%; border: 0; display: block; } + +/* -------------------------------------------------- Link-out fallback */ +.linkout-card { + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--rounded-lg); + padding: var(--space-xl); + max-width: 520px; + box-shadow: var(--shadow-level-1); +} + +/* -------------------------------------------------- Partial rows (status) */ +.status-refresh { + display: contents; /* keeps the HTMX swap invisible-pressure-free */ +} + +/* -------------------------------------------------- Admin */ +.user-table { width: 100%; border-collapse: collapse; background: var(--surface); border-radius: var(--rounded-lg); overflow: hidden; border: 1px solid var(--hairline); } +.user-table th, .user-table td { text-align: left; padding: var(--space-sm) var(--space-md); border-bottom: 1px solid var(--hairline); font: var(--type-body-sm); } +.user-table th { background: var(--canvas-soft); color: var(--ink-muted); font: var(--type-eyebrow); letter-spacing: .125px; } +.user-table tr:last-child td { border-bottom: none; } +.badge { display: inline-block; font: var(--type-eyebrow); border-radius: var(--rounded-full); padding: 2px 8px; background: var(--canvas-soft); color: var(--ink-muted); } +.badge-admin { background: var(--secondary); color: var(--surface); } + +.admin-panel { display: grid; grid-template-columns: 2fr 1fr; gap: var(--space-xxl); align-items: start; } +@media (max-width: 840px) { .admin-panel { grid-template-columns: 1fr; } } + +/* -------------------------------------------------- Workspace / file list */ +.tool-detail { display: flex; flex-direction: column; gap: var(--space-lg); } +.workspace { + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--rounded-lg); + padding: var(--space-lg) var(--space-xl); + box-shadow: var(--shadow-level-1); +} +.workspace-head { + display: flex; align-items: baseline; justify-content: space-between; + gap: var(--space-md); margin-bottom: var(--space-sm); +} +.file-list { list-style: none; margin: 0; padding: 0; } +.file-list li { + display: flex; align-items: center; justify-content: space-between; + gap: var(--space-md); padding: 10px 2px; + border-bottom: 1px solid var(--hairline); font: var(--type-body-sm); +} +.file-list li:last-child { border-bottom: none; } +.file-name { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.file-meta { color: var(--ink-faint); font: var(--type-caption); white-space: nowrap; } + +/* -------------------------------------------------- Minimal, generic */ +.muted { color: var(--ink-muted); } +.faint { color: var(--ink-faint); } +.margin-top { margin-top: var(--space-lg); } +.stack > * + * { margin-top: var(--space-lg); } \ No newline at end of file diff --git a/portal/static/fonts/inter-400.woff2 b/portal/static/fonts/inter-400.woff2 new file mode 100644 index 0000000..33002f1 Binary files /dev/null and b/portal/static/fonts/inter-400.woff2 differ diff --git a/portal/static/fonts/inter-500.woff2 b/portal/static/fonts/inter-500.woff2 new file mode 100644 index 0000000..03aaea1 Binary files /dev/null and b/portal/static/fonts/inter-500.woff2 differ diff --git a/portal/static/fonts/inter-600.woff2 b/portal/static/fonts/inter-600.woff2 new file mode 100644 index 0000000..fb50a02 Binary files /dev/null and b/portal/static/fonts/inter-600.woff2 differ diff --git a/portal/static/fonts/inter-700.woff2 b/portal/static/fonts/inter-700.woff2 new file mode 100644 index 0000000..12b51d7 Binary files /dev/null and b/portal/static/fonts/inter-700.woff2 differ diff --git a/portal/static/tokens.css b/portal/static/tokens.css new file mode 100644 index 0000000..e037d62 --- /dev/null +++ b/portal/static/tokens.css @@ -0,0 +1,90 @@ +/* ============================================================ + Family Home Lab — Design Tokens + Source: DESIGN.md (Notion design language) + Only structural/semantic tokens here. All values map 1:1. + ============================================================ */ + +/* Inter (@fontsource) — self-hosted substitute for NotionInter */ +@font-face { font-family: "Inter"; font-style: normal; font-weight: 400; font-display: swap; src: url("/static/fonts/inter-400.woff2") format("woff2"); } +@font-face { font-family: "Inter"; font-style: normal; font-weight: 500; font-display: swap; src: url("/static/fonts/inter-500.woff2") format("woff2"); } +@font-face { font-family: "Inter"; font-style: normal; font-weight: 600; font-display: swap; src: url("/static/fonts/inter-600.woff2") format("woff2"); } +@font-face { font-family: "Inter"; font-style: normal; font-weight: 700; font-display: swap; src: url("/static/fonts/inter-700.woff2") format("woff2"); } + +:root { + /* --- Surface --- */ + --canvas-soft: #f6f5f4; + --surface: #ffffff; + --hairline: #e6e6e6; + + /* --- Text --- */ + --ink: #000000; + --ink-secondary: #31302e; + --ink-muted: #615d59; + --ink-faint: #a39e98; + + /* --- Structural accents --- */ + --primary: #0075de; /* the ONLY action colour */ + --primary-active: #005bab; + --on-primary: #ffffff; + --secondary: #213183; /* deep indigo — login band + greeting only */ + + /* --- Sticker palette (decorative only) --- */ + --sticker-sky: #62aef0; /* chat */ + --sticker-purple: #d6b6f6; /* image */ + --sticker-pink: #ff64c8; /* video */ + --sticker-orange: #dd5b00; /* audio */ + --sticker-teal: #2a9d99; /* docs */ + --sticker-green: #1aae39; /* status: online/saved */ + --sticker-purple-deep: #391c57; /* illustration only */ + --sticker-orange-deep: #793400; /* illustration only */ + + /* --- Typography (Inter, self-hosted via @font-face) --- */ + --font-family: "Inter", -apple-system, system-ui, "Segoe UI", + Helvetica, Arial, sans-serif; + + /* Font-family is baked into each token below: the CSS `font` shorthand + resets font-family to the UA default (serif) unless a family is written, + and it cannot carry letter-spacing — so weights/sizes ship inline and the + family is appended per token. */ + --type-display-1: 700 48px/1.05 var(--font-family); + --type-heading-1: 700 32px/1.1 var(--font-family); + --type-heading-2: 700 22px/1.27 var(--font-family); + --type-title: 600 17px/1.4 var(--font-family); + --type-body-md: 400 15px/1.5 var(--font-family); + --type-body-sm: 400 14px/1.4 var(--font-family); + --type-button: 500 15px/1.4 var(--font-family); + --type-caption: 400 13px/1.43 var(--font-family); + --type-eyebrow: 600 12px/1.33 var(--font-family); + + /* --- Layout (base unit 8px) --- */ + --space-xxs: 4px; + --space-xs: 8px; + --space-sm: 12px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 28px; + --space-xxl: 32px; + + --container-max: 1200px; + + /* --- Elevation --- */ + --shadow-level-1: rgba(0, 0, 0, 0.01) 0 0.175px 1px, + rgba(0, 0, 0, 0.02) 0 0.8px 3px, + rgba(0, 0, 0, 0.027) 0 2px 8px, + rgba(0, 0, 0, 0.04) 0 4px 18px; + --shadow-level-2: rgba(0, 0, 0, 0.0122) 0 0.175px 1px, + rgba(0, 0, 0, 0.024) 0 0.8px 3px, + rgba(0, 0, 0, 0.032) 0 2px 8px, + rgba(0, 0, 0, 0.047) 0 4px 18px, + rgba(0, 0, 0, 0.05) 0 23px 52px; + + /* --- Shapes --- */ + --rounded-xs: 4px; + --rounded-sm: 5px; + --rounded-md: 8px; + --rounded-lg: 12px; + --rounded-full: 9999px; +} + +/* Numerals: enable OpenType lnum */ +html { font-feature-settings: "lnum" 1; } \ No newline at end of file diff --git a/portal/tasks.py b/portal/tasks.py new file mode 100644 index 0000000..e10175d --- /dev/null +++ b/portal/tasks.py @@ -0,0 +1,171 @@ +"""Background workers (Celery + Redis). + +Handles async work that shouldn't block the web request: S3 bucket +operations, backup notifications, and (future) media processing. + +Run with: + celery -A portal.tasks.celery_app worker --loglevel=info +""" +from __future__ import annotations + +import logging + +from celery import Celery + +from .config import settings +from .s3 import s3_bucket_name, s3_client + +logger = logging.getLogger(__name__) + +celery_app = Celery( + "family_home_lab", + broker=settings.CELERY_BROKER_URL, + backend=settings.REDIS_URL, + include=["portal.tasks"], +) + +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + timezone="UTC", + enable_utc=True, + broker_connection_retry_on_startup=True, +) + + +@celery_app.task(bind=True, max_retries=3, default_retry_delay=30) +def ensure_user_bucket(self, username: str) -> dict: + """Create (idempotently) the per-user S3 bucket in Garage.""" + try: + client = s3_client() + for bucket in ("shared-media", s3_bucket_name(username)): + try: + client.head_bucket(Bucket=bucket) + logger.info("Bucket %s exists", bucket) + except Exception: + client.create_bucket(Bucket=bucket) + logger.info("Created bucket %s", bucket) + return {"ok": True, "buckets": ["shared-media", s3_bucket_name(username)]} + except Exception as exc: # noqa: BLE001 + logger.exception("ensure_user_bucket failed") + raise self.retry(exc=exc) from exc + + +@celery_app.task(bind=True, queue="transcription-mus", max_retries=1, default_retry_delay=120) +def transcribe_sheetmusic(self, bucket: str, key: str, user: str = "") -> dict: + """Transcribe audio to MIDI + MusicXML + engraved PDF / tabs (Kyutai MuScriptor). + + Runs on the dedicated `transcriber-mus` worker. Requires HUGGINGFACE_TOKEN (the + CC BY-NC model weights are gated) and is slow on CPU. Uses the muscriptor CLI: + muscriptor transcribe