Merge pull request 'Merge dev into main' (#24) from dev into main
Deploy Dashboard / deploy (push) Successful in 57s

This commit was merged in pull request #24.
This commit is contained in:
2026-03-06 15:21:10 +08:00
+78 -40
View File
@@ -1,5 +1,5 @@
<script> <script>
import { onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { VoiceSession } from "../lib/voice.js"; import { VoiceSession } from "../lib/voice.js";
const hosts = [ const hosts = [
@@ -10,13 +10,54 @@
{ id: "claude-dev", label: "Claude Dev" }, { id: "claude-dev", label: "Claude Dev" },
]; ];
const DARK_THEME = {
background: "#0f172a", foreground: "#e2e8f0", cursor: "#6366f1",
cursorAccent: "#0f172a", selectionBackground: "#334155",
black: "#0f172a", red: "#f43f5e", green: "#10b981",
yellow: "#f59e0b", blue: "#3b82f6", magenta: "#a855f7",
cyan: "#06b6d4", white: "#e2e8f0",
};
const LIGHT_THEME = {
background: "#f8fafc", foreground: "#0f172a", cursor: "#4f46e5",
cursorAccent: "#f8fafc", selectionBackground: "#cbd5e1",
black: "#0f172a", red: "#e11d48", green: "#059669",
yellow: "#d97706", blue: "#2563eb", magenta: "#9333ea",
cyan: "#0891b2", white: "#334155",
};
let tabs = $state([]); let tabs = $state([]);
let activeTab = $state(null); let activeTab = $state(null);
let showMenu = $state(false); let showMenu = $state(false);
let voiceTick = $state(0); let voiceTick = $state(0);
let isDark = $state(false);
let tabCounter = 0; let tabCounter = 0;
const tabData = new Map(); const tabData = new Map();
function currentTheme() {
return isDark ? DARK_THEME : LIGHT_THEME;
}
function applyThemeToTerminals() {
const theme = currentTheme();
tabData.forEach((d) => {
d.term?.options && (d.term.options.theme = theme);
});
}
function syncTheme() {
isDark = document.documentElement.classList.contains("dark");
applyThemeToTerminals();
}
const themeObserver = new MutationObserver((mutations) => {
if (mutations.some((m) => m.attributeName === "class")) syncTheme();
});
onMount(() => {
syncTheme();
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
});
function cleanName(name) { function cleanName(name) {
return (name || "image") return (name || "image")
.replace(/[^a-zA-Z0-9._-]/g, "-") .replace(/[^a-zA-Z0-9._-]/g, "-")
@@ -106,13 +147,7 @@
cursorBlink: true, fontSize: 13, cursorBlink: true, fontSize: 13,
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', 'Menlo', monospace", fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', 'Menlo', monospace",
lineHeight: 1.4, lineHeight: 1.4,
theme: { theme: currentTheme(),
background: "#0f172a", foreground: "#e2e8f0", cursor: "#6366f1",
cursorAccent: "#0f172a", selectionBackground: "#334155",
black: "#0f172a", red: "#f43f5e", green: "#10b981",
yellow: "#f59e0b", blue: "#3b82f6", magenta: "#a855f7",
cyan: "#06b6d4", white: "#e2e8f0",
},
}); });
const fit = new FitAddon(); const fit = new FitAddon();
term.loadAddon(fit); term.loadAddon(fit);
@@ -182,25 +217,28 @@
function activeVoice() { return voiceTick >= 0 && tabData.get(activeTab)?.voice; } function activeVoice() { return voiceTick >= 0 && tabData.get(activeTab)?.voice; }
onDestroy(() => tabData.forEach((d) => { onDestroy(() => {
d.voice?.destroy(); themeObserver.disconnect();
d.ro?.disconnect(); tabData.forEach((d) => {
d.term?.element?.removeEventListener("dragover", d.handleDragOver); d.voice?.destroy();
d.term?.element?.removeEventListener("drop", d.handleDrop); d.ro?.disconnect();
d.term?.textarea?.removeEventListener("paste", d.handlePaste); d.term?.element?.removeEventListener("dragover", d.handleDragOver);
d.ws?.close(); d.term?.element?.removeEventListener("drop", d.handleDrop);
d.term?.dispose(); d.term?.textarea?.removeEventListener("paste", d.handlePaste);
})); d.ws?.close();
d.term?.dispose();
});
});
</script> </script>
<div class={activeVoice()?.voiceMode ? 'flex flex-col h-[calc(100vh-4rem)] overflow-hidden gap-1' : 'space-y-2'}> <div class={activeVoice()?.voiceMode ? 'flex flex-col h-[calc(100vh-4rem)] overflow-hidden gap-1' : 'space-y-2'}>
{#if !activeVoice()?.voiceMode} {#if !activeVoice()?.voiceMode}
<h1 class="text-2xl font-bold text-surface-900 tracking-tight">Terminal</h1> <h1 class="text-2xl font-bold text-surface-900 dark:text-surface-100 tracking-tight">Terminal</h1>
{/if} {/if}
<div class="flex items-center gap-1 border-b border-surface-700 pb-1 relative"> <div class="flex items-center gap-1 border-b border-surface-300 dark:border-surface-700 pb-1 relative">
{#each tabs as tab (tab.id)} {#each tabs as tab (tab.id)}
<button <button
class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-t-lg transition-colors {activeTab === tab.id ? 'bg-surface-800 text-surface-100' : 'text-surface-400 hover:text-surface-200 hover:bg-surface-800/50'}" class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-t-lg transition-colors {activeTab === tab.id ? 'bg-surface-200 dark:bg-surface-800 text-surface-900 dark:text-surface-100' : 'text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800/50'}"
onclick={() => activeTab = tab.id} onclick={() => activeTab = tab.id}
> >
{#if tab.connected} {#if tab.connected}
@@ -222,14 +260,14 @@
{/each} {/each}
<div class="relative"> <div class="relative">
<button <button
class="px-2 py-1 text-surface-400 hover:text-surface-100 hover:bg-surface-800 rounded text-lg leading-none" class="px-2 py-1 text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:bg-surface-200 dark:hover:bg-surface-800 rounded text-lg leading-none"
onclick={() => showMenu = !showMenu} onclick={() => showMenu = !showMenu}
>+</button> >+</button>
{#if showMenu} {#if showMenu}
<div class="absolute top-full left-0 mt-1 bg-surface-800 border border-surface-600 rounded-lg shadow-xl z-10 py-1 min-w-[140px]"> <div class="absolute top-full left-0 mt-1 bg-surface-100 dark:bg-surface-800 border border-surface-300 dark:border-surface-600 rounded-lg shadow-xl z-10 py-1 min-w-[140px]">
{#each hosts as h} {#each hosts as h}
<button <button
class="block w-full text-left px-3 py-1.5 text-sm text-surface-200 hover:bg-surface-700" class="block w-full text-left px-3 py-1.5 text-sm text-surface-800 dark:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
onclick={() => addTab(h.id)} onclick={() => addTab(h.id)}
>{h.label}</button> >{h.label}</button>
{/each} {/each}
@@ -240,7 +278,7 @@
{@const v = activeVoice()} {@const v = activeVoice()}
<div class="ml-auto"> <div class="ml-auto">
<button <button
class="px-2 py-1 rounded text-sm transition-colors {v.voiceMode ? 'text-rose-400' : v.sttAvailable ? 'text-surface-500 hover:text-surface-300' : 'text-surface-700 cursor-not-allowed'}" class="px-2 py-1 rounded text-sm transition-colors {v.voiceMode ? 'text-rose-400' : v.sttAvailable ? 'text-surface-600 dark:text-surface-500 hover:text-surface-800 dark:hover:text-surface-300' : 'text-surface-300 dark:text-surface-700 cursor-not-allowed'}"
title={!v.sttAvailable ? 'STT not supported' : v.voiceMode ? 'Voice mode on' : 'Enter voice mode'} title={!v.sttAvailable ? 'STT not supported' : v.voiceMode ? 'Voice mode on' : 'Enter voice mode'}
disabled={!v.sttAvailable} disabled={!v.sttAvailable}
onclick={() => { if (!v.voiceMode) v.enterVoiceMode(); else v.exitVoiceMode(); voiceTick++; }} onclick={() => { if (!v.voiceMode) v.enterVoiceMode(); else v.exitVoiceMode(); voiceTick++; }}
@@ -251,12 +289,12 @@
{/if} {/if}
</div> </div>
<p class="text-xs text-surface-500"> <p class="text-xs text-surface-600 dark:text-surface-500">
Tip: drag & drop or paste images into the active terminal tab to upload them to <code class="text-surface-400">/tmp/claude-images</code>. Tip: drag & drop or paste images into the active terminal tab to upload them to <code class="text-surface-700 dark:text-surface-400">/tmp/claude-images</code>.
</p> </p>
{#if !tabs.length} {#if !tabs.length}
<div class="flex items-center justify-center h-[calc(100vh-12rem)] text-surface-500 text-sm"> <div class="flex items-center justify-center h-[calc(100vh-12rem)] text-surface-600 dark:text-surface-500 text-sm">
Click + to open a terminal Click + to open a terminal
</div> </div>
{/if} {/if}
@@ -265,21 +303,21 @@
<div class="{activeTab === tab.id ? '' : 'hidden'} {activeVoice()?.voiceMode ? 'flex-1 min-h-0' : ''}"> <div class="{activeTab === tab.id ? '' : 'hidden'} {activeVoice()?.voiceMode ? 'flex-1 min-h-0' : ''}">
<div <div
use:initTerminal={tab} use:initTerminal={tab}
class="h-full rounded-xl overflow-hidden shadow-lg border border-surface-700 transition-all duration-300" class="h-full rounded-xl overflow-hidden shadow-lg border border-surface-300 dark:border-surface-700 bg-surface-50 dark:bg-[#0f172a] transition-all duration-300"
style="background: #0f172a; {activeVoice()?.voiceMode ? '' : 'height: calc(100vh - 10rem);'}" style="{activeVoice()?.voiceMode ? '' : 'height: calc(100vh - 10rem);'}"
></div> ></div>
</div> </div>
{/each} {/each}
{#if activeVoice()?.voiceMode} {#if activeVoice()?.voiceMode}
{@const v = activeVoice()} {@const v = activeVoice()}
<div class="mt-1 shrink-0 rounded-xl bg-surface-800/95 border border-surface-700 shadow-xl overflow-hidden animate-slide-up"> <div class="mt-1 shrink-0 rounded-xl bg-surface-100/95 dark:bg-surface-800/95 border border-surface-300 dark:border-surface-700 shadow-xl overflow-hidden animate-slide-up">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between px-3 py-1.5 border-b border-surface-700/50"> <div class="flex items-center justify-between px-3 py-1.5 border-b border-surface-300/50 dark:border-surface-700/50">
<span class="text-xs font-medium text-surface-300">Voice Mode</span> <span class="text-xs font-medium text-surface-700 dark:text-surface-300">Voice Mode</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<select <select
class="bg-surface-700 text-surface-300 text-xs rounded px-1 py-0.5 outline-none max-w-[120px]" class="bg-surface-200 dark:bg-surface-700 text-surface-800 dark:text-surface-300 text-xs rounded px-1 py-0.5 outline-none max-w-[120px]"
onchange={(e) => { v.setVoice(+e.target.value); voiceTick++; }} onchange={(e) => { v.setVoice(+e.target.value); voiceTick++; }}
> >
{#each v.voices as voice, i} {#each v.voices as voice, i}
@@ -287,11 +325,11 @@
{/each} {/each}
</select> </select>
<button <button
class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-400 hover:text-surface-200 hover:bg-surface-700" class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
onclick={() => { v.cycleLang(); voiceTick++; }} onclick={() => { v.cycleLang(); voiceTick++; }}
>{v.langLabel}</button> >{v.langLabel}</button>
<button <button
class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-400 hover:text-surface-200 hover:bg-surface-700" class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
onclick={() => { v.cycleTalkMode(); voiceTick++; }} onclick={() => { v.cycleTalkMode(); voiceTick++; }}
>{v.modeLabel}</button> >{v.modeLabel}</button>
<button <button
@@ -305,7 +343,7 @@
{#if v.talkMode === "hold"} {#if v.talkMode === "hold"}
<button <button
aria-label="Hold to talk" aria-label="Hold to talk"
class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-700 text-surface-300 hover:bg-surface-600'}" class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-300 dark:hover:bg-surface-600'}"
onpointerdown={() => { v.startHold(); voiceTick++; }} onpointerdown={() => { v.startHold(); voiceTick++; }}
onpointerup={() => { v.releaseHold(); voiceTick++; }} onpointerup={() => { v.releaseHold(); voiceTick++; }}
onpointerleave={() => { if (v.listening) { v.releaseHold(); voiceTick++; } }} onpointerleave={() => { if (v.listening) { v.releaseHold(); voiceTick++; } }}
@@ -315,21 +353,21 @@
{:else if v.talkMode === "tap"} {:else if v.talkMode === "tap"}
<button <button
aria-label="Tap to talk" aria-label="Tap to talk"
class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-700 text-surface-300 hover:bg-surface-600'}" class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-300 dark:hover:bg-surface-600'}"
onclick={() => { v.tapMic(); voiceTick++; }} onclick={() => { v.tapMic(); voiceTick++; }}
> >
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
</button> </button>
{:else} {:else}
<div class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center {v.listening ? 'bg-emerald-500/20 text-emerald-400 animate-pulse' : 'bg-surface-700 text-surface-500'}"> <div class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center {v.listening ? 'bg-emerald-500/20 text-emerald-400 animate-pulse' : 'bg-surface-200 dark:bg-surface-700 text-surface-500'}">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
</div> </div>
{/if} {/if}
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
{#if v.transcript} {#if v.transcript}
<p class="text-sm text-surface-400 italic truncate">"{v.transcript}"</p> <p class="text-sm text-surface-600 dark:text-surface-400 italic truncate">"{v.transcript}"</p>
{:else} {:else}
<p class="text-xs text-surface-600">{v.speaking ? 'Speaking...' : v.listening ? 'Listening...' : 'Ready'}</p> <p class="text-xs text-surface-500 dark:text-surface-600">{v.speaking ? 'Speaking...' : v.listening ? 'Listening...' : 'Ready'}</p>
{/if} {/if}
</div> </div>
</div> </div>