Compare commits
14 Commits
c83bd6a89b
...
v2.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e29bacbda1 | ||
|
|
fc1e93a58f | ||
|
|
2dc9d612de | ||
|
|
e2be462ecb | ||
|
|
6d5c3027ac | ||
|
|
262be3e332 | ||
|
|
c502ccda48 | ||
|
|
a67ff9e403 | ||
|
|
3674811e40 | ||
|
|
ce8769d5a2 | ||
|
|
f52fb05f5e | ||
|
|
4535b03207 | ||
|
|
1df0a905a2 | ||
|
|
7a09b9f91c |
16
deploy/whisper/README.md
Normal file
16
deploy/whisper/README.md
Normal 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
35
deploy/whisper/server.py
Normal 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
26
deploy/whisper/setup.sh
Normal 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"
|
||||
@@ -1,7 +1,7 @@
|
||||
# Floating Dross Chat — Design
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
@@ -21,6 +21,7 @@ The chat mechanics are already factored into a reusable engine (`public/componen
|
||||
5. **Colour** — Dross is **violet** by default, but his accent is **tunable in Settings** (his own vars, independent of the UI theme).
|
||||
6. **Persona** — give him the real Cradle-Dross voice (dry, sardonic, impatient, brilliant, secretly loyal) via an **editable system prompt in Settings** (tunable).
|
||||
7. **Voice** — record a clip → transcribe with **local faster-whisper on the Ollama box (CT 102, GPU, CPU-fallback)** → transcript lands in the input for **review-and-send first (mode 1)**. A *voice-mode* setting allows graduating to **hands-free auto-send (mode 2)**, then **interpret-into-confirmable-action (mode 3)** later.
|
||||
8. **Audio retention (Phase 2, added 2026-06-09)** — by default the clip is transcribed then **destroyed** (transient). Add a **Dross setting** "Keep voice clips" that, when on, **saves each audio clip paired with its transcript**, stored **safely and securely** (encrypted at rest / access-controlled; on a homelab dataset, owner-only — exact store TBD in P2: e.g. a `voice_clips` table + blob on a ZFS dataset, or object store). Off by default. This is a P2 deliverable, designed-for now.
|
||||
|
||||
## Non-goals (this iteration)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -38,6 +38,8 @@ import { router as storageRouter } from './routes/storage.js';
|
||||
import { router as backupsRouter } from './routes/backups.js';
|
||||
import { router as kuttRouter } from './routes/kutt.js';
|
||||
import { router as themeRouter } from './routes/theme.js';
|
||||
import { router as drossRouter } from './routes/dross.js';
|
||||
import { router as voiceRouter } from './routes/voice.js';
|
||||
|
||||
export function mountApi(app) {
|
||||
const api = Router();
|
||||
@@ -73,6 +75,8 @@ export function mountApi(app) {
|
||||
api.use('/links', linksRouter);
|
||||
api.use('/kutt', kuttRouter);
|
||||
api.use('/theme', themeRouter);
|
||||
api.use('/dross', drossRouter);
|
||||
api.use('/voice', voiceRouter);
|
||||
api.use('/pending-changes', pendingChangesRouter);
|
||||
api.use('/audit', auditRouter);
|
||||
api.use('/search', searchRouter);
|
||||
|
||||
115
lib/api/routes/dross.js
Normal file
115
lib/api/routes/dross.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { validate } from '../validate.js';
|
||||
import { asyncWrap } from '../errors.js';
|
||||
import { requireOwner } from '../cap.js';
|
||||
import * as settings from '../../db/repos/app_settings.js';
|
||||
import * as conversations from '../../db/repos/conversations.js';
|
||||
import * as messages from '../../db/repos/messages.js';
|
||||
import * as agents from '../../db/repos/agents.js';
|
||||
import { runAgentTurn } from '../../ai/agent/run_turn.js';
|
||||
import { personaFor } from '../../ai/personas/index.js';
|
||||
|
||||
const DEFAULT_SETTINGS = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||
const COMPANION_SLUG = 'companion';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
async function getCfg() { return { ...DEFAULT_SETTINGS, ...(await settings.get('dross', {})) }; }
|
||||
|
||||
router.get('/settings', asyncWrap(async (_req, res) => res.json(await getCfg())));
|
||||
|
||||
const settingsBody = z.object({
|
||||
avatar: z.enum(['soft-eye', 'wisp', 'motes']),
|
||||
accent: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||
persona: z.string().max(8000),
|
||||
voiceMode: z.enum(['review', 'handsfree', 'action'])
|
||||
});
|
||||
router.put('/settings', requireOwner, validate({ body: settingsBody }),
|
||||
asyncWrap(async (req, res) => res.json(await settings.set('dross', req.body))));
|
||||
|
||||
async function resolve() {
|
||||
const agent = await agents.getBySlug(COMPANION_SLUG);
|
||||
const convo = await conversations.findOrCreateGlobal(agent.id, { kind: 'user', id: null });
|
||||
return { agent, convo };
|
||||
}
|
||||
|
||||
router.get('/', asyncWrap(async (_req, res) => {
|
||||
const { agent, convo } = await resolve();
|
||||
const rows = await messages.listByConversation(convo.id);
|
||||
res.json({
|
||||
conversation_id: convo.id,
|
||||
agent: { id: agent.id, slug: agent.slug, name: agent.name },
|
||||
messages: rows
|
||||
});
|
||||
}));
|
||||
|
||||
const turnSchema = z.object({
|
||||
text: z.string().min(1),
|
||||
view: z.object({ entityType: z.string(), entityId: z.string() }).partial().nullish()
|
||||
});
|
||||
|
||||
router.post('/turn', requireOwner, validate({ body: turnSchema }), asyncWrap(async (req, res) => {
|
||||
const { agent, convo } = await resolve();
|
||||
const { text, view } = req.body;
|
||||
const cfg = await getCfg();
|
||||
const persona = (cfg.persona && cfg.persona.trim()) ? cfg.persona : personaFor(COMPANION_SLUG);
|
||||
|
||||
const priorTurns = (await messages.listByConversation(convo.id)).length;
|
||||
const resume = priorTurns > 0;
|
||||
await messages.append(convo.id, { role: 'user', body: text });
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
||||
const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
const claudeExe = req.app.locals.claudeExe || process.env.CLAUDE_EXE || 'claude';
|
||||
const companionTools = ['mcp__void__search', 'mcp__void__read', 'mcp__void__context', 'mcp__void__propose_change'];
|
||||
const draftIds = [];
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await runAgentTurn({
|
||||
agent, persona, registryName: undefined, toolNames: companionTools,
|
||||
spaceId: null, view, sessionId: convo.id, resume, userText: text, claudeExe,
|
||||
home: process.env.VOID_CLAUDE_HOME || undefined,
|
||||
onEvent: (e) => {
|
||||
if (e.type === 'delta') {
|
||||
send('delta', { type: 'delta', text: e.text });
|
||||
} else if (e.type === 'tool') {
|
||||
send('tool', { type: 'tool', tool: e.tool, status: e.status });
|
||||
} else if (e.type === 'tool_result') {
|
||||
try {
|
||||
let parsed = null;
|
||||
const tryParse = (s) => { try { return JSON.parse(s); } catch { return null; } };
|
||||
if (typeof e.result === 'string') {
|
||||
parsed = tryParse(e.result);
|
||||
} else if (e.result?.structuredContent?.pending_change_id) {
|
||||
parsed = e.result.structuredContent;
|
||||
} else if (Array.isArray(e.result)) {
|
||||
for (const b of e.result) {
|
||||
const c = b?.type === 'text' && b.text ? tryParse(b.text) : null;
|
||||
if (c?.pending_change_id) { parsed = c; break; }
|
||||
}
|
||||
}
|
||||
if (parsed?.pending_change_id) {
|
||||
draftIds.push(parsed.pending_change_id);
|
||||
send('draft', { type: 'draft', pending_change_id: parsed.pending_change_id, summary: parsed.summary || 'a change' });
|
||||
}
|
||||
} catch { /* parsing failed — no draft to surface */ }
|
||||
} else if (e.type === 'error') {
|
||||
send('error', { type: 'error', message: e.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
send('error', { message: String(e?.message || e) });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const assistant = await messages.append(convo.id, {
|
||||
role: 'assistant', body: result.text, agent_id: agent.id,
|
||||
metadata: { tool_trace: result.toolTrace, draft_ids: draftIds, usage: result.usage }
|
||||
});
|
||||
send('done', { assistant_message_id: assistant.id, draft_ids: draftIds, usage: result.usage });
|
||||
res.end();
|
||||
}));
|
||||
24
lib/api/routes/voice.js
Normal file
24
lib/api/routes/voice.js
Normal 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
22
lib/voice/whisper.js
Normal 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; }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.10.0",
|
||||
"version": "2.12.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { api } from './api.js';
|
||||
import { route, current, navigate } from './router.js';
|
||||
import { renderSidebar } from './components/sidebar.js';
|
||||
import { renderTopbar } from './components/topbar.js';
|
||||
import { renderRightrail } from './components/rightrail.js';
|
||||
import { renderDrossBubble } from './components/dross_bubble.js';
|
||||
import { emit, state } from './state.js';
|
||||
import { el, mount } from './dom.js';
|
||||
import { attachDropzone } from './components/dropzone.js';
|
||||
@@ -84,7 +84,7 @@ async function init() {
|
||||
await loadTheme(); // apply saved palette overrides before rendering chrome
|
||||
renderTopbar(document.getElementById('topbar'));
|
||||
renderSidebar(document.getElementById('sidebar'));
|
||||
renderRightrail(document.getElementById('rightrail'));
|
||||
renderDrossBubble();
|
||||
initChrome();
|
||||
attachDropzone(document.getElementById('main'));
|
||||
route(renderView);
|
||||
|
||||
20
public/components/dross_avatar.js
Normal file
20
public/components/dross_avatar.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// public/components/dross_avatar.js
|
||||
import { el } from '../dom.js';
|
||||
|
||||
// Returns a .dross-orb element rendering the chosen avatar. Colours come from
|
||||
// CSS vars (--dross*), set on the element by the caller for per-user accent.
|
||||
export function drossAvatar(variant = 'soft-eye', size = 60) {
|
||||
let inner;
|
||||
if (variant === 'wisp') {
|
||||
inner = [el('div', { class: 'b-core' }), el('div', { class: 'b-bright' })];
|
||||
} else if (variant === 'motes') {
|
||||
inner = [
|
||||
el('div', { class: 'd-ring' }, el('div', { class: 'd-mote' })),
|
||||
el('div', { class: 'd-ring r2' }, el('div', { class: 'd-mote' })),
|
||||
el('div', { class: 'd-core' })
|
||||
];
|
||||
} else { // soft-eye (default)
|
||||
inner = [el('div', { class: 'av-eye' }, el('div', { class: 'av-pupil' }))];
|
||||
}
|
||||
return el('div', { class: 'dross-orb', style: { width: size + 'px', height: size + 'px' } }, ...inner);
|
||||
}
|
||||
129
public/components/dross_bubble.js
Normal file
129
public/components/dross_bubble.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// public/components/dross_bubble.js
|
||||
// Global floating Dross companion. Replaces the per-Space right rail.
|
||||
import { el, mount } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
import { state } from '../state.js';
|
||||
import { wireAgentChat } from './agent_chat.js';
|
||||
import { drossAvatar } from './dross_avatar.js';
|
||||
|
||||
const TOOL_LABELS = { search: '🔍 searching', read: '📄 reading', context: '🧭 looking at this view', propose_change: '📝 drafting a change' };
|
||||
let cfg = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||
|
||||
function applyAccent(node, hex) {
|
||||
node.style.setProperty('--dross', hex);
|
||||
}
|
||||
|
||||
export async function renderDrossBubble() {
|
||||
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { /* defaults */ }
|
||||
|
||||
const fab = el('div', { class: 'dross-fab', title: 'Dross' },
|
||||
el('div', { class: 'dross-ping', style: { display: 'none' } }, ''), drossAvatar(cfg.avatar, 60));
|
||||
const log = el('div', { class: 'dross-log' });
|
||||
const input = el('textarea', { rows: 1, placeholder: 'Ask Dross…' });
|
||||
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>' }));
|
||||
const micLabel = el('span', {}, 'Tap to record');
|
||||
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 header = el('div', { class: 'dross-hd' }, drossAvatar(cfg.avatar, 30),
|
||||
el('div', { class: 'dross-who' }, 'Dross', el('small', {}, 'always here, regrettably')), closeBtn);
|
||||
const collapse = el('div', { class: 'dross-collapse', title: 'Collapse' },
|
||||
el('span', { class: 'grip' }), el('span', {}, '⌄ collapse'), el('span', { class: 'grip' }));
|
||||
const panel = el('div', { class: 'dross-panel' }, header, log,
|
||||
el('div', { class: 'dross-inwrap' }, input, el('div', { class: 'dross-btnrow' }, mic, sendBtn)), collapse);
|
||||
|
||||
document.getElementById('shell').append(fab, panel);
|
||||
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
|
||||
|
||||
const chat = wireAgentChat({
|
||||
logEl: log, inputEl: input, sendBtnEl: sendBtn,
|
||||
historyUrl: '/api/dross', turnUrl: '/api/dross/turn',
|
||||
agentName: 'Dross', showDrafts: true, toolLabels: TOOL_LABELS,
|
||||
turnBody: (text) => ({ text, view: state.view || null })
|
||||
});
|
||||
let loaded = false;
|
||||
|
||||
function openPanel() {
|
||||
const r = fab.getBoundingClientRect();
|
||||
panel.classList.add('open'); fab.style.display = 'none';
|
||||
const pr = panel.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(r.right - pr.width, innerWidth - pr.width - 8));
|
||||
const top = Math.max(8, Math.min(r.bottom - pr.height, innerHeight - pr.height - 8));
|
||||
panel.style.right = 'auto'; panel.style.bottom = 'auto'; panel.style.left = left + 'px'; panel.style.top = top + 'px';
|
||||
if (!loaded) { loaded = true; chat.load(); }
|
||||
input.focus();
|
||||
}
|
||||
function closePanel() { panel.classList.remove('open'); fab.style.display = 'block'; }
|
||||
fab.addEventListener('click', () => { if (fab._moved) { fab._moved = false; return; } openPanel(); });
|
||||
closeBtn.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);
|
||||
|
||||
// ---- 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 () => {
|
||||
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { return; }
|
||||
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
|
||||
mount(fab, el('div', { class: 'dross-ping', style: { display: 'none' } }), drossAvatar(cfg.avatar, 60));
|
||||
header.replaceChild(drossAvatar(cfg.avatar, 30), header.firstChild);
|
||||
});
|
||||
}
|
||||
|
||||
function drag(handle, target, isFab) {
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('.dross-x') || e.target.closest('.dross-mic') || e.target.closest('.dross-send')) return;
|
||||
e.preventDefault();
|
||||
const r = target.getBoundingClientRect(); const sx = e.clientX, sy = e.clientY; let moved = false;
|
||||
target.style.right = 'auto'; target.style.bottom = 'auto'; target.style.left = r.left + 'px'; target.style.top = r.top + 'px';
|
||||
const mv = (ev) => {
|
||||
const dx = ev.clientX - sx, dy = ev.clientY - sy; if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;
|
||||
target.style.left = Math.max(4, Math.min(innerWidth - r.width - 4, r.left + dx)) + 'px';
|
||||
target.style.top = Math.max(4, Math.min(innerHeight - r.height - 4, r.top + dy)) + 'px';
|
||||
};
|
||||
const up = () => { document.removeEventListener('pointermove', mv); document.removeEventListener('pointerup', up); if (isFab) target._moved = moved; };
|
||||
document.addEventListener('pointermove', mv); document.addEventListener('pointerup', up);
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
import { el, mount, clear } from '../dom.js';
|
||||
import { navigate } from '../router.js';
|
||||
import { on } from '../state.js';
|
||||
import { toggleSidebar, toggleRail } from './chrome.js';
|
||||
import { toggleSidebar } from './chrome.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
// Cluster health → topbar pill. Returns [status, label, title].
|
||||
@@ -72,7 +72,7 @@ export function renderTopbar(root) {
|
||||
el('div', { class: 'topbar-spacer' }),
|
||||
clusterPill,
|
||||
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')
|
||||
);
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
<header id="topbar"></header>
|
||||
<aside id="sidebar"></aside>
|
||||
<main id="main"></main>
|
||||
<aside id="rightrail"></aside>
|
||||
</div>
|
||||
<div id="modal-root"></div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
|
||||
@@ -29,20 +29,18 @@ html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color:
|
||||
|
||||
#shell {
|
||||
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-areas:
|
||||
"topbar topbar topbar"
|
||||
"sidebar main rail";
|
||||
"topbar topbar"
|
||||
"sidebar main";
|
||||
height: 100vh;
|
||||
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; }
|
||||
#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; }
|
||||
#rightrail{ grid-area: rail; border-left: 1px solid var(--border); background: var(--panel); overflow: hidden; display: flex; flex-direction: column; }
|
||||
|
||||
/* 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; }
|
||||
@@ -483,15 +481,11 @@ ul.plain li:last-child { border-bottom: none; }
|
||||
/* ===== Collapsible chrome + responsive layout (Plan 6 polish) ===== */
|
||||
:root { --sidebar-w-min: 0px; }
|
||||
#shell { transition: grid-template-columns .22s ease; }
|
||||
#sidebar, #rightrail { transition: transform .22s ease; }
|
||||
#sidebar { transition: transform .22s ease; }
|
||||
|
||||
/* Desktop collapse — shrink the grid columns */
|
||||
#shell.sidebar-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr var(--rail-w); }
|
||||
#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); }
|
||||
/* Desktop collapse — shrink the sidebar column */
|
||||
#shell.sidebar-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr; }
|
||||
#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 */
|
||||
.chrome-toggle {
|
||||
@@ -514,13 +508,11 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
||||
/* ---- Narrow / mobile / vertical: off-canvas drawers, single-column main ---- */
|
||||
@media (max-width: 860px) {
|
||||
#shell,
|
||||
#shell.sidebar-collapsed,
|
||||
#shell.rail-collapsed,
|
||||
#shell.sidebar-collapsed.rail-collapsed {
|
||||
#shell.sidebar-collapsed {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas: "topbar" "main";
|
||||
}
|
||||
#sidebar, #rightrail {
|
||||
#sidebar {
|
||||
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); }
|
||||
@@ -729,3 +721,58 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
||||
.ip-icon img { width: 28px; height: 28px; object-fit: contain; }
|
||||
.ip-set-hd, .isp-hd { font-size: 12px; margin: 6px 0 3px; text-transform: capitalize; }
|
||||
.isp-upload { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
|
||||
/* ---- Dross floating chat ---- */
|
||||
:root{ --dross:#a86adf; --dross-dim:#5a2e8a; --dross-soft:#1e1030; --dross-glow:#c79bff; }
|
||||
.dross-orb{position:relative;border-radius:50%;display:grid;place-items:center;overflow:hidden;flex:none;
|
||||
background:radial-gradient(circle at 38% 30%, #2a1640, #1a0f2a 70%, #120a1e);
|
||||
box-shadow:0 0 0 1px #ffffff12, 0 6px 22px -6px #000, 0 0 26px -4px var(--dross-dim)}
|
||||
.dross-fab{position:fixed;right:20px;bottom:20px;z-index:40;cursor:grab;touch-action:none;animation:dross-bob 5s ease-in-out infinite}
|
||||
.dross-fab:active{cursor:grabbing}
|
||||
@keyframes dross-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
|
||||
.dross-fab .dross-orb{width:60px;height:60px}
|
||||
.dross-ping{position:absolute;right:-2px;top:-2px;width:17px;height:17px;border-radius:50%;background:var(--accent);
|
||||
color:#0a0a0e;font-size:10px;display:grid;place-items:center;box-shadow:0 0 0 2px var(--bg);z-index:2;font-family:var(--font-ui)}
|
||||
.av-eye{width:54%;height:54%;border-radius:50%;background:radial-gradient(circle at 50% 40%, #2a1c3a, #140b20);display:grid;place-items:center;box-shadow:inset 0 0 10px #000}
|
||||
.av-pupil{width:44%;height:44%;border-radius:50%;position:relative;background:radial-gradient(circle at 38% 32%, #fff, var(--dross-glow) 50%, var(--dross) 100%);box-shadow:0 0 10px var(--dross-glow);animation:dross-look 7s ease-in-out infinite}
|
||||
.av-pupil::after{content:"";position:absolute;right:14%;bottom:18%;width:26%;height:26%;border-radius:50%;background:#fff;opacity:.8}
|
||||
@keyframes dross-look{0%,45%{transform:translate(0,0)}58%{transform:translate(3px,-2px)}72%{transform:translate(-2px,1px)}88%,100%{transform:translate(0,0)}}
|
||||
.b-core{position:absolute;inset:13%;border-radius:50%;filter:blur(3px);animation:dross-spin 7s linear infinite;
|
||||
background:conic-gradient(from 0deg, var(--dross-dim), var(--dross-glow), var(--dross), var(--dross-soft), var(--dross-dim))}
|
||||
.b-bright{position:absolute;inset:32%;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,transparent 75%);animation:dross-pulse 3s ease-in-out infinite}
|
||||
@keyframes dross-spin{to{transform:rotate(360deg)}}
|
||||
@keyframes dross-pulse{0%,100%{opacity:.55;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
|
||||
.d-core{width:22%;height:22%;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,var(--dross));box-shadow:0 0 12px var(--dross-glow)}
|
||||
.d-ring{position:absolute;inset:0;animation:dross-spin 5s linear infinite}
|
||||
.d-ring.r2{animation-duration:8s;animation-direction:reverse}
|
||||
.d-mote{position:absolute;top:11%;left:50%;width:11%;height:11%;margin-left:-5.5%;border-radius:50%;background:var(--dross-glow);box-shadow:0 0 8px var(--dross-glow)}
|
||||
.d-ring.r2 .d-mote{top:auto;bottom:14%;background:var(--dross);width:8%;height:8%}
|
||||
.dross-panel{position:fixed;right:20px;bottom:20px;width:340px;max-width:calc(100vw - 24px);height:480px;max-height:calc(100vh - 24px);
|
||||
display:none;flex-direction:column;z-index:41;border:1px solid var(--dross-dim);border-radius:16px;overflow:hidden;
|
||||
background:linear-gradient(180deg, rgba(30,16,48,.6), rgba(20,20,28,.96) 22%);
|
||||
box-shadow:0 24px 70px -18px #000, 0 0 0 1px #00000060, 0 0 40px -16px var(--dross-dim);backdrop-filter:blur(6px)}
|
||||
.dross-panel.open{display:flex}
|
||||
.dross-hd{display:flex;align-items:center;gap:10px;padding:11px 12px;cursor:grab;touch-action:none;
|
||||
background:linear-gradient(180deg, var(--dross-soft), transparent);border-bottom:1px solid var(--border)}
|
||||
.dross-hd .dross-orb{width:30px;height:30px}
|
||||
.dross-who{font-family:var(--font-display);letter-spacing:.16em;text-transform:uppercase;font-size:13px;color:#efe9f6;flex:1}
|
||||
.dross-who small{display:block;font-family:var(--font-mono);letter-spacing:0;text-transform:none;font-size:10px;color:var(--dross-glow);opacity:.85}
|
||||
.dross-x{background:none;border:0;color:var(--muted);font-size:18px;cursor:pointer;padding:4px 6px}
|
||||
.dross-x:hover{color:var(--text)}
|
||||
.dross-log{flex:1;overflow:auto;padding:12px 12px;display:flex;flex-direction:column;gap:10px}
|
||||
.dross-inwrap{padding:10px;border-top:1px solid var(--border);background:#0d0a12;display:flex;flex-direction:column;gap:9px}
|
||||
.dross-inwrap textarea{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:10px;padding:10px 12px;color:var(--text);font-family:var(--font-mono);font-size:13px;resize:none;height:46px;max-height:96px}
|
||||
.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[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-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)}
|
||||
.dross-collapse:hover{color:var(--dross-glow)}
|
||||
.dross-collapse .grip{width:42px;height:4px;border-radius:3px;background:var(--border)}
|
||||
.dross-pick{display:flex;gap:12px;margin-bottom:12px;flex-wrap:wrap}
|
||||
.dross-avopt{display:flex;flex-direction:column;align-items:center;gap:6px;padding:10px 14px;border:1px solid var(--border);border-radius:12px;background:var(--panel);color:var(--muted);cursor:pointer;font-size:11px}
|
||||
.dross-avopt.on{border-color:var(--dross);color:var(--dross-glow);background:var(--dross-soft)}
|
||||
.dross-persona{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:8px;padding:10px;color:var(--text);font-family:var(--font-mono);font-size:12px;resize:vertical;margin:10px 0}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { el, mount } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
import { iconSetsPanel } from './icon_sets_panel.js';
|
||||
import { THEME_VARS, PRESETS, applyTheme, clearTheme, saveTheme, currentTheme, effectiveHex, toHex6 } from '../theme.js';
|
||||
import { drossAvatar } from '../components/dross_avatar.js';
|
||||
|
||||
// Theming — colour pickers for the palette, live-preview on input, presets +
|
||||
// reset. Persists to /api/theme (app_settings); applied app-wide on next boot.
|
||||
@@ -148,6 +149,44 @@ async function renderAgents(c) {
|
||||
}
|
||||
}
|
||||
|
||||
function drossBody() {
|
||||
const out = el('span', { class: 'muted', style: { marginLeft: '8px' } });
|
||||
let cur = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||
const avatarRow = el('div', { class: 'dross-pick' });
|
||||
const accent = el('input', { type: 'color', value: cur.accent });
|
||||
const persona = el('textarea', { class: 'dross-persona', rows: 6, placeholder: "Dross's system prompt…" });
|
||||
const mode = el('select', { class: 'pm-input', style: { maxWidth: '200px' } },
|
||||
el('option', { value: 'review' }, 'Voice: review then send'),
|
||||
el('option', { value: 'handsfree' }, 'Voice: hands-free (Phase 2)'),
|
||||
el('option', { value: 'action' }, 'Voice: interpret to action (later)'));
|
||||
|
||||
function paintAvatars() {
|
||||
mount(avatarRow, ['soft-eye', 'wisp', 'motes'].map(v => {
|
||||
const card = el('button', { class: 'dross-avopt' + (cur.avatar === v ? ' on' : ''), title: v },
|
||||
drossAvatar(v, 48), el('span', {}, v));
|
||||
card.style.setProperty('--dross', cur.accent);
|
||||
card.onclick = () => { cur.avatar = v; paintAvatars(); };
|
||||
return card;
|
||||
}));
|
||||
}
|
||||
(async () => {
|
||||
try { cur = { ...cur, ...(await api.get('/api/dross/settings')) }; } catch {}
|
||||
accent.value = cur.accent; persona.value = cur.persona; mode.value = cur.voiceMode; paintAvatars();
|
||||
})();
|
||||
accent.addEventListener('input', () => { cur.accent = accent.value; paintAvatars(); });
|
||||
|
||||
const save = el('button', { class: 'primary' }, 'Save');
|
||||
save.onclick = async () => {
|
||||
try {
|
||||
await api.put('/api/dross/settings', { avatar: cur.avatar, accent: accent.value, persona: persona.value, voiceMode: mode.value });
|
||||
window.dispatchEvent(new CustomEvent('dross-settings-changed'));
|
||||
out.textContent = 'Saved.';
|
||||
} catch { out.textContent = 'Save failed'; }
|
||||
};
|
||||
return el('div', { class: 'settings-body' }, avatarRow, el('label', { class: 'st-lbl' }, 'Accent', accent),
|
||||
persona, el('div', { class: 'theme-actions' }, mode, save, out));
|
||||
}
|
||||
|
||||
export async function render(main) {
|
||||
const tokensBody = el('div', { class: 'settings-body' });
|
||||
const agentsBody = el('div', { class: 'settings-body' });
|
||||
@@ -172,6 +211,7 @@ export async function render(main) {
|
||||
mount(main,
|
||||
el('h1', { class: 'view-h1' }, '◆ Settings'),
|
||||
section('Theming', 'Recolour the interface. Pick a colour to preview it live, choose a preset, then Save to persist. Reset returns to the default Blackflame palette.', themingBody()),
|
||||
section('Dross', "Your companion's look and voice. Avatar, accent colour, his personality (system prompt), and how voice clips behave.", drossBody()),
|
||||
section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody),
|
||||
section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody),
|
||||
section('Icon Sets', 'Upload or delete custom icon packs for device and service icons.', iconSetsWrap),
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('dashboard layout api', () => {
|
||||
it('GET returns defaults', async () => {
|
||||
const res = await request(app).get('/api/dashboard/layout').set(ownerHeaders);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ card_order: [], hidden: [], sizes: {} });
|
||||
expect(res.body).toEqual({ card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] });
|
||||
});
|
||||
|
||||
it('PUT persists and GET reflects it', async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ beforeAll(async () => { await resetDb(); await migrateUp(); });
|
||||
describe('dashboard_layout repo', () => {
|
||||
it('returns defaults when unset', async () => {
|
||||
const l = await repo.get();
|
||||
expect(l).toEqual({ card_order: [], hidden: [], sizes: {} });
|
||||
expect(l).toEqual({ card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] });
|
||||
});
|
||||
|
||||
it('upserts and reads back', async () => {
|
||||
|
||||
55
tests/routes/dross.test.js
Normal file
55
tests/routes/dross.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
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('dross chat', () => {
|
||||
it('GET /api/dross returns a global conversation + Dross agent', async () => {
|
||||
const res = await request(app).get('/api/dross').set(owner);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.conversation_id).toBeTruthy();
|
||||
expect(res.body.agent.slug).toBe('companion');
|
||||
expect(Array.isArray(res.body.messages)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/dross/turn rejects empty text (400)', async () => {
|
||||
const res = await request(app).post('/api/dross/turn').set(owner).send({ text: '' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('GET /api/dross without token is 401', async () => {
|
||||
const res = await request(app).get('/api/dross');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dross settings', () => {
|
||||
it('GET /api/dross/settings returns defaults', async () => {
|
||||
const res = await request(app).get('/api/dross/settings').set(owner);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' });
|
||||
});
|
||||
|
||||
it('PUT /api/dross/settings persists and round-trips', async () => {
|
||||
const body = { avatar: 'wisp', accent: '#aa66ff', persona: 'Be terse.', voiceMode: 'handsfree' };
|
||||
const put = await request(app).put('/api/dross/settings').set(owner).send(body);
|
||||
expect(put.status).toBe(200);
|
||||
const get = await request(app).get('/api/dross/settings').set(owner);
|
||||
expect(get.body).toMatchObject(body);
|
||||
});
|
||||
|
||||
it('PUT rejects a bad avatar (400)', async () => {
|
||||
const res = await request(app).put('/api/dross/settings').set(owner)
|
||||
.send({ avatar: 'nope', accent: '#aa66ff', persona: '', voiceMode: 'review' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
24
tests/routes/voice.test.js
Normal file
24
tests/routes/voice.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
21
tests/views/dross_avatar.test.js
Normal file
21
tests/views/dross_avatar.test.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { drossAvatar } from '../../public/components/dross_avatar.js';
|
||||
|
||||
describe('drossAvatar', () => {
|
||||
it('renders the requested variant class', () => {
|
||||
const eye = drossAvatar('soft-eye', 60);
|
||||
expect(eye.classList.contains('dross-orb')).toBe(true);
|
||||
expect(eye.querySelector('.av-eye')).toBeTruthy();
|
||||
expect(drossAvatar('wisp', 30).querySelector('.b-core')).toBeTruthy();
|
||||
expect(drossAvatar('motes', 30).querySelector('.d-core')).toBeTruthy();
|
||||
});
|
||||
it('falls back to soft-eye for unknown variants', () => {
|
||||
expect(drossAvatar('bogus', 60).querySelector('.av-eye')).toBeTruthy();
|
||||
});
|
||||
it('sets the pixel size', () => {
|
||||
const a = drossAvatar('wisp', 42);
|
||||
expect(a.style.width).toBe('42px');
|
||||
expect(a.style.height).toBe('42px');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user