250 lines
6.7 KiB
Svelte
250 lines
6.7 KiB
Svelte
|
|
<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} /> Добавить
|
|||
|
|
</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} /> Забыть
|
|||
|
|
</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>
|