feat: VoiceCoach v2 — livekit-agents 1.5 personal voice assistant
Replaces pipecat 0.0.48 (6 manual patches, DuplicateIdentity reconnect storms) with livekit-agents 1.5 SDK. New features: - SvelteKit UI with live transcript, VAD ring, mode bar, vocal meter, history view - SQLite session store with WAL mode (cross-session memory) - Async SpeechBrain vocal/emotion analysis injected per turn - Homelab tools: SSH/docker, Prometheus alerts, Home Assistant, SearXNG search - OpenClaw LLM gateway preserved (qwen2.5:14b via Ollama) - 4 modes: coaching, vent, practice, briefing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--surface2: #22263a;
|
||||
--accent: #6c63ff;
|
||||
--accent2: #4ecdc4;
|
||||
--text: #e8eaf0;
|
||||
--muted: #7b8094;
|
||||
--danger: #ff5252;
|
||||
--success: #4caf50;
|
||||
--warn: #ffc107;
|
||||
--radius: 12px;
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--font); }
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5rem 1.2rem;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
button:active { transform: scale(0.97); }
|
||||
button:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover:not(:disabled) { opacity: 0.85; }
|
||||
.btn-danger { background: var(--danger); color: #fff; }
|
||||
.btn-ghost { background: var(--surface2); color: var(--text); }
|
||||
.btn-ghost:hover:not(:disabled) { background: #2d3147; }
|
||||
|
||||
a { color: var(--accent2); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
<title>VoiceCoach</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
<div>%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Room, RoomEvent, Track } from 'livekit-client';
|
||||
|
||||
const LIVEKIT_URL = 'wss://voice.hobbs.farm';
|
||||
const API_BASE = '/api';
|
||||
|
||||
export let room = null;
|
||||
|
||||
export async function connect(identity, onEvent) {
|
||||
const res = await fetch(`${API_BASE}/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identity: identity || `jeremy-${Date.now()}` }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Token fetch failed: ${res.status}`);
|
||||
const { token, url } = await res.json();
|
||||
|
||||
room = new Room({
|
||||
audioCaptureDefaults: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
|
||||
room.on(RoomEvent.Connected, () => onEvent({ type: 'connected' }));
|
||||
room.on(RoomEvent.Disconnected, (reason) => onEvent({ type: 'disconnected', reason }));
|
||||
room.on(RoomEvent.ParticipantConnected, (p) => onEvent({ type: 'participant_joined', identity: p.identity }));
|
||||
room.on(RoomEvent.ParticipantDisconnected, (p) => onEvent({ type: 'participant_left', identity: p.identity }));
|
||||
|
||||
room.on(RoomEvent.TrackSubscribed, (track, _pub, participant) => {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
const el = track.attach();
|
||||
el.autoplay = true;
|
||||
document.body.appendChild(el);
|
||||
onEvent({ type: 'audio_attached', identity: participant.identity });
|
||||
}
|
||||
});
|
||||
|
||||
room.on(RoomEvent.TrackUnsubscribed, (track) => {
|
||||
track.detach().forEach(el => el.remove());
|
||||
});
|
||||
|
||||
room.on(RoomEvent.DataReceived, (payload, participant) => {
|
||||
try {
|
||||
const msg = JSON.parse(new TextDecoder().decode(payload));
|
||||
onEvent(msg);
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
await room.connect(url || LIVEKIT_URL, token);
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
return room;
|
||||
}
|
||||
|
||||
export async function disconnect() {
|
||||
if (room) {
|
||||
await room.disconnect();
|
||||
room = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function sendData(payload) {
|
||||
if (!room?.localParticipant) return;
|
||||
const data = new TextEncoder().encode(JSON.stringify(payload));
|
||||
room.localParticipant.publishData(data, { reliable: true });
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const prerender = false;
|
||||
export const ssr = false;
|
||||
@@ -0,0 +1,6 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -0,0 +1,328 @@
|
||||
<script>
|
||||
import { connect, disconnect, sendData } from '$lib/livekit.js';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
let status = 'disconnected'; // disconnected | connecting | connected
|
||||
let mode = 'coaching';
|
||||
let transcript = []; // {role, text, partial, id}
|
||||
let tools = []; // {action, detail, result, done}
|
||||
let vocal = null; // {energy, emotion, pitch_hz}
|
||||
let error = '';
|
||||
let nextId = 0;
|
||||
|
||||
const MODES = ['coaching', 'vent', 'practice', 'briefing'];
|
||||
|
||||
function handleEvent(ev) {
|
||||
switch (ev.type) {
|
||||
case 'connected':
|
||||
status = 'connected';
|
||||
error = '';
|
||||
break;
|
||||
case 'disconnected':
|
||||
status = 'disconnected';
|
||||
break;
|
||||
case 'partial_transcript':
|
||||
if (ev.speaker === 'user') {
|
||||
const last = transcript[transcript.length - 1];
|
||||
if (last && last.partial && last.role === 'user') {
|
||||
transcript[transcript.length - 1] = { ...last, text: ev.text };
|
||||
} else {
|
||||
transcript = [...transcript, { role: 'user', text: ev.text, partial: true, id: nextId++ }];
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'final_transcript':
|
||||
if (ev.speaker === 'user') {
|
||||
// Replace last partial or add new
|
||||
const lastIdx = transcript.findLastIndex(t => t.partial && t.role === 'user');
|
||||
if (lastIdx >= 0) {
|
||||
transcript = transcript.map((t, i) =>
|
||||
i === lastIdx ? { ...t, text: ev.text, partial: false } : t
|
||||
);
|
||||
} else {
|
||||
transcript = [...transcript, { role: 'user', text: ev.text, partial: false, id: nextId++ }];
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'agent_response':
|
||||
transcript = [...transcript, { role: 'assistant', text: ev.text, partial: false, id: nextId++ }];
|
||||
break;
|
||||
case 'tool_start':
|
||||
tools = [...tools, { action: ev.action, detail: ev.detail || '', result: '', done: false, id: nextId++ }];
|
||||
break;
|
||||
case 'tool_complete':
|
||||
tools = tools.map(t =>
|
||||
!t.done && t.action === ev.action ? { ...t, result: ev.result, done: true } : t
|
||||
);
|
||||
break;
|
||||
case 'vocal_state':
|
||||
vocal = { energy: ev.energy, emotion: ev.emotion, pitch_hz: ev.pitch_hz };
|
||||
break;
|
||||
case 'mode_ack':
|
||||
mode = ev.mode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function startSession() {
|
||||
status = 'connecting';
|
||||
error = '';
|
||||
transcript = [];
|
||||
tools = [];
|
||||
try {
|
||||
await connect(null, handleEvent);
|
||||
} catch (e) {
|
||||
status = 'disconnected';
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function endSession() {
|
||||
await disconnect();
|
||||
status = 'disconnected';
|
||||
}
|
||||
|
||||
function setMode(m) {
|
||||
mode = m;
|
||||
sendData({ type: 'mode_change', mode: m });
|
||||
}
|
||||
|
||||
onDestroy(() => disconnect());
|
||||
|
||||
$: energyPct = vocal ? Math.round(vocal.energy * 100) : 0;
|
||||
$: vadRing = status === 'connected' && vocal && vocal.energy > 0.1;
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<header>
|
||||
<div class="logo">
|
||||
<div class="logo-dot" class:connected={status === 'connected'} class:connecting={status === 'connecting'}></div>
|
||||
<span>VoiceCoach</span>
|
||||
</div>
|
||||
<nav>
|
||||
<a href="/history">History</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<!-- Mode bar -->
|
||||
<div class="mode-bar" class:disabled={status !== 'connected'}>
|
||||
{#each MODES as m}
|
||||
<button
|
||||
class="mode-chip"
|
||||
class:active={mode === m}
|
||||
disabled={status !== 'connected'}
|
||||
on:click={() => setMode(m)}
|
||||
>{m}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Transcript -->
|
||||
<div class="transcript" id="transcript-scroll">
|
||||
{#if transcript.length === 0 && status === 'disconnected'}
|
||||
<p class="hint">Press Start to connect your voice.</p>
|
||||
{:else if transcript.length === 0 && status !== 'disconnected'}
|
||||
<p class="hint">Listening…</p>
|
||||
{/if}
|
||||
{#each transcript as msg (msg.id)}
|
||||
<div class="msg msg-{msg.role}" class:partial={msg.partial}>
|
||||
<span class="msg-label">{msg.role === 'user' ? 'You' : 'Assistant'}</span>
|
||||
<span class="msg-text">{msg.text}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Tool activity feed -->
|
||||
{#if tools.length > 0}
|
||||
<div class="tool-feed">
|
||||
{#each tools.slice(-5) as t (t.id)}
|
||||
<div class="tool-item" class:done={t.done}>
|
||||
<span class="tool-icon">{t.done ? '✓' : '⚙'}</span>
|
||||
<span class="tool-text">{t.action}{t.detail ? ` — ${t.detail}` : ''}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Vocal meter -->
|
||||
{#if status === 'connected'}
|
||||
<div class="vocal-row">
|
||||
<div class="energy-bar">
|
||||
<div class="energy-fill" style="width: {energyPct}%"></div>
|
||||
</div>
|
||||
{#if vocal && vocal.emotion && vocal.emotion !== 'neutral'}
|
||||
<span class="emotion-tag">{vocal.emotion}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error -->
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="controls">
|
||||
{#if status === 'disconnected'}
|
||||
<button class="btn-primary big-btn" on:click={startSession}>Start Session</button>
|
||||
{:else if status === 'connecting'}
|
||||
<button class="btn-ghost big-btn" disabled>Connecting…</button>
|
||||
{:else}
|
||||
<button class="btn-danger big-btn" on:click={endSession}>End Session</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VAD ring -->
|
||||
{#if vadRing}
|
||||
<div class="vad-ring" style="opacity: {Math.min(vocal.energy * 3, 1)}"></div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #2a2d3e;
|
||||
}
|
||||
|
||||
.logo { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; font-size: 1.1rem; }
|
||||
|
||||
.logo-dot {
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
.logo-dot.connecting { background: var(--warn); animation: pulse 1s infinite; }
|
||||
.logo-dot.connected { background: var(--success); }
|
||||
|
||||
nav a { font-size: 0.875rem; color: var(--muted); }
|
||||
|
||||
.content { flex: 1; display: flex; flex-direction: column; padding: 1rem 0; gap: 1rem; }
|
||||
|
||||
.mode-bar {
|
||||
display: flex; gap: 0.5rem; flex-wrap: wrap;
|
||||
}
|
||||
.mode-bar.disabled { opacity: 0.5; }
|
||||
|
||||
.mode-chip {
|
||||
padding: 0.35rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
background: var(--surface2);
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
text-transform: capitalize;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.mode-chip.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.transcript {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.hint { color: var(--muted); font-size: 0.875rem; text-align: center; margin: auto; }
|
||||
|
||||
.msg { display: flex; flex-direction: column; gap: 0.15rem; }
|
||||
|
||||
.msg-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.msg-user .msg-label { color: var(--accent2); }
|
||||
.msg-assistant .msg-label { color: var(--accent); }
|
||||
|
||||
.msg-text { font-size: 0.95rem; line-height: 1.5; }
|
||||
.msg.partial .msg-text { opacity: 0.6; font-style: italic; }
|
||||
|
||||
.tool-feed {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tool-item {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
font-size: 0.8rem; color: var(--muted);
|
||||
}
|
||||
.tool-item.done { color: var(--success); }
|
||||
.tool-icon { font-size: 0.9rem; }
|
||||
|
||||
.vocal-row {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
}
|
||||
|
||||
.energy-bar {
|
||||
flex: 1; height: 6px; border-radius: 3px;
|
||||
background: var(--surface2); overflow: hidden;
|
||||
}
|
||||
.energy-fill {
|
||||
height: 100%; border-radius: 3px;
|
||||
background: linear-gradient(90deg, var(--accent2), var(--accent));
|
||||
transition: width 0.2s;
|
||||
}
|
||||
|
||||
.emotion-tag {
|
||||
font-size: 0.75rem; color: var(--muted);
|
||||
background: var(--surface2);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.error { color: var(--danger); font-size: 0.85rem; }
|
||||
|
||||
.controls { display: flex; justify-content: center; padding-bottom: 1.5rem; }
|
||||
|
||||
.big-btn {
|
||||
width: 100%; max-width: 320px; padding: 0.85rem;
|
||||
font-size: 1rem; border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.vad-ring {
|
||||
position: fixed; inset: 0; pointer-events: none;
|
||||
border: 3px solid var(--accent2);
|
||||
border-radius: inherit;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.big-btn { max-width: 100%; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let sessions = [];
|
||||
let expanded = null;
|
||||
let messages = {};
|
||||
let loading = true;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sessions?limit=30');
|
||||
sessions = await res.json();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function toggleSession(id) {
|
||||
if (expanded === id) { expanded = null; return; }
|
||||
expanded = id;
|
||||
if (!messages[id]) {
|
||||
const res = await fetch(`/api/sessions/${id}/messages`);
|
||||
messages[id] = await res.json();
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(ts) {
|
||||
if (!ts) return '';
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function duration(s) {
|
||||
if (!s.ended_at || !s.started_at) return '';
|
||||
const mins = Math.round((s.ended_at - s.started_at) / 60);
|
||||
return mins < 1 ? '<1m' : `${mins}m`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<header>
|
||||
<a href="/" class="back">← Back</a>
|
||||
<h1>Session History</h1>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<p class="hint">Loading…</p>
|
||||
{:else if sessions.length === 0}
|
||||
<p class="hint">No sessions yet.</p>
|
||||
{:else}
|
||||
<div class="session-list">
|
||||
{#each sessions as s}
|
||||
<div class="session-card" class:open={expanded === s.id}>
|
||||
<button class="session-header" on:click={() => toggleSession(s.id)}>
|
||||
<div class="session-meta">
|
||||
<span class="session-date">{fmt(s.started_at)}</span>
|
||||
<span class="session-chips">
|
||||
<span class="chip mode-chip">{s.mode || 'coaching'}</span>
|
||||
{#if duration(s)}
|
||||
<span class="chip">{duration(s)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if s.summary}
|
||||
<p class="session-summary">{s.summary}</p>
|
||||
{/if}
|
||||
<span class="toggle-icon">{expanded === s.id ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{#if expanded === s.id && messages[s.id]}
|
||||
<div class="transcript">
|
||||
{#each messages[s.id] as msg}
|
||||
<div class="msg msg-{msg.role}">
|
||||
<span class="msg-label">
|
||||
{msg.role === 'user' ? 'You' : 'Assistant'}
|
||||
{#if msg.vocal_emotion && msg.vocal_emotion !== 'neutral'}
|
||||
<span class="vocal-badge">{msg.vocal_emotion}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="msg-text">{msg.content}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
max-width: 680px; margin: 0 auto; padding: 1rem;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
padding: 1rem 0; border-bottom: 1px solid #2a2d3e; margin-bottom: 1rem;
|
||||
}
|
||||
header h1 { font-size: 1.1rem; font-weight: 600; }
|
||||
.back { font-size: 0.875rem; color: var(--muted); }
|
||||
|
||||
.hint { color: var(--muted); text-align: center; padding: 2rem; }
|
||||
|
||||
.session-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
|
||||
.session-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.session-card.open { border-color: var(--accent); }
|
||||
|
||||
.session-header {
|
||||
width: 100%; text-align: left;
|
||||
background: none; padding: 1rem;
|
||||
display: flex; flex-direction: column; gap: 0.4rem;
|
||||
border-radius: 0; color: var(--text);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.session-meta { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.session-date { font-size: 0.8rem; color: var(--muted); }
|
||||
|
||||
.session-chips { display: flex; gap: 0.35rem; }
|
||||
.chip {
|
||||
font-size: 0.7rem; padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px; background: var(--surface2); color: var(--muted);
|
||||
}
|
||||
.mode-chip { color: var(--accent2); }
|
||||
|
||||
.session-summary { font-size: 0.85rem; color: var(--text); line-height: 1.4; }
|
||||
|
||||
.toggle-icon {
|
||||
position: absolute; right: 1rem; top: 1rem;
|
||||
font-size: 0.7rem; color: var(--muted);
|
||||
}
|
||||
|
||||
.transcript {
|
||||
padding: 1rem; border-top: 1px solid #2a2d3e;
|
||||
display: flex; flex-direction: column; gap: 0.75rem;
|
||||
}
|
||||
|
||||
.msg { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||
.msg-label {
|
||||
font-size: 0.7rem; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; color: var(--muted);
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
}
|
||||
.msg-user .msg-label { color: var(--accent2); }
|
||||
.msg-assistant .msg-label { color: var(--accent); }
|
||||
.msg-text { font-size: 0.9rem; line-height: 1.5; }
|
||||
|
||||
.vocal-badge {
|
||||
font-size: 0.65rem; background: var(--surface2);
|
||||
padding: 0.1rem 0.4rem; border-radius: 999px;
|
||||
text-transform: capitalize; color: var(--muted);
|
||||
font-weight: 400; letter-spacing: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user