Tray options implementation + Voice system rewrite + build fixes

This commit is contained in:
Priler 2026-01-07 23:29:46 +05:00
parent 412acb7e2d
commit 47b7e7a65d
117 changed files with 1300 additions and 2334 deletions

View file

@ -46,13 +46,13 @@
</p>
<p class="links last">
{#if $currentLanguage === "ru"}
{t('footer-support')} <a href={tgLink} target="_blank" class="telegram-link">
{t('footer-support')} <a href={boostyLink} target="_blank" class="telegram-link">
<img src="/media/icons/boosty.webp" alt="Boosty" width="18px" />
<span>Boosty</span>
</a>.
{/if}
{#if $currentLanguage === "ua" || $currentLanguage === "en"}
{t('footer-support')} <a href={tgLink} target="_blank" class="telegram-link">
{t('footer-support')} <a href={patreonLink} target="_blank" class="telegram-link">
<img src="/media/icons/patreon.png" alt="Patreon" width="18px" />
<span>Patreon</span>
</a>.

View file

@ -1,20 +1,12 @@
<script lang="ts">
import { jarvisState } from "@/stores"
$: stateClass = getStateClass($jarvisState)
function getStateClass(state: string): string {
switch (state) {
case "listening":
case "processing":
return "active"
case "idle":
return "idle"
case "disconnected":
default:
return "disconnected"
}
}
$: stateClass = {
'disconnected': 'disconnected',
'idle': 'idle',
'listening': 'active',
'processing': 'active'
}[$jarvisState] || 'disconnected'
</script>
<div id="arc-reactor" class="reactor-container {stateClass} arc-white">

View file

@ -1,22 +1,85 @@
<script lang="ts">
import { translations, translate } from "@/stores"
import { translations, translate, isJarvisRunning, ipcConnected, sendTextCommand } from "@/stores"
$: t = (key: string) => translate($translations, key)
let searchQuery = ""
let isProcessing = false
let statusMessage = ""
async function handleSubmit(e: Event) {
e.preventDefault()
const command = searchQuery.trim()
if (!command || isProcessing) return
if (!$isJarvisRunning || !$ipcConnected) {
statusMessage = t('search-error-not-running')
setTimeout(() => statusMessage = "", 3000)
return
}
isProcessing = true
statusMessage = ""
try {
await sendTextCommand(command)
searchQuery = ""
} catch (err) {
console.error("Failed to send command:", err)
statusMessage = t('search-error-failed')
setTimeout(() => statusMessage = "", 3000)
} finally {
isProcessing = false
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
searchQuery = ""
}
}
</script>
<div id="search-form" class="search" class:active={searchQuery !== ""}>
<form action="#" method="GET">
<div id="search-form" class="search" class:active={searchQuery !== ""} class:processing={isProcessing}>
<form on:submit={handleSubmit}>
<input
bind:value={searchQuery}
on:keydown={handleKeydown}
type="text"
name="q"
placeholder={t('search-placeholder')}
autocomplete="off"
minlength="3"
maxlength="30"
minlength="1"
maxlength="200"
disabled={isProcessing}
/>
<small>Enter</small>
<small>{isProcessing ? '...' : 'Enter'}</small>
</form>
</div>
{#if statusMessage}
<div class="search-status">{statusMessage}</div>
{/if}
</div>
<style lang="scss">
.search.processing input {
opacity: 0.6;
cursor: wait;
}
.search-status {
position: absolute;
bottom: -24px;
left: 50%;
transform: translateX(-50%);
font-size: 0.75rem;
color: rgba(82, 254, 254, 0.8);
white-space: nowrap;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(-5px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
</style>

View file

@ -12,10 +12,10 @@
$: t = (key: string) => translate($translations, key)
let microphoneName = "Загрузка..."
let microphoneName = ""
let wakeWordEngine = "Rustpotter"
let sttEngine = "Vosk"
let vadInfo = "Snip + ChatGPT"
let vadInfo = ""
onMount(async () => {
microphoneName = t('stats-loading')
@ -68,7 +68,7 @@
<span class="stat-dot" class:active={$ipcConnected} style="--color: #3b82f6;"></span>
<div class="stat-content">
<span class="stat-label">{t('stats-resources')}</span>
<span class="stat-value">{#if jarvisRamUsage }RAM {$jarvisRamUsage}mb{:else}...{/if}</span>
<span class="stat-value">{#if $jarvisRamUsage }RAM {$jarvisRamUsage}mb{:else}...{/if}</span>
</div>
</div>
</div>

View file

@ -1,4 +1,6 @@
import { writable, get } from "svelte/store"
import { invoke } from "@tauri-apps/api/core"
import { getCurrentWindow } from "@tauri-apps/api/window"
// ### IPC STORES ###
@ -30,57 +32,32 @@ export function disableIpc() {
disconnectIpc()
}
export function connectIpc() {
if (!enabled) {
console.log("IPC: Not enabled, skipping connection")
return
export function connectIpc(port: number = 9712) {
if (ws?.readyState === WebSocket.OPEN) return
ws = new WebSocket(`ws://127.0.0.1:${port}`)
ws.onopen = () => {
ipcConnected.set(true)
jarvisState.set("idle")
console.log("[IPC] connected")
}
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) {
return
ws.onclose = () => {
ipcConnected.set(false)
console.log("[IPC] disconnected")
}
manualDisconnect = false
ws.onerror = (err) => {
console.error("[IPC] error:", err)
}
console.log("IPC: Connecting to", IPC_URL)
try {
ws = new WebSocket(IPC_URL)
ws.onopen = () => {
console.log("IPC: Connected")
ipcConnected.set(true)
jarvisState.set("idle")
sendAction("ping")
}
ws.onclose = (event) => {
console.log("IPC: Disconnected", event.code)
ipcConnected.set(false)
jarvisState.set("disconnected")
ws = null
if (!manualDisconnect && enabled) {
scheduleReconnect()
}
}
ws.onerror = () => {
// error is handled in onclose, just suppress console spam
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
handleEvent(data)
} catch (e) {
console.error("IPC: Failed to parse message:", event.data, e)
}
}
} catch (e) {
// suppress errors when server isn't running
if (enabled) {
scheduleReconnect()
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
handleEvent(msg)
} catch (e) {
console.error("[IPC] failed to parse message:", e)
}
}
}
@ -151,6 +128,11 @@ function handleEvent(data: any) {
case "pong":
// connection verified
break
case "reveal_window":
// bring window to foreground
revealWindow()
break
}
}
@ -171,4 +153,35 @@ export function stopJarvisApp() {
export function reloadCommands() {
return sendAction("reload_commands")
}
}
export function sendIpcMessage(message: object): Promise<void> {
return new Promise((resolve, reject) => {
if (!ws || ws.readyState !== WebSocket.OPEN) {
reject(new Error("IPC not connected"))
return
}
try {
ws.send(JSON.stringify(message))
resolve()
} catch (err) {
reject(err)
}
})
}
export function sendTextCommand(text: string): boolean {
return sendAction("text_command", { text })
}
async function revealWindow() {
try {
const window = getCurrentWindow()
await window.show()
await window.unminimize()
await window.setFocus()
} catch (e) {
console.error("[IPC] Failed to reveal window:", e)
}
}

View file

@ -21,13 +21,17 @@
let processRunning = false
let launching = false
let wasRunning = false // track previous state
isJarvisRunning.subscribe((value) => {
processRunning = value
if (value) {
enableIpc()
} else {
wasRunning = true
} else if (wasRunning) {
// only disable if it was running before
disableIpc()
wasRunning = false
}
})

View file

@ -35,6 +35,30 @@
$: t = (key: string) => translate($translations, key)
interface VoiceMeta {
id: string
name: string
author: string
languages: string[]
}
interface VoiceConfig {
voice: VoiceMeta
}
let availableVoices: VoiceMeta[] = []
async function selectVoice(voiceId: string) {
voiceVal = voiceId
// play preview sound
try {
await invoke("preview_voice", { voiceId })
} catch (err) {
console.error("Failed to preview voice:", err)
}
}
// ### STATE
interface MicrophoneOption {
label: string
@ -113,11 +137,20 @@
// ### INIT
onMount(async () => {
// load voices
try {
const voices = await invoke<VoiceConfig[]>("list_voices")
availableVoices = voices.map(v => v.voice)
} catch (err) {
console.error("Failed to load voices:", err)
availableVoices = []
}
try {
// load microphones
const mics = await invoke<string[]>("pv_get_audio_devices")
availableMicrophones = [
{ label: "По умолчанию (Система)", value: "-1" }, // system default
{ label: t('settings-mic-default'), value: "-1" }, // system default
...mics.map((name, idx) => ({
label: name,
value: String(idx)
@ -200,18 +233,42 @@
<Tabs class="form" color="#8AC832" position="left">
<Tabs.Tab label={t('settings-general')} icon={Gear}>
<Space h="sm" />
<NativeSelect
data={[
{ label: "Jarvis New (ремастер)", value: "jarvis-remaster" },
{ label: "Рик из «Рик и Морти»", value: "rick-morty" },
{ label: "Jarvis (от Хауди)", value: "jarvis-howdy" },
{ label: "Jarvis OG (из фильмов)", value: "jarvis-og" }
]}
label={t('settings-voice')}
description={t('settings-voice-desc')}
variant="filled"
bind:value={voiceVal}
/>
<div class="voice-select">
<label>{t('settings-voice')}</label>
<p class="description">{t('settings-voice-desc')}</p>
<div class="voice-options">
{#each availableVoices as voice}
<button
type="button"
class="voice-option"
class:selected={voiceVal === voice.id}
on:click={() => selectVoice(voice.id)}
>
<div class="voice-info">
<span class="voice-name">{voice.name}</span>
{#if voice.author}
<span class="voice-author">by {voice.author}</span>
{/if}
</div>
<div class="voice-languages">
{#each voice.languages as lang}
<img
src="/media/flags/{lang.toUpperCase()}.png"
alt={lang}
width="20"
title={lang}
/>
{/each}
</div>
</button>
{/each}
{#if availableVoices.length === 0}
<p class="no-voices">{t('settings-no-voices')}</p>
{/if}
</div>
</div>
</Tabs.Tab>
<Tabs.Tab label={t('settings-devices')} icon={Mix}>
@ -390,3 +447,112 @@
<HDivider />
<Footer />
<style lang="scss">
.voice-select {
margin-bottom: 1rem;
label {
font-weight: 600;
font-size: 0.9rem;
color: #fff;
display: block;
margin-bottom: 0.25rem;
}
.description {
font-size: 0.75rem;
color: rgba(255,255,255,0.5);
margin: 0 0 0.75rem;
white-space: pre-line;
}
}
$voice-item-height: 70px;
$voice-item-gap: 0.5rem;
$voice-max-visible: 3;
.voice-options {
display: flex;
flex-direction: column;
gap: $voice-item-gap;
max-height: $voice-item-height * $voice-max-visible;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
&:hover {
background: rgba(255, 255, 255, 0.3);
}
}
}
.voice-option {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: rgba(30, 40, 45, 0.8);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
width: 100%;
&:hover {
background: rgba(40, 55, 60, 0.9);
border-color: rgba(255,255,255,0.2);
}
&.selected {
background: rgba(82, 254, 254, 0.1);
border-color: rgba(82, 254, 254, 0.4);
}
}
.voice-info {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
}
.voice-name {
font-size: 0.85rem;
color: #fff;
font-weight: 500;
}
.voice-author {
font-size: 0.7rem;
color: rgba(255,255,255,0.4);
}
.voice-languages {
display: flex;
gap: 0.35rem;
img {
opacity: 0.8;
border-radius: 2px;
}
}
.no-voices {
font-size: 0.8rem;
color: rgba(255,255,255,0.4);
font-style: italic;
}
</style>

View file

@ -13,6 +13,8 @@ export {
disableIpc,
disconnectIpc,
sendAction,
sendIpcMessage,
sendTextCommand,
stopJarvisApp,
reloadCommands
} from "./lib/ipc"