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:
parent
c7ab751e39
commit
c22a24ccd8
11 changed files with 998 additions and 167 deletions
306
frontend/src/routes/macros/index.svelte
Normal file
306
frontend/src/routes/macros/index.svelte
Normal 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>
|
||||
|
||||
<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} /> Начать запись
|
||||
</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} /> Запустить
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="red" radius="md" size="xs" uppercase
|
||||
on:click={() => deleteMacro(m.name)}
|
||||
>
|
||||
<TrashIcon size={14} /> Удалить
|
||||
</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>
|
||||
238
frontend/src/routes/scheduler/index.svelte
Normal file
238
frontend/src/routes/scheduler/index.svelte
Normal 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} /> Отменить
|
||||
</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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue