4 Commits

Author SHA1 Message Date
root
e29bacbda1 feat(dross): voice Phase 2a — local whisper transcribe + mic (2.12.0)
faster-whisper (small.en, GPU+CPU fallback) on CT 102 → POST
/api/voice/transcribe (multer→whisper client) → mic in the bubble
records (MediaRecorder), uploads, drops the transcript into the input
to review-and-send. Infra scripts in deploy/whisper/. Retention (P2b)
next. NOTE: mic needs a secure context (the https domain), not the LAN IP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 01:00:10 +10:00
root
fc1e93a58f docs(dross): Phase 2 (voice) design spec
Local faster-whisper on CT 102, record→transcribe→review-send, and a
durable owner-only clip-retention store (transcript in void-db, audio on
a backed-up ZFS dataset — not void-app's ephemeral tier). Encryption-at-
rest noted as future.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:45:59 +10:00
root
2dc9d612de docs(dross): mark Phase 1 shipped (2.11.0) 2026-06-10 00:34:54 +10:00
root
e2be462ecb fix(dross): collapse shell to 2 columns; topbar ◆ summons Dross
Removing #rightrail left a dead 360px grid column that narrowed #main.
Shell grid is now sidebar+main; the topbar ◆ (was Toggle-companion-rail)
now dispatches dross-toggle to open/close the floating bubble. Remaining
.rail-* CSS + chrome.js toggleRail are dead no-ops (minor cleanup later).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:34:39 +10:00
13 changed files with 273 additions and 22 deletions

16
deploy/whisper/README.md Normal file
View File

@@ -0,0 +1,16 @@
# faster-whisper service (Dross voice STT)
Runs on **CT 102** (the Ollama box, `192.168.1.185`), bare-metal (no Docker), on the
RTX A2000 with CPU fallback. OpenAI-style `/transcribe` consumed by void-app
`lib/voice/whisper.js` (`WHISPER_URL=http://192.168.1.185:8001`).
## Install (on CT 102)
```
scp deploy/whisper/{server.py,setup.sh} root@192.168.1.185:/opt/whisper_server.py /root/setup.sh
ssh root@192.168.1.185 'bash /root/setup.sh && install -m644 /opt/whisper_server.py /opt/whisper/server.py && systemctl enable --now whisper'
curl http://192.168.1.185:8001/health # {"ok":true,"model":"small.en","device":"cuda"}
```
- venv at `/opt/whisper/venv`; model `small.en` (env `WHISPER_MODEL`); CUDA libs via
`nvidia-cublas-cu12`/`nvidia-cudnn-cu12` pip wheels (LD_LIBRARY_PATH in the unit).
- GPU → CPU fallback is in `server.py` `load()`.
- **CT 102 disk was expanded +20G** (was 89% full) before install.

35
deploy/whisper/server.py Normal file
View File

@@ -0,0 +1,35 @@
import os, tempfile
from fastapi import FastAPI, UploadFile, File, HTTPException
from faster_whisper import WhisperModel
MODEL = os.environ.get("WHISPER_MODEL", "small.en")
app = FastAPI()
model = None
device_used = None
def load():
global model, device_used
try:
model = WhisperModel(MODEL, device="cuda", compute_type="int8_float16")
device_used = "cuda"
except Exception:
model = WhisperModel(MODEL, device="cpu", compute_type="int8")
device_used = "cpu"
load()
@app.get("/health")
def health():
return {"ok": True, "model": MODEL, "device": device_used}
@app.post("/transcribe")
async def transcribe(file: UploadFile = File(...)):
data = await file.read()
if not data:
raise HTTPException(400, "empty audio")
with tempfile.NamedTemporaryFile(suffix=".bin") as f:
f.write(data); f.flush()
segments, info = model.transcribe(f.name, beam_size=1, vad_filter=True)
text = "".join(s.text for s in segments).strip()
return {"text": text, "language": info.language,
"duration": round(info.duration, 2), "device": device_used}

26
deploy/whisper/setup.sh Normal file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq python3-pip python3-venv ffmpeg >/dev/null
mkdir -p /opt/whisper
python3 -m venv /opt/whisper/venv
/opt/whisper/venv/bin/pip install -q --upgrade pip
/opt/whisper/venv/bin/pip install -q faster-whisper fastapi "uvicorn[standard]" python-multipart nvidia-cublas-cu12 nvidia-cudnn-cu12
SITE=/opt/whisper/venv/lib/python3.12/site-packages
cat > /etc/systemd/system/whisper.service <<UNIT
[Unit]
Description=faster-whisper transcription server (Dross voice)
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/whisper
Environment=WHISPER_MODEL=small.en
Environment=LD_LIBRARY_PATH=${SITE}/nvidia/cublas/lib:${SITE}/nvidia/cudnn/lib
ExecStart=/opt/whisper/venv/bin/uvicorn server:app --host 0.0.0.0 --port 8001
Restart=on-failure
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
echo "deps+unit installed"

View File

@@ -1,7 +1,7 @@
# Floating Dross Chat — Design # Floating Dross Chat — Design
**Date:** 2026-06-09 **Date:** 2026-06-09
**Status:** Approved (pending final spec sign-off) **Status:** Phase 1 SHIPPED (v2.11.0, 2026-06-10) — global floating bubble + avatars + settings + persona. Phase 2 (voice) and the "Keep voice clips" retention setting are next.
**Goal:** Replace the docked, per-Space "Cradle Chat" with a global, movable floating-bubble Dross companion — mobile-first, with voice-clip input transcribed locally into instructions. **Goal:** Replace the docked, per-Space "Cradle Chat" with a global, movable floating-bubble Dross companion — mobile-first, with voice-clip input transcribed locally into instructions.
--- ---

View File

@@ -0,0 +1,64 @@
# Floating Dross Chat — Phase 2 (Voice) Design
**Date:** 2026-06-10
**Status:** Draft (awaiting sign-off)
**Builds on:** `2026-06-09-floating-dross-chat-design.md` (Phase 1 shipped in v2.11.0)
**Goal:** Let the user record a voice clip in the Dross bubble, transcribe it locally, and drop the transcript into the input to review-and-send. Optionally retain each clip paired with its transcript, stored durably and owner-only.
---
## Locked decisions (from the Phase-1 brainstorm + 2026-06-10 follow-up)
1. **STT = local faster-whisper on CT 102** (the Ollama box, RTX A2000). GPU with CPU fallback (per the GPU/CPU-fallback HA rule). English model (`small.en`) for speed/accuracy. OpenAI-compatible HTTP API.
2. **Flow = review-and-send first** (`voiceMode: 'review'`). Record → transcribe → transcript lands in the bubble input → user edits/sends. `handsfree` (auto-send) and `action` (interpret) are later (the setting already exists; only `review` is wired now).
3. **Retention = "Keep voice clips" Dross setting**, default OFF. When ON, each clip is saved paired with its transcript. **Storage:** transcript + metadata in **void-db** (`voice_clips` table — in the Core-4 offsite backup + HA-replicated); audio files on a **dedicated owner-only ZFS dataset** (`localzfs/void-voiceclips`, bind-mounted into void-app at `/var/lib/void/voice-clips`, 0700), **added to the offsite backup + syncoid replication**. NOT on void-app's ephemeral rootfs (it's the rebuildable tier, excluded from backups). Encryption-at-rest is a documented **future** toggle (ZFS native encryption, key in Vaultwarden).
## Non-goals (this phase)
- `handsfree` / `action` voice modes (designed-for; only `review` wired).
- Encryption-at-rest of clips (future).
- Wake-word / always-listening.
---
## Architecture
### Components
| Unit | Responsibility |
|---|---|
| **faster-whisper service** (CT 102, infra) | OpenAI-compatible `/v1/audio/transcriptions` (e.g. `faster-whisper-server`/`speaches`), `small.en`, GPU+CPU fallback, systemd unit, bound to `192.168.1.185:<port>` (LAN-only). |
| `lib/voice/whisper.js` (void-app) | Thin client: POST the audio buffer to the CT-102 service, return `{ text }`. Timeout + error surface. |
| `lib/api/routes/voice.js` (void-app) | `POST /api/voice/transcribe` (owner-only, multipart, ≤25 MB / ≤60 s): transcribe; if `dross.keepClips` is on, persist (Task: retention). Returns `{ text, clip_id? }`. |
| `lib/db/repos/voice_clips.js` + migration | `voice_clips` table (id, transcript, duration_ms, bytes, mime, path, created_at). |
| `public/components/dross_bubble.js` (edit) | Enable the mic: `MediaRecorder` capture (tap start/stop), recording UI (timer/waveform), upload, transcript → input (review-and-send). |
| Settings → Dross (edit) | Add the **"Keep voice clips"** toggle; a small **clips list** (play / delete) when retention is on. |
| Infra | New ZFS dataset + bind-mount; add the dataset to the offsite-backup script + syncoid job. |
### Data flow
**Transcribe:** mic tap → `MediaRecorder` (`audio/webm;codecs=opus`) → on stop, blob → `POST /api/voice/transcribe` (multipart) → `whisper.js` → CT-102 faster-whisper → `{text}` → bubble drops text into the input (review-and-send). Errors never block typing.
**Retention (when `dross.keepClips`):** the transcribe route, after a successful transcript, writes the audio to `/var/lib/void/voice-clips/<uuid>.webm` (0600) and inserts a `voice_clips` row (transcript + metadata + path). `GET /api/voice/clips` lists; `GET /api/voice/clips/:id/audio` streams; `DELETE` removes row + file. Owner-only throughout.
## Error handling
- **Whisper down / GPU absent:** `/transcribe` returns a clear 503; bubble shows "couldn't transcribe — type instead", keeps the typed text. faster-whisper falls back to CPU on a GPU-less node (slower).
- **Mic permission denied / unsupported:** hide recording UI, one-line hint, typing still works.
- **Clip too large/long:** reject at the route (413) with a friendly message.
- **CT 102 disk pressure** (currently 89% full / 6.4 GB free): install lean (CTranslate2, no torch); **may expand the CT disk first**. Flagged as a build risk.
## Testing
- **Unit:** `voice.js` route with a mocked whisper client (returns `{text}`); retention path writes a row + file (temp dir) and lists/deletes; size/duration guard returns 413.
- **Live smoke:** record a short WAV via the CT-300 test harness → `/api/voice/transcribe` → non-empty text from the real CT-102 service.
- **Headless:** mic button enabled; recording UI toggles; (MediaRecorder needs a fake audio device in Chromium — use `--use-fake-device-for-media-stream`).
## Build phases
- **P2a — Transcription path.** faster-whisper on CT 102 + `whisper.js` + `/api/voice/transcribe` (no retention) + enable the mic + record→review-send. Ship-able.
- **P2b — Retention.** ZFS dataset + bind-mount + backup/replication wiring; `voice_clips` table + repo; save on transcribe when `keepClips`; clips list/play/delete UI; the "Keep voice clips" toggle.
## Documentation
Wiki + Gitea per the standing rule; update `project_cradle_chat_floating` memory. Encryption-at-rest recorded as a future toggle.

View File

@@ -39,6 +39,7 @@ import { router as backupsRouter } from './routes/backups.js';
import { router as kuttRouter } from './routes/kutt.js'; import { router as kuttRouter } from './routes/kutt.js';
import { router as themeRouter } from './routes/theme.js'; import { router as themeRouter } from './routes/theme.js';
import { router as drossRouter } from './routes/dross.js'; import { router as drossRouter } from './routes/dross.js';
import { router as voiceRouter } from './routes/voice.js';
export function mountApi(app) { export function mountApi(app) {
const api = Router(); const api = Router();
@@ -75,6 +76,7 @@ export function mountApi(app) {
api.use('/kutt', kuttRouter); api.use('/kutt', kuttRouter);
api.use('/theme', themeRouter); api.use('/theme', themeRouter);
api.use('/dross', drossRouter); api.use('/dross', drossRouter);
api.use('/voice', voiceRouter);
api.use('/pending-changes', pendingChangesRouter); api.use('/pending-changes', pendingChangesRouter);
api.use('/audit', auditRouter); api.use('/audit', auditRouter);
api.use('/search', searchRouter); api.use('/search', searchRouter);

24
lib/api/routes/voice.js Normal file
View File

@@ -0,0 +1,24 @@
import { Router } from 'express';
import multer from 'multer';
import { asyncWrap } from '../errors.js';
import { requireOwner } from '../cap.js';
import * as whisper from '../../voice/whisper.js';
export const router = Router();
// In-memory upload; clips are small voice notes. 25 MB ceiling.
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
// POST /api/voice/transcribe — owner-only. multipart field `audio`. Returns { text }.
// (Phase 2b will optionally persist the clip + transcript when keepClips is on.)
router.post('/transcribe', requireOwner, upload.single('audio'), asyncWrap(async (req, res) => {
if (!req.file || !req.file.buffer?.length) {
return res.status(400).json({ error: { code: 'no_audio', message: 'no audio supplied' } });
}
try {
const r = await whisper.transcribe(
req.file.buffer, req.file.originalname || 'clip.webm', req.file.mimetype || 'audio/webm');
res.json({ text: r.text, duration: r.duration ?? null });
} catch {
res.status(503).json({ error: { code: 'stt_unavailable', message: 'transcription service unavailable' } });
}
}));

22
lib/voice/whisper.js Normal file
View File

@@ -0,0 +1,22 @@
// Thin client for the local faster-whisper service on CT 102 (the Ollama box).
// GPU with CPU fallback lives in the service itself; here we just POST the audio
// buffer and return the transcript. LAN-only endpoint.
const WHISPER_URL = process.env.WHISPER_URL || 'http://192.168.1.185:8001';
export async function transcribe(buffer, filename = 'clip.webm', mime = 'audio/webm') {
const fd = new FormData();
fd.append('file', new Blob([buffer], { type: mime }), filename);
const res = await fetch(`${WHISPER_URL}/transcribe`, {
method: 'POST', body: fd, signal: AbortSignal.timeout(120000)
});
if (!res.ok) throw new Error(`whisper ${res.status}`);
const j = await res.json();
return { text: (j.text || '').trim(), duration: j.duration, device: j.device };
}
export async function health() {
try {
const res = await fetch(`${WHISPER_URL}/health`, { signal: AbortSignal.timeout(5000) });
return res.ok ? await res.json() : null;
} catch { return null; }
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "void-server", "name": "void-server",
"version": "2.11.0", "version": "2.12.0",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -22,8 +22,9 @@ export async function renderDrossBubble() {
const input = el('textarea', { rows: 1, placeholder: 'Ask Dross…' }); const input = el('textarea', { rows: 1, placeholder: 'Ask Dross…' });
const sendBtn = el('button', { class: 'dross-send', title: 'Send' }, const sendBtn = el('button', { class: 'dross-send', title: 'Send' },
el('span', { html: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>' })); el('span', { html: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>' }));
const mic = el('button', { class: 'dross-mic', disabled: true, title: 'Voice arrives in Phase 2' }, const micLabel = el('span', {}, 'Tap to record');
el('span', { html: '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg>' }), 'Hold to talk'); const mic = el('button', { class: 'dross-mic', title: 'Record a voice note' },
el('span', { html: '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg>' }), micLabel);
const closeBtn = el('button', { class: 'dross-x', title: 'Close' }, ''); const closeBtn = el('button', { class: 'dross-x', title: 'Close' }, '');
const header = el('div', { class: 'dross-hd' }, drossAvatar(cfg.avatar, 30), const header = el('div', { class: 'dross-hd' }, drossAvatar(cfg.avatar, 30),
el('div', { class: 'dross-who' }, 'Dross', el('small', {}, 'always here, regrettably')), closeBtn); el('div', { class: 'dross-who' }, 'Dross', el('small', {}, 'always here, regrettably')), closeBtn);
@@ -57,9 +58,52 @@ export async function renderDrossBubble() {
fab.addEventListener('click', () => { if (fab._moved) { fab._moved = false; return; } openPanel(); }); fab.addEventListener('click', () => { if (fab._moved) { fab._moved = false; return; } openPanel(); });
closeBtn.addEventListener('click', closePanel); closeBtn.addEventListener('click', closePanel);
collapse.addEventListener('click', closePanel); collapse.addEventListener('click', closePanel);
// Topbar ◆ button (and any caller) can summon/dismiss Dross.
window.addEventListener('dross-toggle', () => panel.classList.contains('open') ? closePanel() : openPanel());
drag(fab, fab, true); drag(header, panel, false); drag(fab, fab, true); drag(header, panel, false);
// ---- voice: tap mic to record, tap again to stop → transcribe → review-and-send ----
let media = null, chunks = [], recording = false;
function setMic(label, rec) { micLabel.textContent = label; mic.classList.toggle('rec', !!rec); }
async function startRec() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
chunks = [];
const opt = (window.MediaRecorder && MediaRecorder.isTypeSupported('audio/webm;codecs=opus'))
? { mimeType: 'audio/webm;codecs=opus' } : {};
media = new MediaRecorder(stream, opt);
media.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
media.onstop = async () => {
stream.getTracks().forEach(t => t.stop());
await sendClip(new Blob(chunks, { type: media.mimeType || 'audio/webm' }));
};
media.start();
recording = true; setMic('● Recording… tap to stop', true);
} catch {
setMic('Mic blocked', false); setTimeout(() => setMic('Tap to record', false), 1800);
}
}
function stopRec() {
if (media && recording) { recording = false; setMic('Transcribing…', false); media.stop(); }
}
async function sendClip(blob) {
try {
const fd = new FormData(); fd.append('audio', blob, 'clip.webm');
const res = await fetch('/api/voice/transcribe', {
method: 'POST', headers: { Authorization: 'Bearer ' + (localStorage.getItem('void_token') || '') }, body: fd
});
if (!res.ok) throw new Error('stt');
const { text } = await res.json();
setMic('Tap to record', false);
if (text) { input.value = input.value ? (input.value + ' ' + text) : text; input.focus(); }
// voiceMode 'handsfree'/'action' (Phase 2b+) would branch here.
} catch {
setMic('Transcribe failed', false); setTimeout(() => setMic('Tap to record', false), 2000);
}
}
mic.addEventListener('click', () => recording ? stopRec() : startRec());
window.addEventListener('dross-settings-changed', async () => { window.addEventListener('dross-settings-changed', async () => {
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { return; } try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { return; }
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent); applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);

View File

@@ -4,7 +4,7 @@
import { el, mount, clear } from '../dom.js'; import { el, mount, clear } from '../dom.js';
import { navigate } from '../router.js'; import { navigate } from '../router.js';
import { on } from '../state.js'; import { on } from '../state.js';
import { toggleSidebar, toggleRail } from './chrome.js'; import { toggleSidebar } from './chrome.js';
import { api } from '../api.js'; import { api } from '../api.js';
// Cluster health → topbar pill. Returns [status, label, title]. // Cluster health → topbar pill. Returns [status, label, title].
@@ -72,7 +72,7 @@ export function renderTopbar(root) {
el('div', { class: 'topbar-spacer' }), el('div', { class: 'topbar-spacer' }),
clusterPill, clusterPill,
bell, bell,
el('button', { class: 'chrome-toggle', title: 'Toggle companion chat', onclick: toggleRail }, '◆'), el('button', { class: 'chrome-toggle', title: 'Summon Dross', onclick: () => window.dispatchEvent(new CustomEvent('dross-toggle')) }, '◆'),
el('button', { class: 'icon-btn', onclick: () => alert('Agent-switching ships post-Plan-2.') }, 'Owner') el('button', { class: 'icon-btn', onclick: () => alert('Agent-switching ships post-Plan-2.') }, 'Owner')
); );

View File

@@ -29,20 +29,18 @@ html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color:
#shell { #shell {
display: grid; display: grid;
grid-template-columns: var(--sidebar-w) 1fr var(--rail-w); grid-template-columns: var(--sidebar-w) 1fr;
grid-template-rows: var(--topbar-h) 1fr; grid-template-rows: var(--topbar-h) 1fr;
grid-template-areas: grid-template-areas:
"topbar topbar topbar" "topbar topbar"
"sidebar main rail"; "sidebar main";
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
} }
#shell.rail-collapsed { grid-template-columns: var(--sidebar-w) 1fr var(--rail-w-min); }
#topbar { grid-area: topbar; border-bottom: 1px solid var(--border); background: var(--panel); display: flex; align-items: center; padding: 0 16px; gap: 12px; } #topbar { grid-area: topbar; border-bottom: 1px solid var(--border); background: var(--panel); display: flex; align-items: center; padding: 0 16px; gap: 12px; }
#sidebar { grid-area: sidebar; border-right: 1px solid var(--border); background: var(--panel); overflow-y: auto; padding: 12px 0; } #sidebar { grid-area: sidebar; border-right: 1px solid var(--border); background: var(--panel); overflow-y: auto; padding: 12px 0; }
#main { grid-area: main; overflow-y: auto; padding: 24px 32px; } #main { grid-area: main; overflow-y: auto; padding: 24px 32px; }
#rightrail{ grid-area: rail; border-left: 1px solid var(--border); background: var(--panel); overflow: hidden; display: flex; flex-direction: column; }
/* topbar */ /* topbar */
.brand { font-family: var(--font-display); font-weight: 700; letter-spacing: 0.18em; font-size: 14px; color: var(--accent); text-transform: uppercase; padding: 0 6px; } .brand { font-family: var(--font-display); font-weight: 700; letter-spacing: 0.18em; font-size: 14px; color: var(--accent); text-transform: uppercase; padding: 0 6px; }
@@ -483,15 +481,11 @@ ul.plain li:last-child { border-bottom: none; }
/* ===== Collapsible chrome + responsive layout (Plan 6 polish) ===== */ /* ===== Collapsible chrome + responsive layout (Plan 6 polish) ===== */
:root { --sidebar-w-min: 0px; } :root { --sidebar-w-min: 0px; }
#shell { transition: grid-template-columns .22s ease; } #shell { transition: grid-template-columns .22s ease; }
#sidebar, #rightrail { transition: transform .22s ease; } #sidebar { transition: transform .22s ease; }
/* Desktop collapse — shrink the grid columns */ /* Desktop collapse — shrink the sidebar column */
#shell.sidebar-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr var(--rail-w); } #shell.sidebar-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr; }
#shell.sidebar-collapsed.rail-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr var(--rail-w-min); }
#shell.rail-collapsed { grid-template-columns: var(--sidebar-w) 1fr var(--rail-w-min); }
#shell.sidebar-collapsed #sidebar { overflow: hidden; border-right: none; } #shell.sidebar-collapsed #sidebar { overflow: hidden; border-right: none; }
/* Hide chat body when the rail is collapsed so the thin strip stays clean */
#shell.rail-collapsed .rail-chat { display: none; }
/* Topbar toggle buttons */ /* Topbar toggle buttons */
.chrome-toggle { .chrome-toggle {
@@ -514,13 +508,11 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
/* ---- Narrow / mobile / vertical: off-canvas drawers, single-column main ---- */ /* ---- Narrow / mobile / vertical: off-canvas drawers, single-column main ---- */
@media (max-width: 860px) { @media (max-width: 860px) {
#shell, #shell,
#shell.sidebar-collapsed, #shell.sidebar-collapsed {
#shell.rail-collapsed,
#shell.sidebar-collapsed.rail-collapsed {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-areas: "topbar" "main"; grid-template-areas: "topbar" "main";
} }
#sidebar, #rightrail { #sidebar {
position: fixed; top: var(--topbar-h); bottom: 0; z-index: 50; position: fixed; top: var(--topbar-h); bottom: 0; z-index: 50;
} }
#sidebar { left: 0; width: min(82vw, 300px); transform: translateX(-100%); border-right: 1px solid var(--border); } #sidebar { left: 0; width: min(82vw, 300px); transform: translateX(-100%); border-right: 1px solid var(--border); }
@@ -773,6 +765,8 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
.dross-btnrow{display:flex;gap:10px} .dross-btnrow{display:flex;gap:10px}
.dross-mic{flex:1;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:var(--dross-soft);color:var(--dross-glow);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:9px;font-family:var(--font-ui);font-size:13px} .dross-mic{flex:1;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:var(--dross-soft);color:var(--dross-glow);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:9px;font-family:var(--font-ui);font-size:13px}
.dross-mic[disabled]{opacity:.5;cursor:not-allowed} .dross-mic[disabled]{opacity:.5;cursor:not-allowed}
.dross-mic.rec{background:#3a1010;border-color:var(--accent);color:#fff;animation:dross-rec 1.2s infinite}
@keyframes dross-rec{0%,100%{box-shadow:0 0 0 0 rgba(255,79,46,.5)}50%{box-shadow:0 0 0 8px rgba(255,79,46,0)}}
.dross-send{width:64px;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:linear-gradient(180deg,var(--dross),var(--dross-dim));color:#fff;cursor:pointer;display:grid;place-items:center} .dross-send{width:64px;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:linear-gradient(180deg,var(--dross),var(--dross-dim));color:#fff;cursor:pointer;display:grid;place-items:center}
.dross-collapse{display:flex;align-items:center;justify-content:center;gap:8px;height:34px;cursor:pointer;color:var(--muted); .dross-collapse{display:flex;align-items:center;justify-content:center;gap:8px;height:34px;cursor:pointer;color:var(--muted);
font-family:var(--font-ui);font-size:11px;letter-spacing:.12em;text-transform:uppercase;background:#0b0810;border-top:1px solid var(--border)} font-family:var(--font-ui);font-size:11px;letter-spacing:.12em;text-transform:uppercase;background:#0b0810;border-top:1px solid var(--border)}

View File

@@ -0,0 +1,24 @@
import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { createApp } from '../../server.js';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
let app;
const owner = { Authorization: 'Bearer test-token' };
beforeAll(async () => {
await resetDb(); await migrateUp();
process.env.OWNER_TOKEN = 'test-token';
app = createApp();
});
describe('voice transcribe route', () => {
it('401 without a token', async () => {
const res = await request(app).post('/api/voice/transcribe');
expect(res.status).toBe(401);
});
it('400 when no audio supplied', async () => {
const res = await request(app).post('/api/voice/transcribe').set(owner);
expect(res.status).toBe(400);
});
});