feat(gui): /macros + /scheduler pages + README rewrite

Surfaces voice macros and scheduled tasks in the GUI, so the user doesn't
have to remember voice commands or grep schedule.json by hand.

Tauri commands (crates/jarvis-gui/src/tauri_commands/)
  - macros.rs:    macros_list, macros_replay, macros_delete, macros_is_recording,
                  macros_recording_name, macros_start_recording, macros_save_recording,
                  macros_cancel_recording (8 commands).
  - scheduler.rs: scheduler_list, scheduler_remove, scheduler_clear (3 commands).
  - Both exposed in main.rs invoke_handler.

GUI pages (frontend/src/routes/)
  - macros/index.svelte:
    * Lists all macros with name, steps_count, first 5 step previews,
      created/last_run timestamps.
    * Top: TextInput + "Начать запись" button. While recording shows orange
      banner with "Сохранить"/"Отменить" buttons + polls is_recording every 2s.
    * Per-card: "Запустить" (with busy state for replay duration), "Удалить".
    * confirm() before delete.
  - scheduler/index.svelte:
    * Lists tasks with name, schedule_human (e.g. "каждые 2 ч"), action body,
      ID, timestamps, action_kind badge.
    * "Отменить" per task + "Очистить всё (N)" bottom button.
    * Auto-polls every 5s (so the list updates as scheduler ticks fire tasks).

Header (frontend/src/components/Header.svelte)
  - Two new buttons: "Макросы" → /macros, "Расписание" → /scheduler.
  - Beside existing /commands and /settings buttons.

i18n
  - ru/en/ua FTL: settings-ai-backends, settings-llm-*, settings-tts-*,
    settings-profile, header-macros, header-scheduler.
  - Re-applied AI Backends keys for ua.ftl (earlier edit hadn't taken).

README.md (full rewrite)
  - Old README was 178 lines mostly explaining LLM-trigger flow and VAD config.
  - New README is ~190 lines covering: features (LLM hot-swap, memory, profiles,
    vision, scheduler, macros, utilities table), quick start, env vars table,
    build steps with vcvars setup, test command, structure tree, voice workflow
    diagram, license, roadmap.
  - Up to date for 59 packs and all new infra.

Build: cargo build --release -p jarvis-gui green (2m). 55/55 tests pass.
This commit is contained in:
Bossiara13 2026-05-15 17:28:22 +03:00
parent c7ab751e39
commit c22a24ccd8
11 changed files with 998 additions and 167 deletions

View file

@ -0,0 +1,306 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte"
import { invoke } from "@tauri-apps/api/core"
import { goto } from "@roxi/routify"
import HDivider from "@/components/elements/HDivider.svelte"
import Footer from "@/components/Footer.svelte"
import {
Button, Space, Text, Notification, Badge, TextInput, Loader,
} from "@svelteuidev/core"
import { Play, TrashIcon, Plus, CrossCircled } from "radix-icons-svelte"
interface MacroInfo {
name: string
steps_count: number
steps: string[]
created_at: number
last_run: number | null
}
let macros: MacroInfo[] = []
let loading = true
let error = ""
let isRecording = false
let recordingName: string | null = null
let newMacroName = ""
let busyMacro = "" // macro currently being replayed
let recordingPoll: ReturnType<typeof setInterval> | null = null
async function load() {
loading = true
error = ""
try {
macros = await invoke<MacroInfo[]>("macros_list")
macros = [...macros].sort((a, b) => a.name.localeCompare(b.name))
await pollRecording()
} catch (e) {
error = String(e)
} finally {
loading = false
}
}
async function pollRecording() {
try {
isRecording = await invoke<boolean>("macros_is_recording")
recordingName = await invoke<string | null>("macros_recording_name")
} catch (e) { /* ignore */ }
}
async function startRecording() {
const name = newMacroName.trim()
if (!name) { error = "Введите имя макроса"; return }
try {
await invoke("macros_start_recording", { name })
newMacroName = ""
await pollRecording()
} catch (e) { error = String(e) }
}
async function saveRecording() {
try {
const count = await invoke<number>("macros_save_recording")
await load()
error = `Сохранил ${count} шагов`
setTimeout(() => { if (error.startsWith("Сохранил")) error = "" }, 3000)
} catch (e) { error = String(e) }
}
async function cancelRecording() {
try {
await invoke("macros_cancel_recording")
await pollRecording()
} catch (e) { error = String(e) }
}
async function playMacro(name: string) {
busyMacro = name
try {
const count = await invoke<number>("macros_replay", { name })
// 800ms × count + safety
setTimeout(() => { busyMacro = "" }, count * 900 + 500)
} catch (e) {
error = String(e)
busyMacro = ""
}
}
async function deleteMacro(name: string) {
if (!confirm(`Удалить макрос "${name}"?`)) return
try {
await invoke("macros_delete", { name })
await load()
} catch (e) { error = String(e) }
}
onMount(() => {
load()
recordingPoll = setInterval(pollRecording, 2000)
})
onDestroy(() => {
if (recordingPoll !== null) clearInterval(recordingPoll)
})
function fmtDate(ts: number | null): string {
if (!ts) return "—"
return new Date(ts * 1000).toLocaleString()
}
</script>
<Space h="xl" />
<h2 class="page-title">Макросы</h2>
<Text size="sm" color="gray">
Запиши последовательность голосовых команд, потом запускай её одной фразой.
</Text>
<Space h="md" />
{#if isRecording}
<Notification
title="Идёт запись макроса"
icon={Play}
color="orange"
withCloseButton={false}
>
<Text size="sm">
Записывается макрос <strong>{recordingName ?? "—"}</strong>.
Произнесите команды, потом нажмите «Сохранить» или скажите «Сохрани макрос».
</Text>
<Space h="sm" />
<Button color="lime" radius="md" size="xs" uppercase on:click={saveRecording}>
Сохранить
</Button>
&nbsp;
<Button color="gray" radius="md" size="xs" uppercase on:click={cancelRecording}>
Отменить
</Button>
</Notification>
<Space h="md" />
{:else}
<div class="new-macro-row">
<TextInput
placeholder="Имя нового макроса"
variant="filled"
bind:value={newMacroName}
/>
<Button color="lime" radius="md" size="sm" on:click={startRecording}>
<Plus size={14} /> &nbsp; Начать запись
</Button>
</div>
<Space h="md" />
{/if}
{#if error}
<Notification
title="Сообщение"
icon={CrossCircled}
color={error.startsWith("Сохранил") ? "teal" : "red"}
on:close={() => { error = "" }}
>
{error}
</Notification>
<Space h="sm" />
{/if}
{#if loading}
<Loader />
{:else if macros.length === 0}
<Text size="sm" color="gray">
Макросов пока нет. Создайте первый: введите имя, нажмите «Начать запись»,
произнесите команды, нажмите «Сохранить».
</Text>
{:else}
<div class="macro-list">
{#each macros as m}
<div class="macro-card">
<div class="macro-header">
<span class="macro-name">{m.name}</span>
<Badge color="blue" variant="filled" size="sm">
{m.steps_count} шагов
</Badge>
</div>
<div class="macro-steps">
{#each m.steps.slice(0, 5) as step}
<div class="step">{step}</div>
{/each}
{#if m.steps.length > 5}
<div class="step muted">+ ещё {m.steps.length - 5}</div>
{/if}
</div>
<div class="macro-meta">
<small>Создан: {fmtDate(m.created_at)}</small>
<small>Последний запуск: {fmtDate(m.last_run)}</small>
</div>
<div class="macro-actions">
<Button
color="lime" radius="md" size="xs" uppercase
on:click={() => playMacro(m.name)}
disabled={busyMacro === m.name}
>
{#if busyMacro === m.name}
Выполняется…
{:else}
<Play size={14} /> &nbsp; Запустить
{/if}
</Button>
&nbsp;
<Button
color="red" radius="md" size="xs" uppercase
on:click={() => deleteMacro(m.name)}
>
<TrashIcon size={14} /> &nbsp; Удалить
</Button>
</div>
</div>
{/each}
</div>
{/if}
<Space h="xl" />
<Button color="gray" radius="md" size="sm" uppercase fullSize on:click={() => $goto("/")}>
Назад
</Button>
<HDivider />
<Footer />
<style lang="scss">
.page-title {
margin: 0 0 4px 0;
color: #fff;
font-size: 1.3rem;
font-weight: 600;
}
.new-macro-row {
display: flex;
gap: 0.5rem;
align-items: flex-end;
:global(.svelteui-Input-root) {
flex: 1;
}
}
.macro-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.macro-card {
background: rgba(30, 40, 45, 0.75);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.macro-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.macro-name {
font-size: 0.95rem;
font-weight: 600;
color: #fff;
}
.macro-steps {
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
padding: 0.4rem 0.6rem;
margin-bottom: 0.5rem;
.step {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.7);
line-height: 1.4em;
&.muted {
color: rgba(255, 255, 255, 0.4);
font-style: italic;
}
}
}
.macro-meta {
display: flex;
justify-content: space-between;
color: rgba(255, 255, 255, 0.4);
font-size: 0.7rem;
margin-bottom: 0.5rem;
}
.macro-actions {
display: flex;
gap: 0.4rem;
}
</style>

View file

@ -0,0 +1,238 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte"
import { invoke } from "@tauri-apps/api/core"
import { goto } from "@roxi/routify"
import HDivider from "@/components/elements/HDivider.svelte"
import Footer from "@/components/Footer.svelte"
import {
Button, Space, Text, Notification, Badge, Loader,
} from "@svelteuidev/core"
import { Clock, TrashIcon, CrossCircled } from "radix-icons-svelte"
interface ScheduledTaskInfo {
id: string
name: string
schedule_human: string
action_kind: string
action_text: string | null
last_fired: number | null
enabled: boolean
created_at: number
}
let tasks: ScheduledTaskInfo[] = []
let loading = true
let error = ""
let poll: ReturnType<typeof setInterval> | null = null
async function load() {
try {
tasks = await invoke<ScheduledTaskInfo[]>("scheduler_list")
tasks = [...tasks].sort((a, b) => a.name.localeCompare(b.name))
} catch (e) {
error = String(e)
} finally {
loading = false
}
}
async function removeTask(id: string, label: string) {
if (!confirm(`Удалить задачу "${label}"?`)) return
try {
await invoke("scheduler_remove", { id })
await load()
} catch (e) { error = String(e) }
}
async function clearAll() {
if (!confirm("Удалить ВСЕ запланированные задачи?")) return
try {
const count = await invoke<number>("scheduler_clear")
error = `Удалено ${count}`
setTimeout(() => { if (error.startsWith("Удалено")) error = "" }, 2500)
await load()
} catch (e) { error = String(e) }
}
onMount(() => {
load()
poll = setInterval(load, 5000)
})
onDestroy(() => {
if (poll !== null) clearInterval(poll)
})
function fmtDate(ts: number | null): string {
if (!ts) return "—"
return new Date(ts * 1000).toLocaleString()
}
function actionColor(kind: string): string {
switch (kind) {
case "speak": return "cyan"
case "lua": return "violet"
default: return "gray"
}
}
</script>
<Space h="xl" />
<h2 class="page-title">Расписание</h2>
<Text size="sm" color="gray">
Запланированные задачи: напоминания, ежедневные брифинги, привычки.
Тик каждые 30 секунд.
</Text>
<Space h="md" />
{#if error}
<Notification
title="Сообщение"
icon={CrossCircled}
color={error.startsWith("Удалено") ? "teal" : "red"}
on:close={() => { error = "" }}
>
{error}
</Notification>
<Space h="sm" />
{/if}
{#if loading}
<Loader />
{:else if tasks.length === 0}
<Text size="sm" color="gray">
Расписание пустое. Добавь задачу голосом:
«напомни через 5 минут выключить кофеварку», «каждые 2 часа напоминай пить воду»,
«каждый день в 9 утра делай брифинг», «напоминай отдыхать глазам».
</Text>
{:else}
<div class="task-list">
{#each tasks as task}
<div class="task-card">
<div class="task-header">
<div class="task-title">
<span class="task-name">{task.name}</span>
<Badge color={actionColor(task.action_kind)} variant="outline" size="sm">
{task.action_kind}
</Badge>
</div>
<Badge color="orange" variant="filled" size="sm">
{task.schedule_human}
</Badge>
</div>
{#if task.action_text}
<div class="task-body">
{task.action_text}
</div>
{/if}
<div class="task-meta">
<small>ID: <code>{task.id}</code></small>
<small>Создано: {fmtDate(task.created_at)}</small>
<small>Последний запуск: {fmtDate(task.last_fired)}</small>
</div>
<div class="task-actions">
<Button
color="red" radius="md" size="xs" uppercase
on:click={() => removeTask(task.id, task.name)}
>
<TrashIcon size={14} /> &nbsp; Отменить
</Button>
</div>
</div>
{/each}
</div>
<Space h="md" />
<Button color="red" radius="md" size="sm" uppercase fullSize on:click={clearAll}>
Очистить всё ({tasks.length})
</Button>
{/if}
<Space h="xl" />
<Button color="gray" radius="md" size="sm" uppercase fullSize on:click={() => $goto("/")}>
Назад
</Button>
<HDivider />
<Footer />
<style lang="scss">
.page-title {
margin: 0 0 4px 0;
color: #fff;
font-size: 1.3rem;
font-weight: 600;
}
.task-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.task-card {
background: rgba(30, 40, 45, 0.75);
border: 1px solid rgba(255, 168, 60, 0.18);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
gap: 0.5rem;
}
.task-title {
display: flex;
gap: 0.5rem;
align-items: center;
}
.task-name {
font-size: 0.95rem;
font-weight: 600;
color: #fff;
}
.task-body {
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
padding: 0.4rem 0.6rem;
margin-bottom: 0.5rem;
color: rgba(255, 255, 255, 0.85);
font-size: 0.85rem;
line-height: 1.4em;
}
.task-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: rgba(255, 255, 255, 0.4);
font-size: 0.7rem;
margin-bottom: 0.5rem;
code {
background: rgba(255, 255, 255, 0.05);
padding: 1px 5px;
border-radius: 3px;
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.6);
}
}
.task-actions {
display: flex;
gap: 0.4rem;
}
</style>