feat: /history page (recognition log) + multi wake-word loading
# Recognition history page
New /history route in the Tauri GUI. Every voice/text phrase that
reaches the dispatcher gets a row with: timestamp, the phrase, what
happened, command id + confidence (if matched), which matcher fired
(intent / fuzzy / LLM-router / LLM-fallback).
Color coding:
- Green: command matched AND executed successfully
- Red: command matched but failed, OR dispatcher error
- Blue: LLM fallback handled it (Jarvis spoke a free-form reply)
- Orange: no match at all ("Не понял")
Live updates: GUI polls every 2s from the on-disk log, so the page
shows daemon writes in near real time even though they're separate
processes. Filter box for searching by phrase or command id.
Architecture:
- New core module `recognition_log` with ring buffer (cap 500) +
atomic JSON write-through to `<APP_CONFIG_DIR>/recognition_log.json`.
- `record(phrase, source, outcome)` is the single call-site from the
daemon's `execute_command()` — hooked into the 4 outcome paths
(matched-ok, matched-fail, not-found, llm-handled, error).
- `recent(limit)` reads the in-memory buffer (daemon's view).
- `recent_from_disk(limit)` re-reads the JSON file — GUI uses this
since the GUI process has its own buffer that doesn't see the
daemon's writes.
- 5 new unit tests covering ring buffer trimming, outcome serde
roundtrip, missing/corrupt/oversized file recovery.
GUI:
- `crates/jarvis-gui/src/tauri_commands/history.rs`: history_recent,
history_clear. Flattens the Outcome enum into a single struct that's
easier for the Svelte template to render.
- `frontend/src/routes/history/index.svelte`: ~270 lines. Stats badges
(✓ N matched / ✗ N misses / total), filter input, virtual list of
entry cards with color-coded left border. Polls every 2s.
- Header gets a new "История" / "History" button (between Plugins and
Settings). Russian + English locale entries added.
# Multi wake-word loading
Was: `init()` loaded the bundled `jarvis-default.rpw` + at most ONE
custom (from `settings.custom_wake_word`). User had to pick a single
trained model.
Now: loads the bundled default PLUS every .rpw in
`APP_CONFIG_DIR/wake_words/` simultaneously. Rustpotter natively
supports multiple wake-word triggers — each adds robustness for
different voice profiles. The legacy `custom_wake_word` field is
checked for back-compat but is a no-op if it points inside the
already-loaded directory.
User-facing impact: train the wake-word once via /wake-trainer →
restart daemon → detection improves automatically without picking a
single "active" model.
# Settings → "Обучить wake-word" button
Added a purple button on the settings page that links to
/wake-trainer. The trainer existed but had no in-GUI link, so users
couldn't find it without typing the URL. Now sits next to the
"Конструктор команд (Python)" button.
Tests: 140 → 145 rust core tests (+5 recognition_log). Frontend
rebuilds in 6.2s. Release builds of jarvis-app + jarvis-gui green.
Practical test: GUI launches (MainWindowTitle confirmed), seed log
file written + visible to the page.
This commit is contained in:
parent
965441d4db
commit
73fc404ec7
13 changed files with 867 additions and 63 deletions
|
|
@ -186,6 +186,10 @@
|
|||
<span class="btn-text">{t('header-plugins') || 'Плагины'}</span>
|
||||
</button>
|
||||
|
||||
<button class="header-btn" on:click={() => $goto('/history')} title="Recognition history">
|
||||
<span class="btn-text">{t('header-history') || 'История'}</span>
|
||||
</button>
|
||||
|
||||
<button class="header-btn" on:click={() => $goto('/settings')}>
|
||||
<span class="btn-text">{t('header-settings')}</span>
|
||||
</button>
|
||||
|
|
|
|||
313
frontend/src/routes/history/index.svelte
Normal file
313
frontend/src/routes/history/index.svelte
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
<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, TextInput,
|
||||
} from "@svelteuidev/core"
|
||||
import { CrossCircled, Reload, Trash, MagnifyingGlass } from "radix-icons-svelte"
|
||||
|
||||
interface HistoryEntry {
|
||||
ts: number
|
||||
phrase: string
|
||||
source: string
|
||||
kind: "matched" | "not_found" | "llm_handled" | "error"
|
||||
command_id: string | null
|
||||
confidence_pct: number | null
|
||||
via: string | null
|
||||
success: boolean | null
|
||||
error_message: string | null
|
||||
}
|
||||
|
||||
let entries: HistoryEntry[] = []
|
||||
let loading = true
|
||||
let error = ""
|
||||
let filter = ""
|
||||
|
||||
// Poll the disk-backed log every 2s so daemon writes show up live.
|
||||
// Backed by `history_recent` which re-reads recognition_log.json each
|
||||
// call — see crates/jarvis-gui/src/tauri_commands/history.rs.
|
||||
let pollHandle: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
entries = await invoke<HistoryEntry[]>("history_recent", { limit: 200 })
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
if (!confirm("Очистить всю историю распознаваний?")) return
|
||||
try {
|
||||
await invoke<number>("history_clear")
|
||||
await load()
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load()
|
||||
pollHandle = setInterval(load, 2000)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollHandle !== null) clearInterval(pollHandle)
|
||||
})
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
const d = new Date(ts * 1000)
|
||||
const now = new Date()
|
||||
const sameDay = d.toDateString() === now.toDateString()
|
||||
if (sameDay) {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
return d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function kindColor(e: HistoryEntry): "lime" | "red" | "orange" | "blue" {
|
||||
if (e.kind === "matched") return e.success ? "lime" : "red"
|
||||
if (e.kind === "llm_handled") return "blue"
|
||||
if (e.kind === "error") return "red"
|
||||
return "orange" // not_found
|
||||
}
|
||||
|
||||
function kindLabel(e: HistoryEntry): string {
|
||||
switch (e.kind) {
|
||||
case "matched": return e.success ? "Выполнено" : "Ошибка"
|
||||
case "not_found": return "Не понял"
|
||||
case "llm_handled": return "LLM ответил"
|
||||
case "error": return "Сбой"
|
||||
}
|
||||
}
|
||||
|
||||
function viaLabel(via: string | null): string {
|
||||
if (!via) return ""
|
||||
const map: Record<string, string> = {
|
||||
intent: "intent",
|
||||
fuzzy: "fuzzy",
|
||||
router: "LLM-router",
|
||||
llm: "LLM",
|
||||
}
|
||||
return map[via] || via
|
||||
}
|
||||
|
||||
$: filtered = filter.trim()
|
||||
? entries.filter(e =>
|
||||
e.phrase.toLowerCase().includes(filter.toLowerCase())
|
||||
|| (e.command_id || "").toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
: entries
|
||||
|
||||
$: matched = entries.filter(e => e.kind === "matched" && e.success).length
|
||||
$: misses = entries.filter(e => e.kind === "not_found" || e.kind === "error" || (e.kind === "matched" && !e.success)).length
|
||||
</script>
|
||||
|
||||
<Space h="xl" />
|
||||
|
||||
<h2 class="page-title">История распознаваний</h2>
|
||||
<Text size="sm" color="gray">
|
||||
Каждая фраза, которую Jarvis услышал и обработал. Зелёный — команда
|
||||
нашлась и выполнилась, оранжевый — не понял, синий — обработал через LLM,
|
||||
красный — была ошибка. Полезно понимать, что именно надо переформулировать
|
||||
или дотренировать.
|
||||
</Text>
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
<div class="stats-row">
|
||||
<Badge color="lime" size="md" variant="filled">✓ {matched}</Badge>
|
||||
|
||||
<Badge color="orange" size="md" variant="filled">✗ {misses}</Badge>
|
||||
|
||||
<Badge color="gray" size="md" variant="light">всего {entries.length}</Badge>
|
||||
</div>
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
<div class="actions-row">
|
||||
<TextInput
|
||||
placeholder="Фильтр по фразе или команде…"
|
||||
variant="filled"
|
||||
bind:value={filter}
|
||||
icon={MagnifyingGlass}
|
||||
/>
|
||||
|
||||
<Button color="gray" radius="md" size="sm" on:click={load}>
|
||||
<Reload size={14} /> Обновить
|
||||
</Button>
|
||||
|
||||
<Button color="red" radius="md" size="sm" variant="outline" on:click={clearAll}>
|
||||
<Trash size={14} /> Очистить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
{#if error}
|
||||
<Notification
|
||||
title="Ошибка"
|
||||
icon={CrossCircled}
|
||||
color="red"
|
||||
on:close={() => { error = "" }}
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
<Space h="sm" />
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<Loader />
|
||||
{:else if filtered.length === 0}
|
||||
<Text size="sm" color="gray">
|
||||
{entries.length === 0
|
||||
? "История пуста. Скажи что-нибудь — появится здесь."
|
||||
: "Под фильтр ничего не подходит."}
|
||||
</Text>
|
||||
{:else}
|
||||
<div class="entry-list">
|
||||
{#each filtered as e}
|
||||
<div class="entry-card kind-{e.kind}">
|
||||
<div class="entry-header">
|
||||
<span class="entry-phrase">«{e.phrase}»</span>
|
||||
<Badge color={kindColor(e)} variant="filled" size="sm">
|
||||
{kindLabel(e)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="entry-meta">
|
||||
<small>⏱ {fmtTime(e.ts)}</small>
|
||||
{#if e.command_id}
|
||||
<small>→ <code>{e.command_id}</code></small>
|
||||
{/if}
|
||||
{#if e.via}
|
||||
<small>через {viaLabel(e.via)}</small>
|
||||
{/if}
|
||||
{#if e.confidence_pct !== null}
|
||||
<small>{e.confidence_pct}% увер.</small>
|
||||
{/if}
|
||||
<small class="source-tag">{e.source}</small>
|
||||
</div>
|
||||
|
||||
{#if e.error_message}
|
||||
<div class="entry-error">{e.error_message}</div>
|
||||
{/if}
|
||||
</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;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
:global(.svelteui-Input-root) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.entry-card {
|
||||
background: rgba(30, 40, 45, 0.75);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-left: 4px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.85rem;
|
||||
|
||||
&.kind-matched {
|
||||
border-left-color: rgba(132, 204, 22, 0.7);
|
||||
}
|
||||
|
||||
&.kind-not_found {
|
||||
border-left-color: rgba(249, 115, 22, 0.7);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&.kind-llm_handled {
|
||||
border-left-color: rgba(59, 130, 246, 0.7);
|
||||
}
|
||||
|
||||
&.kind-error {
|
||||
border-left-color: rgba(239, 68, 68, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
.entry-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.entry-phrase {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.entry-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 0.72rem;
|
||||
|
||||
code {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.source-tag {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.entry-error {
|
||||
color: rgba(255, 120, 120, 0.95);
|
||||
background: rgba(120, 30, 30, 0.2);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -715,6 +715,19 @@
|
|||
|
||||
<Space h="sm" />
|
||||
|
||||
<Button
|
||||
color="grape"
|
||||
radius="md"
|
||||
size="sm"
|
||||
uppercase
|
||||
fullSize
|
||||
on:click={() => $goto("/wake-trainer")}
|
||||
>
|
||||
Обучить wake-word (записать свой голос)
|
||||
</Button>
|
||||
|
||||
<Space h="sm" />
|
||||
|
||||
<Button
|
||||
color="blue"
|
||||
radius="md"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue