feat: GUI /memory page + disk/date_math/sleep_timer packs (10 new commands)

GUI: Memory management completes the management trio (/macros + /scheduler + /memory).

Tauri commands (crates/jarvis-gui/src/tauri_commands/memory.rs)
  - memory_list             → Vec<MemoryFact{key, value, created_at, last_used_at, use_count}>
  - memory_remember(k, v)   → persist new fact (or overwrite)
  - memory_forget(key)      → delete by exact key
  - memory_search(q, limit) → substring search

GUI /memory page (frontend/src/routes/memory/index.svelte)
  - Top: add-fact form (key + value inputs + "+Добавить" button)
  - Substring filter input for the list
  - Per-card: key + use_count badge + value (highlighted box) + timestamps +
    "Забыть" button with confirm() guard
  - Auto-sorted by recency (last_used_at desc)
  - Empty state shows voice-hint: 'скажи Jarvis-у "запомни что я люблю чай улун"'

Header (frontend/src/components/Header.svelte)
  - New "Память" button between /scheduler and /settings.
  - i18n: header-memory in ru/en/ua FTL files.

New voice packs

resources/commands/disk/ (2 cmds)
  - disk.free      "сколько свободно на диске" / "сколько места на диске C"
                   PowerShell Get-PSDrive → speaks "Свободно X ГБ из Y, это N%"
  - disk.list      "какие у меня диски" → "C 120 ГБ, D 300 ГБ, E 50 ГБ"

resources/commands/date_math/ (2 cmds)
  - date.days_until    "сколько дней до нового года" / "сколько до 8 марта" /
                       "сколько до 15 марта" — recognises Russian months,
                       holidays (Новый год, Рождество, 8 марта, 9 мая).
                       Auto-rolls to next year if target already passed.
                       Russian-grammar pluralisation (день/дня/дней).
  - date.day_of_week   "какой сегодня день недели" — Zeller's congruence,
                       maps to ru day name.

resources/commands/sleep_timer/ (3 cmds)
  - sleep_timer.pause_in    "выключи музыку через 30 минут"
                            → scheduler one-shot lua action that fires media
                            VK_MEDIA_PLAY_PAUSE via the existing media_keys helper.
                            (Auto-generates _fire_pause.lua wrapper if missing.)
  - sleep_timer.shutdown_in "выключи компьютер через 1 час"
                            → shutdown.exe /s /t <secs>. Caps at 24 hours.
                            Speaks "Скажите 'отмени таймер' чтобы передумать."
  - sleep_timer.cancel      "отмени таймер выключения" / "не выключай компьютер"
                            → shutdown /a + scheduler.remove("sleep_timer_pause"),
                            idempotent.

Pack count: 64 → 67. Tests: 112/112 pass.
Build: cargo build --release -p jarvis-gui green.
This commit is contained in:
Bossiara13 2026-05-15 23:35:23 +03:00
parent 84d3b57ddc
commit d3180b7d78
18 changed files with 717 additions and 4 deletions

View file

@ -80,6 +80,10 @@
<span class="btn-text">{t('header-scheduler') || 'Расписание'}</span>
</button>
<button class="header-btn" on:click={() => $goto('/memory')} title="Long-term memory">
<span class="btn-text">{t('header-memory') || 'Память'}</span>
</button>
<button class="header-btn" on:click={() => $goto('/settings')}>
<span class="btn-text">{t('header-settings')}</span>
</button>

View file

@ -0,0 +1,249 @@
<script lang="ts">
import { onMount } 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, Textarea,
} from "@svelteuidev/core"
import { Plus, TrashIcon, CrossCircled, MagnifyingGlass } from "radix-icons-svelte"
interface MemoryFact {
key: string
value: string
created_at: number
last_used_at: number
use_count: number
}
let facts: MemoryFact[] = []
let loading = true
let error = ""
let filter = ""
// new-fact form
let newKey = ""
let newValue = ""
async function load() {
loading = true
error = ""
try {
facts = await invoke<MemoryFact[]>("memory_list")
facts = [...facts].sort((a, b) => b.last_used_at - a.last_used_at)
} catch (e) {
error = String(e)
} finally {
loading = false
}
}
async function addFact() {
const k = newKey.trim()
const v = newValue.trim()
if (!k) { error = "Ключ не может быть пустым"; return }
if (!v) { error = "Значение не может быть пустым"; return }
try {
await invoke("memory_remember", { key: k, value: v })
newKey = ""
newValue = ""
await load()
} catch (e) { error = String(e) }
}
async function deleteFact(key: string) {
if (!confirm(`Забыть факт "${key}"?`)) return
try {
await invoke("memory_forget", { key })
await load()
} catch (e) { error = String(e) }
}
onMount(load)
$: visible = filter
? facts.filter(f =>
f.key.toLowerCase().includes(filter.toLowerCase()) ||
f.value.toLowerCase().includes(filter.toLowerCase())
)
: facts
function fmtDate(ts: number): string {
if (!ts) return "—"
return new Date(ts * 1000).toLocaleString()
}
</script>
<Space h="xl" />
<h2 class="page-title">Память</h2>
<Text size="sm" color="gray">
Долговременные факты о пользователе. LLM получает их в системном промпте автоматически,
когда фразой пересекаются. Хранятся в <code>long_term_memory.json</code>.
</Text>
<Space h="md" />
<div class="new-fact">
<div class="fact-row">
<TextInput placeholder="Ключ (напр. 'любимый чай')" variant="filled" bind:value={newKey} />
<TextInput placeholder="Значение (напр. 'улун')" variant="filled" bind:value={newValue} />
<Button color="lime" radius="md" size="sm" on:click={addFact}>
<Plus size={14} /> &nbsp; Добавить
</Button>
</div>
</div>
<Space h="md" />
<TextInput
placeholder="Фильтр по ключу или значению"
variant="filled"
icon={MagnifyingGlass}
bind:value={filter}
/>
<Space h="sm" />
{#if error}
<Notification
title="Ошибка"
icon={CrossCircled}
color="red"
on:close={() => { error = "" }}
>
{error}
</Notification>
<Space h="sm" />
{/if}
{#if loading}
<Loader />
{:else if facts.length === 0}
<Text size="sm" color="gray">
Память пустая. Добавь факт сверху или скажи Jarvis-у "запомни что я люблю чай улун".
</Text>
{:else if visible.length === 0}
<Text size="sm" color="gray">
По фильтру ничего не найдено. Всего фактов: {facts.length}.
</Text>
{:else}
<div class="fact-list">
{#each visible as fact}
<div class="fact-card">
<div class="fact-header">
<span class="fact-key">{fact.key}</span>
<div class="fact-meta-inline">
<Badge color="cyan" variant="outline" size="sm">
{fact.use_count}× использований
</Badge>
</div>
</div>
<div class="fact-value">
{fact.value}
</div>
<div class="fact-meta">
<small>Создан: {fmtDate(fact.created_at)}</small>
<small>Последний доступ: {fmtDate(fact.last_used_at)}</small>
</div>
<div class="fact-actions">
<Button
color="red" radius="md" size="xs" uppercase
on:click={() => deleteFact(fact.key)}
>
<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-fact {
background: rgba(30, 40, 45, 0.55);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 10px;
padding: 0.75rem;
.fact-row {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: 0.5rem;
align-items: end;
}
}
.fact-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.fact-card {
background: rgba(30, 40, 45, 0.75);
border: 1px solid rgba(82, 254, 254, 0.15);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.fact-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.4rem;
}
.fact-key {
font-size: 0.95rem;
font-weight: 600;
color: #fff;
}
.fact-meta-inline {
display: flex;
gap: 0.4rem;
}
.fact-value {
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
padding: 0.5rem 0.7rem;
color: rgba(255, 255, 255, 0.9);
font-size: 0.9rem;
line-height: 1.45em;
margin-bottom: 0.5rem;
}
.fact-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: rgba(255, 255, 255, 0.4);
font-size: 0.7rem;
margin-bottom: 0.5rem;
}
.fact-actions {
display: flex;
gap: 0.4rem;
}
</style>