Tray options implementation + Voice system rewrite + build fixes
This commit is contained in:
parent
412acb7e2d
commit
47b7e7a65d
117 changed files with 1300 additions and 2334 deletions
|
|
@ -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>.
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -13,6 +13,8 @@ export {
|
|||
disableIpc,
|
||||
disconnectIpc,
|
||||
sendAction,
|
||||
sendIpcMessage,
|
||||
sendTextCommand,
|
||||
stopJarvisApp,
|
||||
reloadCommands
|
||||
} from "./lib/ipc"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue