mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(memory_tree): cloud-default LLM, queue priority, entity filter, Memory tab UI (#1198)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
f9f2360341
commit
62516ba418
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Backend } from '../../lib/intelligence/settingsApi';
|
||||
|
||||
interface BackendChooserProps {
|
||||
/** Currently selected backend. */
|
||||
value: Backend;
|
||||
/** Called when the user clicks a different card. */
|
||||
onChange: (next: Backend) => void;
|
||||
/** Optional cloud-cost estimate. Mock value until cost-tracker hook lands. */
|
||||
costEstimate?: string;
|
||||
/** Disabled while a backend switch is in flight. */
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two large cards — Cloud (default, recommended) vs Local (advanced).
|
||||
*
|
||||
* Visual style intentionally matches the rest of the Intelligence page:
|
||||
* `bg-white` + `border-stone-200` + `rounded-2xl`, primary blue for the
|
||||
* selected accent. The inline tokens from the brief
|
||||
* (paper, hairline, ocean) map onto the existing stone/primary scale —
|
||||
* we keep the existing scale to avoid forking the design system.
|
||||
*/
|
||||
export default function BackendChooser({
|
||||
value,
|
||||
onChange,
|
||||
costEstimate = '$0.42 / mo est.',
|
||||
busy = false,
|
||||
}: BackendChooserProps) {
|
||||
const [hoveredCloud, setHoveredCloud] = useState(false);
|
||||
|
||||
const cardBase =
|
||||
'flex-1 min-h-[160px] px-6 py-5 rounded-2xl text-left transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 flex-col sm:flex-row" role="radiogroup" aria-label="AI backend">
|
||||
{/* Cloud */}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === 'cloud'}
|
||||
disabled={busy}
|
||||
onClick={() => onChange('cloud')}
|
||||
onMouseEnter={() => setHoveredCloud(true)}
|
||||
onMouseLeave={() => setHoveredCloud(false)}
|
||||
onFocus={() => setHoveredCloud(true)}
|
||||
onBlur={() => setHoveredCloud(false)}
|
||||
className={`${cardBase} border-2 ${
|
||||
value === 'cloud'
|
||||
? 'border-primary-500 bg-white shadow-soft'
|
||||
: 'border-stone-200 bg-stone-50 hover:bg-white hover:border-stone-300'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioDot active={value === 'cloud'} />
|
||||
<span className="text-sm font-semibold text-stone-900">Cloud</span>
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded-full bg-primary-50 text-primary-700 border border-primary-100">
|
||||
Recommended
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-stone-600 leading-relaxed mb-3">
|
||||
Runs on OpenHuman servers. Costs credits. No local CPU.
|
||||
</p>
|
||||
<div className="font-mono text-[11px] text-stone-500">{costEstimate}</div>
|
||||
{/* Privacy reassurance — appears on hover/focus of the Cloud card. */}
|
||||
<div
|
||||
className={`mt-3 text-[11px] text-stone-500 leading-snug transition-opacity ${
|
||||
hoveredCloud ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
aria-live="polite">
|
||||
Your data still stays local. bge-m3 embedder runs on your machine regardless.
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Local */}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === 'local'}
|
||||
disabled={busy}
|
||||
onClick={() => onChange('local')}
|
||||
className={`${cardBase} border-2 ${
|
||||
value === 'local'
|
||||
? 'border-primary-500 bg-white shadow-soft'
|
||||
: 'border-stone-200 bg-stone-50 hover:bg-white hover:border-stone-300'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioDot active={value === 'local'} />
|
||||
<span className="text-sm font-semibold text-stone-900">Local</span>
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded-full bg-stone-100 text-stone-600 border border-stone-200">
|
||||
Advanced
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-stone-600 leading-relaxed mb-3">
|
||||
Runs on your machine. Free. Uses your CPU and battery.
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-amber-700">
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m0 3.75h.008v.008H12v-.008zM21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>≥8 GB RAM recommended</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioDot({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center ${
|
||||
active ? 'border-primary-500' : 'border-stone-300'
|
||||
}`}>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${active ? 'bg-primary-500' : 'bg-transparent'}`}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
type Backend,
|
||||
capabilityForModel,
|
||||
DEFAULT_EXTRACT_MODEL,
|
||||
downloadAsset,
|
||||
fetchInstalledModels,
|
||||
getMemoryTreeLlm,
|
||||
type ModelDescriptor,
|
||||
REQUIRED_EMBEDDER_MODEL,
|
||||
setMemoryTreeLlm,
|
||||
} from '../../lib/intelligence/settingsApi';
|
||||
import BackendChooser from './BackendChooser';
|
||||
import ModelAssignment from './ModelAssignment';
|
||||
import ModelCatalog from './ModelCatalog';
|
||||
|
||||
/**
|
||||
* Settings tab for the Intelligence page.
|
||||
*
|
||||
* Layout (top → bottom):
|
||||
* 1. AI Backend — Cloud / Local toggle
|
||||
* 2. Model Assignment — per-role dropdowns (visible only in Local mode)
|
||||
* 3. Model Catalog — full curated list with download / use / delete
|
||||
* 4. Currently Loaded — live `/api/ps`-style readout
|
||||
*
|
||||
* The orchestrator owns the cross-section state (backend, role assignments,
|
||||
* cached installed-models / status). Sections themselves stay presentational.
|
||||
*/
|
||||
export default function IntelligenceSettingsTab() {
|
||||
const [backend, setBackend] = useState<Backend>('cloud');
|
||||
const [backendBusy, setBackendBusy] = useState(false);
|
||||
// Single Memory LLM that drives both extractor and summariser. Most
|
||||
// users want one model for both; the rare case of mixing them is not
|
||||
// worth the second dropdown's cognitive cost.
|
||||
const [memoryModel, setMemoryModel] = useState<string>(DEFAULT_EXTRACT_MODEL);
|
||||
const [installedModels, setInstalledModels] = useState<string[]>([]);
|
||||
|
||||
// One-shot bootstrap — pull current backend and the installed-model list.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
console.debug('[intelligence-settings] bootstrap');
|
||||
const [bk, models] = await Promise.all([getMemoryTreeLlm(), fetchInstalledModels()]);
|
||||
if (cancelled) return;
|
||||
setBackend(bk);
|
||||
setInstalledModels(models.map(m => m.name));
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
// Bootstrap failure leaves the tab on its useState defaults
|
||||
// (cloud backend, empty installed list) rather than throwing
|
||||
// an unhandled rejection. The user can still flip the backend
|
||||
// chooser; subsequent reads will retry the RPCs.
|
||||
console.error('[intelligence-settings] bootstrap failed', err);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleBackendChange = useCallback(async (next: Backend) => {
|
||||
setBackendBusy(true);
|
||||
try {
|
||||
const { effective } = await setMemoryTreeLlm(next);
|
||||
setBackend(effective);
|
||||
} catch (err) {
|
||||
console.error('[intelligence-settings] backend switch failed', err);
|
||||
} finally {
|
||||
setBackendBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Persist Memory LLM changes to config.toml. Fans out to both
|
||||
// extractor and summariser keys in a single atomic write — the unified
|
||||
// UI is one dropdown, but the underlying schema retains both keys so
|
||||
// power users can still split them via the RPC directly if needed.
|
||||
const handleMemoryModelChange = useCallback(
|
||||
async (id: string) => {
|
||||
console.debug('[intelligence-settings] memory model -> %s', id);
|
||||
const previous = memoryModel;
|
||||
setMemoryModel(id);
|
||||
try {
|
||||
await setMemoryTreeLlm('local', { extractModel: id, summariserModel: id });
|
||||
} catch (err) {
|
||||
// Persistence failed → roll back the optimistic UI update so the
|
||||
// dropdown reflects the value that's actually saved on disk
|
||||
// rather than the one the user just attempted.
|
||||
setMemoryModel(previous);
|
||||
console.error('[intelligence-settings] persist memory model failed', err);
|
||||
}
|
||||
},
|
||||
[memoryModel]
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(async (model: ModelDescriptor) => {
|
||||
const cap = capabilityForModel(model);
|
||||
if (!cap) {
|
||||
console.debug('[intelligence-settings] no capability for model', { id: model.id });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await downloadAsset(cap);
|
||||
} catch (err) {
|
||||
console.error('[intelligence-settings] model download failed', err);
|
||||
} finally {
|
||||
// Refresh installed list after any download attempt — even on
|
||||
// failure, Ollama may have partially landed assets we should
|
||||
// surface; if it hasn't, the next bootstrap tick will catch up.
|
||||
const refreshed = await fetchInstalledModels();
|
||||
setInstalledModels(refreshed.map(m => m.name));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUse = useCallback(
|
||||
(model: ModelDescriptor) => {
|
||||
if (model.roles.includes('extract') || model.roles.includes('summariser')) {
|
||||
void handleMemoryModelChange(model.id);
|
||||
}
|
||||
},
|
||||
[handleMemoryModelChange]
|
||||
);
|
||||
|
||||
const activeModelIds = useMemo<string[]>(() => {
|
||||
const ids = new Set<string>();
|
||||
ids.add(memoryModel);
|
||||
ids.add(REQUIRED_EMBEDDER_MODEL);
|
||||
return [...ids];
|
||||
}, [memoryModel]);
|
||||
|
||||
return (
|
||||
<div className="space-y-10" data-testid="intelligence-settings-tab">
|
||||
<Section title="AI backend">
|
||||
<BackendChooser value={backend} onChange={handleBackendChange} busy={backendBusy} />
|
||||
</Section>
|
||||
|
||||
{/* All local-model sections (assignment, catalog, currently-loaded)
|
||||
are gated on local backend. Cloud users get just the backend
|
||||
chooser + the explanatory copy that lives inside it — they don't
|
||||
need to see Ollama-related UI at all. */}
|
||||
{backend === 'local' && (
|
||||
<>
|
||||
<Section title="Model assignment">
|
||||
<ModelAssignment
|
||||
installedModelIds={installedModels}
|
||||
memoryModel={memoryModel}
|
||||
onChangeMemory={handleMemoryModelChange}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Model catalog">
|
||||
<ModelCatalog
|
||||
installedModelIds={installedModels}
|
||||
activeModelIds={activeModelIds}
|
||||
onDownload={handleDownload}
|
||||
onUse={handleUse}
|
||||
/>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Section({ title, children }: SectionProps) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="font-display text-[11px] uppercase tracking-[0.18em] text-stone-400 mb-3">
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Right pane — single-chunk detail rendered as correspondence (a letter):
|
||||
*
|
||||
* 1. Letterhead (from / to / date)
|
||||
* 2. Subject + body (markdown-ish prose; entities highlighted)
|
||||
* 3. Mentioned (entity index for the chunk)
|
||||
* 4. Why kept (signal breakdown + threshold)
|
||||
* 5. Footer (source_ref, chunk id, embedder info)
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
type Chunk,
|
||||
type EntityRef,
|
||||
memoryTreeChunkScore,
|
||||
memoryTreeEntityIndexFor,
|
||||
type ScoreBreakdown,
|
||||
} from '../../utils/tauriCommands';
|
||||
import { MemoryChunkLetterhead } from './MemoryChunkLetterhead';
|
||||
import { MemoryChunkMentioned } from './MemoryChunkMentioned';
|
||||
import { MemoryChunkScoreBars } from './MemoryChunkScoreBars';
|
||||
import { MemoryTextWithEntities } from './MemoryTextWithEntities';
|
||||
|
||||
interface MemoryChunkDetailProps {
|
||||
chunk: Chunk;
|
||||
onSelectEntity: (entity: EntityRef) => void;
|
||||
}
|
||||
|
||||
function deriveSubject(chunk: Chunk): string {
|
||||
const preview = (chunk.content_preview ?? '').trim();
|
||||
if (!preview) return chunk.id;
|
||||
const firstLine = preview.split(/[.!?\n]/)[0]?.trim() ?? '';
|
||||
if (firstLine.length === 0) return chunk.id;
|
||||
return firstLine.length > 200 ? `${firstLine.slice(0, 197)}…` : firstLine;
|
||||
}
|
||||
|
||||
function deriveBody(chunk: Chunk): string {
|
||||
const preview = (chunk.content_preview ?? '').trim();
|
||||
if (!preview) return '';
|
||||
// Drop the subject (first sentence/line) when there's more content after it.
|
||||
const firstBreak = preview.search(/[.!?\n](?:\s|$)/);
|
||||
if (firstBreak > 0 && preview.length > firstBreak + 2) {
|
||||
return preview.slice(firstBreak + 1).trim();
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
function shortChunkId(id: string): string {
|
||||
// Trim "chunk-" prefix if present, then take 8 chars.
|
||||
const stripped = id.startsWith('chunk-') ? id.slice('chunk-'.length) : id;
|
||||
return stripped.slice(0, 8);
|
||||
}
|
||||
|
||||
export function MemoryChunkDetail({ chunk, onSelectEntity }: MemoryChunkDetailProps) {
|
||||
const [entities, setEntities] = useState<EntityRef[]>([]);
|
||||
const [breakdown, setBreakdown] = useState<ScoreBreakdown | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
console.debug('[ui-flow][memory-workspace] loading detail for chunk', chunk.id);
|
||||
void Promise.all([memoryTreeEntityIndexFor(chunk.id), memoryTreeChunkScore(chunk.id)]).then(
|
||||
([ents, score]) => {
|
||||
if (cancelled) return;
|
||||
setEntities(ents);
|
||||
setBreakdown(score);
|
||||
console.debug(
|
||||
'[ui-flow][memory-workspace] detail loaded',
|
||||
chunk.id,
|
||||
'entities=',
|
||||
ents.length,
|
||||
'score_total=',
|
||||
score?.total ?? null
|
||||
);
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chunk.id]);
|
||||
|
||||
const handleCopyId = async () => {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(chunk.id);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
} catch (err) {
|
||||
console.warn('[ui-flow][memory-workspace] copy chunk id failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
const subject = deriveSubject(chunk);
|
||||
const body = deriveBody(chunk);
|
||||
|
||||
return (
|
||||
<article className="mw-pane-detail" data-testid="memory-chunk-detail">
|
||||
<div className="mw-pane-scroll mw-detail-scroll">
|
||||
<div className="mw-letter">
|
||||
<MemoryChunkLetterhead chunk={chunk} />
|
||||
|
||||
<hr className="mw-rule" />
|
||||
|
||||
<h1 className="mw-letter-subject">{subject}</h1>
|
||||
{body && (
|
||||
<div className="mw-letter-body">
|
||||
<MemoryTextWithEntities text={body} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entities.length > 0 && <hr className="mw-rule" />}
|
||||
|
||||
<MemoryChunkMentioned entities={entities} onSelectEntity={onSelectEntity} />
|
||||
|
||||
{breakdown && <hr className="mw-rule" />}
|
||||
|
||||
{breakdown && <MemoryChunkScoreBars breakdown={breakdown} />}
|
||||
|
||||
<footer className="mw-letter-footer">
|
||||
{chunk.source_ref && <span>{chunk.source_ref}</span>}
|
||||
<span>·</span>
|
||||
<button type="button" onClick={() => void handleCopyId()} title="Copy chunk id">
|
||||
chunk {shortChunkId(chunk.id)}
|
||||
{copied && <span style={{ marginLeft: 6, color: 'var(--sage)' }}>copied</span>}
|
||||
</button>
|
||||
<span>·</span>
|
||||
<span>{chunk.has_embedding ? 'bge-m3 1024dim' : 'no embedding'}</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Letterhead: the from / to / date frontmatter of a chunk, rendered
|
||||
* as correspondence (dl-style with monospace labels in a fixed column).
|
||||
*/
|
||||
import type { Chunk } from '../../utils/tauriCommands';
|
||||
|
||||
interface LetterheadParts {
|
||||
fromName: string;
|
||||
fromAddress?: string;
|
||||
toAddress: string;
|
||||
}
|
||||
|
||||
function parseSourceParts(chunk: Chunk): LetterheadParts {
|
||||
const left = chunk.source_id.split('|');
|
||||
const senderRaw = left[0];
|
||||
const recipient = left[1] ?? chunk.owner;
|
||||
const afterColon = senderRaw.includes(':') ? senderRaw.split(':').slice(1).join(':') : senderRaw;
|
||||
|
||||
// Heuristic for known prefixes: prefer the human-readable display when we have one,
|
||||
// else fall back to the raw email/handle.
|
||||
const isEmailish = /@/.test(afterColon);
|
||||
// Try to recover a personalized name from the chunk's tags (first person/* tag)
|
||||
const personTag = chunk.tags.find(t => t.startsWith('person/'));
|
||||
const personName = personTag ? personTag.slice('person/'.length).replace(/-/g, ' ') : null;
|
||||
|
||||
if (isEmailish && personName) {
|
||||
return { fromName: personName, fromAddress: afterColon, toAddress: recipient };
|
||||
}
|
||||
if (isEmailish) {
|
||||
return { fromName: afterColon, toAddress: recipient };
|
||||
}
|
||||
if (personName) {
|
||||
return { fromName: personName, fromAddress: afterColon, toAddress: recipient };
|
||||
}
|
||||
return { fromName: afterColon || chunk.source_kind, toAddress: recipient };
|
||||
}
|
||||
|
||||
function formatLetterDate(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const yyyy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getUTCDate()).padStart(2, '0');
|
||||
const hh = String(d.getUTCHours()).padStart(2, '0');
|
||||
const mi = String(d.getUTCMinutes()).padStart(2, '0');
|
||||
return `${yyyy}·${mm}·${dd} · ${hh}:${mi} utc`;
|
||||
}
|
||||
|
||||
export function MemoryChunkLetterhead({ chunk }: { chunk: Chunk }) {
|
||||
const parts = parseSourceParts(chunk);
|
||||
return (
|
||||
<header className="mw-letterhead" data-testid="memory-chunk-letterhead">
|
||||
<dl style={{ margin: 0 }}>
|
||||
<div className="mw-letterhead-row">
|
||||
<dt className="mw-letterhead-label">from</dt>
|
||||
<dd className="mw-letterhead-value" style={{ margin: 0 }}>
|
||||
{parts.fromName}
|
||||
{parts.fromAddress && parts.fromAddress !== parts.fromName && (
|
||||
<span className="mw-letterhead-value-secondary">{parts.fromAddress}</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="mw-letterhead-row">
|
||||
<dt className="mw-letterhead-label">to</dt>
|
||||
<dd className="mw-letterhead-value" style={{ margin: 0 }}>
|
||||
{parts.toAddress}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="mw-letterhead-date">{formatLetterDate(chunk.timestamp_ms)}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* "Mentioned" entity list — the marginalia of a chunk's letter view.
|
||||
*
|
||||
* Each row is `[kind label mono] [surface] [chunk count]`. Clicking a row
|
||||
* activates the corresponding entity in the Navigator, filtering the
|
||||
* result list to chunks tagged with that entity.
|
||||
*/
|
||||
import type { EntityRef } from '../../utils/tauriCommands';
|
||||
|
||||
interface MemoryChunkMentionedProps {
|
||||
entities: EntityRef[];
|
||||
onSelectEntity: (entity: EntityRef) => void;
|
||||
}
|
||||
|
||||
export function MemoryChunkMentioned({ entities, onSelectEntity }: MemoryChunkMentionedProps) {
|
||||
if (entities.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section data-testid="memory-chunk-mentioned">
|
||||
<h3 className="mw-mentioned-heading">m e n t i o n e d</h3>
|
||||
<div className="mw-mentioned-table">
|
||||
{entities.map(ent => (
|
||||
<button
|
||||
type="button"
|
||||
key={ent.entity_id}
|
||||
className="mw-mentioned-row"
|
||||
onClick={() => onSelectEntity(ent)}>
|
||||
<span className="mw-mentioned-kind">{ent.kind}</span>
|
||||
<span className="mw-mentioned-surface">{ent.surface}</span>
|
||||
<span className="mw-mentioned-count">
|
||||
{ent.count} chunk{ent.count === 1 ? '' : 's'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* "Why kept" score bars — SVG-rendered (not CSS divs) for crisp pixel
|
||||
* alignment regardless of zoom or DPR.
|
||||
*/
|
||||
import type { ScoreBreakdown } from '../../utils/tauriCommands';
|
||||
|
||||
interface MemoryChunkScoreBarsProps {
|
||||
breakdown: ScoreBreakdown;
|
||||
}
|
||||
|
||||
const TRACK_WIDTH = 200;
|
||||
const TRACK_HEIGHT = 8;
|
||||
|
||||
function clamp01(v: number): number {
|
||||
if (Number.isNaN(v)) return 0;
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
export function MemoryChunkScoreBars({ breakdown }: MemoryChunkScoreBarsProps) {
|
||||
return (
|
||||
<section data-testid="memory-chunk-scorebars">
|
||||
<h3 className="mw-whykept-heading">w h y k e p t</h3>
|
||||
<div>
|
||||
{breakdown.signals.map(sig => {
|
||||
const pct = clamp01(sig.value);
|
||||
return (
|
||||
<div key={sig.name} className="mw-scorebar-row">
|
||||
<span className="mw-scorebar-label">{sig.name}</span>
|
||||
<svg
|
||||
width="100%"
|
||||
viewBox={`0 0 ${TRACK_WIDTH} ${TRACK_HEIGHT}`}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={`${sig.name} score ${(pct * 100).toFixed(0)} percent`}>
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={TRACK_WIDTH}
|
||||
height={TRACK_HEIGHT}
|
||||
rx={2}
|
||||
fill="var(--paper-recessed)"
|
||||
/>
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={pct * TRACK_WIDTH}
|
||||
height={TRACK_HEIGHT}
|
||||
rx={2}
|
||||
fill="var(--sage)"
|
||||
/>
|
||||
</svg>
|
||||
<span className="mw-scorebar-value">{pct.toFixed(2)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mw-scorebar-threshold">
|
||||
─── {breakdown.kept ? 'kept' : 'dropped'} at {breakdown.threshold.toFixed(2)} ───
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Right-pane placeholder shown to brand-new users (zero chunks).
|
||||
*
|
||||
* Centered, generous whitespace, no call-to-action buttons — the only path
|
||||
* forward is connecting an integration in Settings, so we point there in
|
||||
* prose without an explicit link to keep the surface meditative.
|
||||
*/
|
||||
export function MemoryEmptyPlaceholder() {
|
||||
return (
|
||||
<div className="mw-detail-empty" data-testid="memory-empty-placeholder">
|
||||
<h2 className="mw-empty-title">Nothing yet.</h2>
|
||||
<p className="mw-empty-body">
|
||||
Connect an integration in Settings to start
|
||||
<br />
|
||||
building your memory tree.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
import debug from 'debug';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import type { GraphRelation } from '../../utils/tauriCommands';
|
||||
|
||||
const log = debug('openhuman:memory-graph');
|
||||
|
||||
interface MemoryGraphMapProps {
|
||||
relations: GraphRelation[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
namespace: string | null;
|
||||
entityType: string | null;
|
||||
connectionCount: number;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
}
|
||||
|
||||
interface GraphEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
predicate: string;
|
||||
}
|
||||
|
||||
const NAMESPACE_COLORS = [
|
||||
'#4A83DD', // ocean blue
|
||||
'#4DC46F', // sage green
|
||||
'#E8A838', // amber
|
||||
'#9B8AFB', // lavender
|
||||
'#F56565', // coral
|
||||
'#7DD3FC', // sky
|
||||
'#FDA4AF', // rose
|
||||
'#6EE7B7', // mint
|
||||
];
|
||||
|
||||
const WIDTH = 800;
|
||||
const HEIGHT = 500;
|
||||
const MAX_NODES = 100;
|
||||
const MAX_EDGES = 200;
|
||||
|
||||
function truncate(s: string, max = 15): string {
|
||||
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
||||
}
|
||||
|
||||
function buildGraph(relations: GraphRelation[]): { nodes: GraphNode[]; edges: GraphEdge[] } {
|
||||
// Cap edges by evidence count descending
|
||||
const sorted = [...relations].sort((a, b) => b.evidenceCount - a.evidenceCount);
|
||||
const cappedRelations = sorted.slice(0, MAX_EDGES);
|
||||
|
||||
// Collect unique entity ids
|
||||
const entitySet = new Map<
|
||||
string,
|
||||
{ namespace: string | null; count: number; entityType: string | null }
|
||||
>();
|
||||
|
||||
for (const r of cappedRelations) {
|
||||
const subKey = r.subject.toLowerCase();
|
||||
const objKey = r.object.toLowerCase();
|
||||
const entityTypes = (r.attrs?.entity_types ?? {}) as Record<string, string>;
|
||||
|
||||
const existing = entitySet.get(subKey);
|
||||
entitySet.set(subKey, {
|
||||
namespace: r.namespace,
|
||||
count: (existing?.count ?? 0) + 1,
|
||||
entityType: existing?.entityType ?? entityTypes.subject ?? null,
|
||||
});
|
||||
|
||||
const existingObj = entitySet.get(objKey);
|
||||
entitySet.set(objKey, {
|
||||
namespace: existingObj?.namespace ?? r.namespace,
|
||||
count: (existingObj?.count ?? 0) + 1,
|
||||
entityType: existingObj?.entityType ?? entityTypes.object ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by connection count, cap at MAX_NODES
|
||||
const sortedEntities = [...entitySet.entries()].sort((a, b) => b[1].count - a[1].count);
|
||||
const cappedEntities = sortedEntities.slice(0, MAX_NODES);
|
||||
const allowedIds = new Set(cappedEntities.map(([id]) => id));
|
||||
|
||||
const nodes: GraphNode[] = cappedEntities.map(([id, info]) => ({
|
||||
id,
|
||||
label: id,
|
||||
namespace: info.namespace,
|
||||
entityType: info.entityType,
|
||||
connectionCount: info.count,
|
||||
x: 80 + Math.random() * (WIDTH - 160),
|
||||
y: 80 + Math.random() * (HEIGHT - 160),
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
}));
|
||||
|
||||
const edges: GraphEdge[] = cappedRelations
|
||||
.filter(r => allowedIds.has(r.subject.toLowerCase()) && allowedIds.has(r.object.toLowerCase()))
|
||||
.map(r => ({
|
||||
source: r.subject.toLowerCase(),
|
||||
target: r.object.toLowerCase(),
|
||||
predicate: r.predicate,
|
||||
}));
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
function runSimulation(nodes: GraphNode[], edges: GraphEdge[], iterations = 150): GraphNode[] {
|
||||
const nodeMap = new Map(nodes.map(n => [n.id, { ...n }]));
|
||||
const nodeList = [...nodeMap.values()];
|
||||
|
||||
const REPULSION = 3500;
|
||||
const ATTRACTION = 0.04;
|
||||
const CENTER_FORCE = 0.012;
|
||||
const DAMPING = 0.75;
|
||||
const cx = WIDTH / 2;
|
||||
const cy = HEIGHT / 2;
|
||||
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
for (let i = 0; i < nodeList.length; i++) {
|
||||
for (let j = i + 1; j < nodeList.length; j++) {
|
||||
const a = nodeList[i];
|
||||
const b = nodeList[j];
|
||||
const dx = b.x - a.x || 0.01;
|
||||
const dy = b.y - a.y || 0.01;
|
||||
const dist2 = dx * dx + dy * dy;
|
||||
const force = REPULSION / (dist2 + 1);
|
||||
const fx = (dx / Math.sqrt(dist2 + 1)) * force;
|
||||
const fy = (dy / Math.sqrt(dist2 + 1)) * force;
|
||||
a.vx -= fx;
|
||||
a.vy -= fy;
|
||||
b.vx += fx;
|
||||
b.vy += fy;
|
||||
}
|
||||
}
|
||||
for (const edge of edges) {
|
||||
const a = nodeMap.get(edge.source);
|
||||
const b = nodeMap.get(edge.target);
|
||||
if (!a || !b) continue;
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const delta = dist - 120;
|
||||
const fx = (dx / dist) * delta * ATTRACTION;
|
||||
const fy = (dy / dist) * delta * ATTRACTION;
|
||||
a.vx += fx;
|
||||
a.vy += fy;
|
||||
b.vx -= fx;
|
||||
b.vy -= fy;
|
||||
}
|
||||
for (const n of nodeList) {
|
||||
n.vx += (cx - n.x) * CENTER_FORCE;
|
||||
n.vy += (cy - n.y) * CENTER_FORCE;
|
||||
n.vx *= DAMPING;
|
||||
n.vy *= DAMPING;
|
||||
n.x = Math.max(40, Math.min(WIDTH - 40, n.x + n.vx));
|
||||
n.y = Math.max(40, Math.min(HEIGHT - 40, n.y + n.vy));
|
||||
}
|
||||
}
|
||||
|
||||
return nodeList;
|
||||
}
|
||||
|
||||
export function MemoryGraphMap({ relations, loading }: MemoryGraphMapProps) {
|
||||
const [hoveredEdge, setHoveredEdge] = useState<number | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
// Build graph data from relations (synchronous, deterministic)
|
||||
const { nodes, edges, namespacePalette } = useMemo(() => {
|
||||
log('[memory-graph] recomputing graph relations=%d', relations.length);
|
||||
if (relations.length === 0) {
|
||||
return {
|
||||
nodes: [] as GraphNode[],
|
||||
edges: [] as GraphEdge[],
|
||||
namespacePalette: new Map<string, string>(),
|
||||
};
|
||||
}
|
||||
const { nodes: rawNodes, edges: rawEdges } = buildGraph(relations);
|
||||
const namespaces = [...new Set(rawNodes.map(n => n.namespace ?? '__none__'))];
|
||||
const p = new Map<string, string>();
|
||||
namespaces.forEach((ns, i) => {
|
||||
p.set(ns, NAMESPACE_COLORS[i % NAMESPACE_COLORS.length]);
|
||||
});
|
||||
const simulated = runSimulation(rawNodes, rawEdges);
|
||||
log('[memory-graph] graph built nodes=%d edges=%d', simulated.length, rawEdges.length);
|
||||
return { nodes: simulated, edges: rawEdges, namespacePalette: p };
|
||||
}, [relations]);
|
||||
|
||||
const getNodeColor = useCallback(
|
||||
(node: GraphNode): string => {
|
||||
const ns = node.namespace ?? '__none__';
|
||||
return namespacePalette.get(ns) ?? NAMESPACE_COLORS[0];
|
||||
},
|
||||
[namespacePalette]
|
||||
);
|
||||
|
||||
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
||||
|
||||
// Guard against stale selectedNode — if the node no longer exists in the current
|
||||
// graph (e.g. after a relations refresh), treat it as deselected so no dimming occurs.
|
||||
const activeSelectedNode =
|
||||
selectedNode !== null && nodeMap.has(selectedNode) ? selectedNode : null;
|
||||
|
||||
const centerNodeId =
|
||||
nodes.find(n => n.id === 'user' || n.id === 'self' || n.id === 'you')?.id ??
|
||||
(nodes.length > 0 ? nodes[0].id : null);
|
||||
|
||||
// Connected node ids for selected highlight
|
||||
const connectedIds = activeSelectedNode
|
||||
? new Set(
|
||||
edges
|
||||
.filter(e => e.source === activeSelectedNode || e.target === activeSelectedNode)
|
||||
.flatMap(e => [e.source, e.target])
|
||||
)
|
||||
: null;
|
||||
|
||||
const namespaceEntries = [...namespacePalette.entries()].filter(([ns]) => ns !== '__none__');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<p className="text-sm font-semibold text-stone-900 mb-3">Memory Graph</p>
|
||||
<div className="flex items-center justify-center" style={{ minHeight: 320 }}>
|
||||
<div className="flex gap-2 items-center text-stone-600 text-sm">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-primary-500 border-t-transparent animate-spin" />
|
||||
Loading graph…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<p className="text-sm font-semibold text-stone-900 mb-3">Memory Graph</p>
|
||||
<div className="flex items-center justify-center" style={{ minHeight: 320 }}>
|
||||
<p className="text-stone-600 text-sm">No memory graph data yet</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxConn = Math.max(...nodes.map(n => n.connectionCount), 1);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<p className="text-sm font-semibold text-stone-900 mb-3">Memory Graph</p>
|
||||
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-lg border border-stone-200"
|
||||
style={{ minHeight: 320 }}>
|
||||
<svg
|
||||
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
|
||||
width="100%"
|
||||
style={{ display: 'block', background: 'rgba(248,248,247,1)' }}
|
||||
onClick={() => setSelectedNode(null)}>
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" fill="rgba(0,0,0,0.25)" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Edges */}
|
||||
{edges.map((edge, i) => {
|
||||
const src = nodeMap.get(edge.source);
|
||||
const tgt = nodeMap.get(edge.target);
|
||||
if (!src || !tgt) return null;
|
||||
|
||||
const isHighlighted =
|
||||
activeSelectedNode === null ||
|
||||
edge.source === activeSelectedNode ||
|
||||
edge.target === activeSelectedNode;
|
||||
|
||||
const midX = (src.x + tgt.x) / 2;
|
||||
const midY = (src.y + tgt.y) / 2;
|
||||
|
||||
return (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={src.x}
|
||||
y1={src.y}
|
||||
x2={tgt.x}
|
||||
y2={tgt.y}
|
||||
stroke={isHighlighted ? 'rgba(0,0,0,0.22)' : 'rgba(0,0,0,0.08)'}
|
||||
strokeWidth={isHighlighted ? 1.5 : 1}
|
||||
markerEnd="url(#arrowhead)"
|
||||
style={{ cursor: 'pointer', transition: 'stroke 0.15s' }}
|
||||
onMouseEnter={() => setHoveredEdge(i)}
|
||||
onMouseLeave={() => setHoveredEdge(null)}
|
||||
/>
|
||||
{/* Edge label */}
|
||||
<text
|
||||
x={midX}
|
||||
y={midY - 4}
|
||||
textAnchor="middle"
|
||||
fontSize={9}
|
||||
fill={
|
||||
hoveredEdge === i
|
||||
? 'rgba(0,0,0,0.7)'
|
||||
: isHighlighted
|
||||
? 'rgba(0,0,0,0.35)'
|
||||
: 'rgba(0,0,0,0.1)'
|
||||
}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none', transition: 'fill 0.15s' }}>
|
||||
{truncate(edge.predicate, 18)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Nodes */}
|
||||
{nodes.map(node => {
|
||||
const r = 8 + (node.connectionCount / maxConn) * 18;
|
||||
const color = getNodeColor(node);
|
||||
const isCenter = node.id === centerNodeId;
|
||||
const isSelected = activeSelectedNode === node.id;
|
||||
const isDimmed = activeSelectedNode !== null && !connectedIds?.has(node.id);
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
transform={`translate(${node.x},${node.y})`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setSelectedNode(activeSelectedNode === node.id ? null : node.id);
|
||||
}}>
|
||||
{(isCenter || isSelected) && (
|
||||
<circle r={r + 5} fill="none" stroke={color} strokeWidth={2} opacity={0.4} />
|
||||
)}
|
||||
<circle
|
||||
r={r}
|
||||
fill={color}
|
||||
opacity={isDimmed ? 0.15 : isSelected ? 1 : 0.82}
|
||||
stroke={isSelected ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.5)'}
|
||||
strokeWidth={isSelected ? 2 : 1}
|
||||
style={{ transition: 'opacity 0.15s' }}
|
||||
/>
|
||||
<text
|
||||
y={r + 11}
|
||||
textAnchor="middle"
|
||||
fontSize={isCenter ? 11 : 9}
|
||||
fontWeight={isCenter ? 600 : 400}
|
||||
fill={isDimmed ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.75)'}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none', transition: 'fill 0.15s' }}>
|
||||
{isCenter && node.id !== 'you' ? 'You' : truncate(node.label)}
|
||||
</text>
|
||||
{node.entityType && (
|
||||
<text
|
||||
y={r + 22}
|
||||
textAnchor="middle"
|
||||
fontSize={7}
|
||||
fontWeight={500}
|
||||
letterSpacing="0.04em"
|
||||
fill={isDimmed ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.4)'}
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
textTransform: 'uppercase',
|
||||
transition: 'fill 0.15s',
|
||||
}}>
|
||||
{node.entityType}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
{namespaceEntries.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
{namespaceEntries.map(([ns, color]) => (
|
||||
<div key={ns} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-xs text-stone-400 truncate max-w-[120px]">{ns}</span>
|
||||
</div>
|
||||
))}
|
||||
{namespacePalette.has('__none__') && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: namespacePalette.get('__none__') }}
|
||||
/>
|
||||
<span className="text-xs text-stone-400">uncategorized</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-xs text-stone-500">
|
||||
{nodes.length} entities · {edges.length} relations · click a node to highlight connections
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Left pane of MemoryWorkspace — search box, slim heatmap header, and four
|
||||
* collapsible lens sections (recent / sources / people / topics).
|
||||
*
|
||||
* Selections are NOT mutually exclusive: multiple selected items intersect
|
||||
* the result list filter. Sections may be collapsed independently.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { Chunk, EntityRef, Source } from '../../utils/tauriCommands';
|
||||
import { MemoryHeatmap } from './MemoryHeatmap';
|
||||
|
||||
export interface NavigatorSelection {
|
||||
sourceIds: string[];
|
||||
entityIds: string[];
|
||||
}
|
||||
|
||||
interface MemoryNavigatorProps {
|
||||
chunks: Chunk[];
|
||||
sources: Source[];
|
||||
topPeople: EntityRef[];
|
||||
topTopics: EntityRef[];
|
||||
selection: NavigatorSelection;
|
||||
onSelectionChange: (next: NavigatorSelection) => void;
|
||||
searchQuery: string;
|
||||
onSearchChange: (next: string) => void;
|
||||
}
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
function dotClassFor(status: string | undefined): string {
|
||||
switch (status) {
|
||||
case 'admitted':
|
||||
return 'mw-dot dot-admitted';
|
||||
case 'pending_extraction':
|
||||
return 'mw-dot dot-pending';
|
||||
case 'buffered':
|
||||
return 'mw-dot dot-buffered';
|
||||
case 'dropped':
|
||||
return 'mw-dot dot-dropped';
|
||||
default:
|
||||
return 'mw-dot';
|
||||
}
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
label: string;
|
||||
defaultOpen?: boolean;
|
||||
countSummary?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function NavSection({ label, defaultOpen = true, countSummary, children }: SectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="mw-section">
|
||||
<button
|
||||
type="button"
|
||||
className="mw-section-heading"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-expanded={open}>
|
||||
<span>{label}</span>
|
||||
{countSummary && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: 10,
|
||||
letterSpacing: 0,
|
||||
color: 'var(--ink-whisper)',
|
||||
marginLeft: 8,
|
||||
textTransform: 'none',
|
||||
}}>
|
||||
{countSummary}
|
||||
</span>
|
||||
)}
|
||||
<span className={`mw-section-chev${open ? ' open' : ''}`} aria-hidden>
|
||||
›
|
||||
</span>
|
||||
</button>
|
||||
{open && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemoryNavigator({
|
||||
chunks,
|
||||
sources,
|
||||
topPeople,
|
||||
topTopics,
|
||||
selection,
|
||||
onSelectionChange,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}: MemoryNavigatorProps) {
|
||||
const heatmapTimestamps = useMemo(
|
||||
() => chunks.map(c => Math.floor(c.timestamp_ms / 1000)),
|
||||
[chunks]
|
||||
);
|
||||
|
||||
// Wall-clock-derived counts. Computed in an effect to keep render pure
|
||||
// (the `react-hooks/components-and-hooks-must-be-pure` rule rejects a
|
||||
// raw `Date.now()` call inside a `useMemo` body, since two equivalent
|
||||
// renders could produce different values).
|
||||
const [recentCounts, setRecentCounts] = useState<{ today: number; week: number }>({
|
||||
today: 0,
|
||||
week: 0,
|
||||
});
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
const startOfDay = new Date(now);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const startOfDayMs = startOfDay.getTime();
|
||||
const startOfWeekMs = now - 7 * DAY_MS;
|
||||
let today = 0;
|
||||
let week = 0;
|
||||
for (const c of chunks) {
|
||||
if (c.timestamp_ms >= startOfDayMs) today++;
|
||||
if (c.timestamp_ms >= startOfWeekMs) week++;
|
||||
}
|
||||
setRecentCounts({ today, week });
|
||||
}, [chunks]);
|
||||
const { today: todayCount, week: weekCount } = recentCounts;
|
||||
|
||||
const toggleSource = (id: string) => {
|
||||
const has = selection.sourceIds.includes(id);
|
||||
onSelectionChange({
|
||||
...selection,
|
||||
sourceIds: has ? selection.sourceIds.filter(s => s !== id) : [...selection.sourceIds, id],
|
||||
});
|
||||
};
|
||||
|
||||
const toggleEntity = (id: string) => {
|
||||
const has = selection.entityIds.includes(id);
|
||||
const next = has ? selection.entityIds.filter(s => s !== id) : [...selection.entityIds, id];
|
||||
console.debug(
|
||||
'[ui-flow][memory-navigator] toggleEntity id=%s wasActive=%o next=%o',
|
||||
id,
|
||||
has,
|
||||
next
|
||||
);
|
||||
onSelectionChange({ ...selection, entityIds: next });
|
||||
};
|
||||
|
||||
const renderEntityList = (refs: EntityRef[]) => (
|
||||
<ul className="mw-list">
|
||||
{refs.map(ref => {
|
||||
const tag = `${ref.kind}/${ref.surface.replace(/\s+/g, '-')}`;
|
||||
// For tag-based selection we use the raw tag string; for entity_id we
|
||||
// compare against tags on chunks. Match either form to be lenient.
|
||||
const isActive =
|
||||
selection.entityIds.includes(tag) || selection.entityIds.includes(ref.entity_id);
|
||||
return (
|
||||
<li key={ref.entity_id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`mw-list-item${isActive ? ' is-active' : ''}`}
|
||||
onClick={() => toggleEntity(ref.entity_id)}
|
||||
aria-pressed={isActive}>
|
||||
<span className="mw-dot" aria-hidden />
|
||||
<span className="mw-list-name" title={ref.surface}>
|
||||
{ref.surface}
|
||||
</span>
|
||||
<span className="mw-list-count">{ref.count}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{refs.length === 0 && (
|
||||
<li style={{ padding: '6px 16px', fontSize: 12, color: 'var(--ink-whisper)' }}>—</li>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="mw-pane-navigator" data-testid="memory-navigator">
|
||||
<div className="mw-search-row">
|
||||
<input
|
||||
type="text"
|
||||
className="mw-search-input"
|
||||
placeholder="search memory…"
|
||||
value={searchQuery}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
aria-label="Search memory"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mw-heatmap-host" data-testid="memory-navigator-heatmap">
|
||||
<MemoryHeatmap timestamps={heatmapTimestamps} />
|
||||
</div>
|
||||
|
||||
<div className="mw-pane-scroll">
|
||||
<NavSection label="recent" defaultOpen>
|
||||
<div className="mw-recent-summary">
|
||||
<span>today {todayCount}</span>
|
||||
<span>this week {weekCount}</span>
|
||||
</div>
|
||||
</NavSection>
|
||||
|
||||
<NavSection label="sources" defaultOpen countSummary={String(sources.length)}>
|
||||
{sources.length === 0 ? (
|
||||
<ul className="mw-list">
|
||||
<li style={{ padding: '6px 16px', fontSize: 12, color: 'var(--ink-whisper)' }}>—</li>
|
||||
</ul>
|
||||
) : (
|
||||
(() => {
|
||||
// Group sources by their source_kind ('email', 'slack', 'chat', …)
|
||||
// and render each kind as its own nested collapsible. Lets the
|
||||
// user filter at the kind level (drill into Email vs Slack) and
|
||||
// then by individual sender within each.
|
||||
const byKind = new Map<string, Source[]>();
|
||||
for (const s of sources) {
|
||||
const arr = byKind.get(s.source_kind) ?? [];
|
||||
arr.push(s);
|
||||
byKind.set(s.source_kind, arr);
|
||||
}
|
||||
const kindLabel: Record<string, string> = {
|
||||
email: 'Email',
|
||||
slack: 'Slack',
|
||||
chat: 'Chat',
|
||||
document: 'Documents',
|
||||
};
|
||||
const kinds = Array.from(byKind.entries()).sort((a, b) => b[1].length - a[1].length);
|
||||
return (
|
||||
<div>
|
||||
{kinds.map(([kind, kindSources]) => (
|
||||
<NavSection
|
||||
key={kind}
|
||||
label={kindLabel[kind] ?? kind}
|
||||
defaultOpen={false}
|
||||
countSummary={String(kindSources.length)}>
|
||||
<ul className="mw-list">
|
||||
{kindSources.map(src => {
|
||||
const isActive = selection.sourceIds.includes(src.source_id);
|
||||
return (
|
||||
<li key={src.source_id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`mw-list-item${isActive ? ' is-active' : ''}`}
|
||||
onClick={() => toggleSource(src.source_id)}
|
||||
aria-pressed={isActive}>
|
||||
<span className={dotClassFor(src.lifecycle_status)} aria-hidden />
|
||||
<span className="mw-list-name" title={src.display_name}>
|
||||
{src.display_name}
|
||||
</span>
|
||||
<span className="mw-list-count">{src.chunk_count}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</NavSection>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</NavSection>
|
||||
|
||||
<NavSection label="people" defaultOpen countSummary={String(topPeople.length)}>
|
||||
{renderEntityList(topPeople)}
|
||||
</NavSection>
|
||||
|
||||
<NavSection label="topics" defaultOpen countSummary={String(topTopics.length)}>
|
||||
{renderEntityList(topTopics)}
|
||||
</NavSection>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Middle pane of MemoryWorkspace — time-grouped chunk rows.
|
||||
*
|
||||
* Sections: TODAY / YESTERDAY / THIS WEEK / OLDER, headers are sticky
|
||||
* so the user always knows which time bucket is on screen.
|
||||
*
|
||||
* Auto-scrolls to the active row on mount and on selection change.
|
||||
*
|
||||
* The list is intentionally non-virtualized for now — mock fixtures
|
||||
* top out at ~30 rows. Once real data lands we can swap in react-window
|
||||
* (or similar) without changing the public API.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import type { Chunk } from '../../utils/tauriCommands';
|
||||
|
||||
interface MemoryResultListProps {
|
||||
chunks: Chunk[];
|
||||
selectedChunkId: string | null;
|
||||
onSelectChunk: (id: string) => void;
|
||||
}
|
||||
|
||||
type GroupKey = 'TODAY' | 'YESTERDAY' | 'THIS WEEK' | 'OLDER';
|
||||
|
||||
interface Group {
|
||||
key: GroupKey;
|
||||
chunks: Chunk[];
|
||||
}
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
function startOfLocalDay(d: Date): Date {
|
||||
const out = new Date(d);
|
||||
out.setHours(0, 0, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
function bucketFor(
|
||||
ts: number,
|
||||
todayMs: number,
|
||||
yesterdayMs: number,
|
||||
weekStartMs: number
|
||||
): GroupKey {
|
||||
if (ts >= todayMs) return 'TODAY';
|
||||
if (ts >= yesterdayMs) return 'YESTERDAY';
|
||||
if (ts >= weekStartMs) return 'THIS WEEK';
|
||||
return 'OLDER';
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n < 10 ? `0${n}` : String(n);
|
||||
}
|
||||
|
||||
const WEEKDAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const MONTH_SHORT = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
|
||||
function formatTime(ts: number, group: GroupKey): string {
|
||||
const d = new Date(ts);
|
||||
if (group === 'TODAY' || group === 'YESTERDAY') {
|
||||
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
}
|
||||
if (group === 'THIS WEEK') {
|
||||
return `${WEEKDAY_SHORT[d.getDay()]} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
}
|
||||
return `${MONTH_SHORT[d.getMonth()]} ${d.getDate()}`;
|
||||
}
|
||||
|
||||
function chunkSubject(chunk: Chunk): string {
|
||||
const preview = (chunk.content_preview ?? '').trim();
|
||||
if (!preview) return chunk.id;
|
||||
// Use the first sentence/line as the subject
|
||||
const firstLine = preview.split('\n')[0];
|
||||
const sentenceEnd = firstLine.search(/[.!?](?:\s|$)/);
|
||||
if (sentenceEnd > 16 && sentenceEnd < 120) {
|
||||
return firstLine.slice(0, sentenceEnd + 1).trim();
|
||||
}
|
||||
return firstLine.length > 140 ? `${firstLine.slice(0, 137)}…` : firstLine;
|
||||
}
|
||||
|
||||
function chunkSenderLabel(chunk: Chunk): string {
|
||||
// Try to derive a sender from source_id; fall back to source kind.
|
||||
const left = chunk.source_id.split('|')[0];
|
||||
const after = left.includes(':') ? left.split(':').slice(1).join(':') : left;
|
||||
return after || chunk.source_kind;
|
||||
}
|
||||
|
||||
export function MemoryResultList({
|
||||
chunks,
|
||||
selectedChunkId,
|
||||
onSelectChunk,
|
||||
}: MemoryResultListProps) {
|
||||
const groups = useMemo<Group[]>(() => {
|
||||
const today = startOfLocalDay(new Date()).getTime();
|
||||
const yesterday = today - DAY_MS;
|
||||
const weekStart = today - 7 * DAY_MS;
|
||||
|
||||
const buckets: Record<GroupKey, Chunk[]> = {
|
||||
TODAY: [],
|
||||
YESTERDAY: [],
|
||||
'THIS WEEK': [],
|
||||
OLDER: [],
|
||||
};
|
||||
for (const c of chunks) {
|
||||
buckets[bucketFor(c.timestamp_ms, today, yesterday, weekStart)].push(c);
|
||||
}
|
||||
const order: GroupKey[] = ['TODAY', 'YESTERDAY', 'THIS WEEK', 'OLDER'];
|
||||
return order.map(key => ({ key, chunks: buckets[key] })).filter(g => g.chunks.length > 0);
|
||||
}, [chunks]);
|
||||
|
||||
const activeRowRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeRowRef.current) {
|
||||
activeRowRef.current.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [selectedChunkId]);
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return (
|
||||
<section className="mw-pane-results" data-testid="memory-result-list">
|
||||
<div className="mw-results-empty">No matching chunks.</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mw-pane-results" data-testid="memory-result-list">
|
||||
<div className="mw-pane-scroll">
|
||||
{groups.map(group => (
|
||||
<div key={group.key} className="mw-results-section">
|
||||
<div className="mw-results-section-header">{group.key}</div>
|
||||
{group.chunks.map(chunk => {
|
||||
const isActive = chunk.id === selectedChunkId;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={chunk.id}
|
||||
ref={isActive ? activeRowRef : undefined}
|
||||
className={`mw-result-row${isActive ? ' is-active' : ''}`}
|
||||
onClick={() => onSelectChunk(chunk.id)}
|
||||
data-chunk-id={chunk.id}>
|
||||
<span className="mw-result-time">
|
||||
{formatTime(chunk.timestamp_ms, group.key)}
|
||||
</span>
|
||||
<span className="mw-result-content">
|
||||
<span className="mw-result-subject">{chunkSubject(chunk)}</span>
|
||||
<span className="mw-result-meta">
|
||||
<span className="mw-result-kind">{chunk.source_kind}</span>
|
||||
{' · '}
|
||||
{chunkSenderLabel(chunk)} · {chunk.token_count} tok
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
DEFAULT_EXTRACT_MODEL,
|
||||
DEFAULT_SUMMARISER_MODEL,
|
||||
type ModelDescriptor,
|
||||
RECOMMENDED_MODEL_CATALOG,
|
||||
REQUIRED_EMBEDDER_MODEL,
|
||||
} from '../../lib/intelligence/settingsApi';
|
||||
|
||||
interface ModelAssignmentProps {
|
||||
/** Names of models that are already installed on the user's machine. */
|
||||
installedModelIds: ReadonlyArray<string>;
|
||||
/** Currently chosen memory LLM (used for both extract + summarise). */
|
||||
memoryModel: string;
|
||||
/** Called when the user picks a different memory LLM. The setting fans
|
||||
* out to both `llm_extractor_model` and `llm_summariser_model` in
|
||||
* config.toml — most users want one model for both roles, and the
|
||||
* cognitive load of two dropdowns isn't worth the rare power-user
|
||||
* case of mixing them. */
|
||||
onChangeMemory: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-role assignment table — two rows: Memory LLM (covers both extract
|
||||
* and summarise), and Embedder.
|
||||
*
|
||||
* The embedder row is locked to `bge-m3` for v1 (the spec says we never
|
||||
* round-trip embeddings through the cloud). The Memory LLM dropdown is
|
||||
* populated from the recommended catalog filtered to models that can
|
||||
* serve both extract AND summarise roles, plus any locally-installed
|
||||
* models the user has pulled outside the curated catalog.
|
||||
*/
|
||||
export default function ModelAssignment({
|
||||
installedModelIds,
|
||||
memoryModel,
|
||||
onChangeMemory,
|
||||
}: ModelAssignmentProps) {
|
||||
// Ollama returns tags as `<name>:latest` for default-tag models. The
|
||||
// catalog stores bare names (e.g. `bge-m3`). Strip the `:latest` suffix
|
||||
// on the installed side so the bare-name comparison matches.
|
||||
const normalizedInstalled = installedModelIds.map(id =>
|
||||
id.endsWith(':latest') ? id.slice(0, -':latest'.length) : id
|
||||
);
|
||||
const memoryOptions = memoryLlmOptions(normalizedInstalled);
|
||||
const embedderDescriptor = RECOMMENDED_MODEL_CATALOG.find(m => m.id === REQUIRED_EMBEDDER_MODEL);
|
||||
const embedderInstalled = normalizedInstalled.includes(REQUIRED_EMBEDDER_MODEL);
|
||||
|
||||
return (
|
||||
<div className="border border-stone-200 rounded-2xl overflow-hidden">
|
||||
<Row
|
||||
label="Memory LLM"
|
||||
sublabel={describeMemory(memoryOptions.find(opt => opt.id === memoryModel))}>
|
||||
<select
|
||||
value={memoryModel}
|
||||
onChange={e => onChangeMemory(e.target.value)}
|
||||
className="w-full sm:w-64 px-3 py-1.5 text-sm bg-white border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:border-primary-500/50 transition-colors"
|
||||
aria-label="Memory LLM (extract + summarise)">
|
||||
{memoryOptions.map(opt => (
|
||||
<option key={opt.id} value={opt.id}>
|
||||
{opt.label ?? opt.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
label="Embedder"
|
||||
sublabel={
|
||||
embedderDescriptor
|
||||
? `${embedderDescriptor.size} · required · 1024-dim`
|
||||
: 'required · 1024-dim'
|
||||
}
|
||||
last>
|
||||
<div className="flex items-center gap-2 text-sm font-mono text-stone-700">
|
||||
<span>{REQUIRED_EMBEDDER_MODEL}</span>
|
||||
{embedderInstalled ? (
|
||||
<span className="inline-flex items-center gap-1 text-sage-600 text-xs">
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
loaded
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-amber-700 text-xs">not downloaded</span>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
label: string;
|
||||
sublabel: string;
|
||||
last?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Row({ label, sublabel, last, children }: RowProps) {
|
||||
return (
|
||||
<div
|
||||
className={`grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-2 sm:gap-6 px-5 py-4 ${
|
||||
last ? '' : 'border-b border-stone-100'
|
||||
}`}>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-stone-900">{label}</div>
|
||||
<div className="font-mono text-[11px] text-stone-500 mt-0.5">{sublabel}</div>
|
||||
</div>
|
||||
<div className="flex items-center sm:justify-end">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function describeMemory(model?: ModelDescriptor): string {
|
||||
if (!model) return 'used for extract + summarise';
|
||||
return `${model.size} · ${model.ramHint} · ${model.category}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Memory LLM dropdown options. A model qualifies if it can serve
|
||||
* BOTH extract and summarise roles. Catalog entries come first; locally
|
||||
* installed extras (pulled outside the curated catalog) are appended so
|
||||
* they remain selectable.
|
||||
*/
|
||||
function memoryLlmOptions(installedModelIds: ReadonlyArray<string>): ModelDescriptor[] {
|
||||
const catalog = RECOMMENDED_MODEL_CATALOG.filter(
|
||||
m => m.roles.includes('extract') && m.roles.includes('summariser')
|
||||
);
|
||||
const known = new Set(catalog.map(m => m.id));
|
||||
const extras = installedModelIds
|
||||
.filter(id => !known.has(id) && id !== REQUIRED_EMBEDDER_MODEL)
|
||||
.map<ModelDescriptor>(id => ({
|
||||
id,
|
||||
size: '—',
|
||||
approxBytes: 0,
|
||||
ramHint: '—',
|
||||
category: 'balanced',
|
||||
note: 'locally installed',
|
||||
roles: ['extract', 'summariser'],
|
||||
}));
|
||||
return [...catalog, ...extras];
|
||||
}
|
||||
|
||||
// Re-export defaults so callers can still seed initial state via these
|
||||
// constants without chasing them through the API module.
|
||||
export { DEFAULT_EXTRACT_MODEL, DEFAULT_SUMMARISER_MODEL, REQUIRED_EMBEDDER_MODEL };
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
capabilityForModel,
|
||||
type ModelDescriptor,
|
||||
RECOMMENDED_MODEL_CATALOG,
|
||||
} from '../../lib/intelligence/settingsApi';
|
||||
|
||||
interface ModelCatalogProps {
|
||||
/** Names of models that are already installed on the user's machine. */
|
||||
installedModelIds: ReadonlyArray<string>;
|
||||
/** Models in active use right now (assigned to a role). */
|
||||
activeModelIds: ReadonlyArray<string>;
|
||||
/** Called when the user kicks off a download for a catalog entry. */
|
||||
onDownload: (model: ModelDescriptor) => Promise<void>;
|
||||
/** Called when the user wants to assign an installed model to its role. */
|
||||
onUse: (model: ModelDescriptor) => void;
|
||||
/** Called when the user removes an installed model. */
|
||||
onDelete?: (model: ModelDescriptor) => Promise<void>;
|
||||
}
|
||||
|
||||
type RowState = 'idle' | 'downloading' | 'error';
|
||||
|
||||
/**
|
||||
* Single-column list of curated models. Each row is one card showing
|
||||
* <id> <size> <status> [action]
|
||||
* The action button changes by state:
|
||||
* - not installed → "Download" (clicks fire the per-capability RPC)
|
||||
* - installed but unused → "Use"
|
||||
* - installed and active → "Active"
|
||||
* - downloading → inline progress bar (mocked client-side animation
|
||||
* since the per-asset RPC is fire-and-forget; the real progress
|
||||
* stream is wired in `local_ai_downloads_progress` polling — out of
|
||||
* scope for v1)
|
||||
*/
|
||||
// Ollama reports tags as `<name>:<tag>` (e.g. `bge-m3:latest`,
|
||||
// `gemma3:1b-it-qat`). The recommended catalog uses bare names for the
|
||||
// default-`:latest` case (e.g. `bge-m3`) and full `<name>:<tag>` for
|
||||
// non-default tags. Normalize both sides by stripping the `:latest`
|
||||
// suffix before comparing — that way `bge-m3` matches `bge-m3:latest`,
|
||||
// while `gemma3:1b-it-qat` still requires the explicit tag.
|
||||
function normalizeModelId(id: string): string {
|
||||
return id.endsWith(':latest') ? id.slice(0, -':latest'.length) : id;
|
||||
}
|
||||
|
||||
export default function ModelCatalog({
|
||||
installedModelIds,
|
||||
activeModelIds,
|
||||
onDownload,
|
||||
onUse,
|
||||
onDelete,
|
||||
}: ModelCatalogProps) {
|
||||
const installedSet = new Set(installedModelIds.map(normalizeModelId));
|
||||
const activeSet = new Set(activeModelIds.map(normalizeModelId));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{RECOMMENDED_MODEL_CATALOG.map(model => (
|
||||
<CatalogRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
installed={installedSet.has(normalizeModelId(model.id))}
|
||||
active={activeSet.has(normalizeModelId(model.id))}
|
||||
onDownload={onDownload}
|
||||
onUse={onUse}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CatalogRowProps {
|
||||
model: ModelDescriptor;
|
||||
installed: boolean;
|
||||
active: boolean;
|
||||
onDownload: ModelCatalogProps['onDownload'];
|
||||
onUse: ModelCatalogProps['onUse'];
|
||||
onDelete: ModelCatalogProps['onDelete'];
|
||||
}
|
||||
|
||||
function CatalogRow({ model, installed, active, onDownload, onUse, onDelete }: CatalogRowProps) {
|
||||
const [state, setState] = useState<RowState>('idle');
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const status: 'active' | 'installed' | 'available' = active
|
||||
? 'active'
|
||||
: installed
|
||||
? 'installed'
|
||||
: 'available';
|
||||
|
||||
const handleDownload = async () => {
|
||||
setState('downloading');
|
||||
setProgress(8);
|
||||
// Animated mock progress while the real per-capability RPC is in flight.
|
||||
// The real download progress stream comes from
|
||||
// `openhumanLocalAiDownloadsProgress` polling — wiring that in is
|
||||
// tracked separately and out of scope for v1.
|
||||
const tick = setInterval(() => {
|
||||
setProgress(prev => {
|
||||
if (prev >= 90) return prev;
|
||||
return prev + Math.max(2, Math.round((100 - prev) * 0.06));
|
||||
});
|
||||
}, 220);
|
||||
let didFail = false;
|
||||
try {
|
||||
await onDownload(model);
|
||||
setProgress(100);
|
||||
} catch (err) {
|
||||
console.debug('[intelligence-settings] catalog download failed', { id: model.id, err });
|
||||
setState('error');
|
||||
didFail = true;
|
||||
} finally {
|
||||
clearInterval(tick);
|
||||
// Hold the terminal state long enough for the user to actually read
|
||||
// it. Success collapses fast (~600 ms) so the row settles back to
|
||||
// its post-install state without a long pause; error lingers ~3s
|
||||
// so an unsuccessful pull doesn't snap back before the user has
|
||||
// a chance to notice. Tracked via a local flag because `state` is
|
||||
// React state and won't reflect the just-issued `setState('error')`
|
||||
// until the next render.
|
||||
const settleMs = didFail ? 3000 : 600;
|
||||
window.setTimeout(() => {
|
||||
setState('idle');
|
||||
setProgress(0);
|
||||
}, settleMs);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-stone-200 rounded-xl bg-white px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-stone-900 truncate">{model.id}</div>
|
||||
<div className="font-mono text-[11px] text-stone-500 whitespace-nowrap">{model.size}</div>
|
||||
<StatusChip status={status} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{state === 'downloading' ? (
|
||||
<ProgressBar progress={progress} />
|
||||
) : (
|
||||
<ActionButton
|
||||
status={status}
|
||||
hasDelete={!!onDelete}
|
||||
onDownload={handleDownload}
|
||||
onUse={() => onUse(model)}
|
||||
onDelete={onDelete ? () => onDelete(model) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 font-mono text-[11px] text-stone-500">
|
||||
<span>{model.ramHint}</span>
|
||||
<span>·</span>
|
||||
<span>{model.category}</span>
|
||||
<span>·</span>
|
||||
<span className="text-stone-400">{model.note}</span>
|
||||
{capabilityForModel(model) === null && (
|
||||
<span className="text-amber-600 ml-auto">no capability binding</span>
|
||||
)}
|
||||
</div>
|
||||
{state === 'error' && (
|
||||
<div className="mt-2 text-[11px] text-coral-700">
|
||||
Download failed — check Ollama is running and try again.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusChip({ status }: { status: 'active' | 'installed' | 'available' }) {
|
||||
if (status === 'active') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] uppercase tracking-wider rounded-full bg-sage-50 text-sage-700 border border-sage-100">
|
||||
active
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'installed') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] uppercase tracking-wider rounded-full bg-stone-100 text-stone-600 border border-stone-200">
|
||||
installed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] uppercase tracking-wider rounded-full bg-white text-stone-500 border border-stone-200">
|
||||
not downloaded
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
status: 'active' | 'installed' | 'available';
|
||||
hasDelete: boolean;
|
||||
onDownload: () => void;
|
||||
onUse: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function ActionButton({ status, hasDelete, onDownload, onUse, onDelete }: ActionButtonProps) {
|
||||
if (status === 'active') {
|
||||
return (
|
||||
<span className="px-3 py-1.5 text-xs text-stone-500 border border-transparent">in use</span>
|
||||
);
|
||||
}
|
||||
if (status === 'installed') {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUse}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-primary-50 hover:bg-primary-100 text-primary-700 border border-primary-100 rounded-lg transition-colors">
|
||||
Use
|
||||
</button>
|
||||
{hasDelete && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="px-2 py-1.5 text-xs text-stone-500 hover:text-coral-700 border border-stone-200 rounded-lg transition-colors"
|
||||
aria-label="Delete model">
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDownload}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-white hover:bg-stone-50 text-stone-700 border border-stone-200 rounded-lg transition-colors">
|
||||
Download
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressBar({ progress }: { progress: number }) {
|
||||
return (
|
||||
<div
|
||||
className="w-32 h-2 rounded-full bg-stone-100 overflow-hidden"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(progress)}>
|
||||
<div
|
||||
className="h-full bg-primary-500 transition-all duration-200"
|
||||
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import IntelligenceSettingsTab from '../IntelligenceSettingsTab';
|
||||
|
||||
// The orchestrator hits these RPCs on mount; the global tauriCommands mock
|
||||
// in setup.ts only stubs auth/service helpers, so we extend it here with
|
||||
// the local-AI surface the Settings tab uses, plus the new memory_tree
|
||||
// LLM-selector RPCs that replaced the dev-time mock backend.
|
||||
vi.mock('../../../utils/tauriCommands', () => ({
|
||||
isTauri: vi.fn(() => true),
|
||||
// memory_tree LLM selector — the BackendChooser polls these on mount and
|
||||
// again on every backend toggle. We track the value in a closure so the
|
||||
// set→get round-trip behaves like the real persistent core.
|
||||
memoryTreeGetLlm: vi.fn(),
|
||||
memoryTreeSetLlm: vi.fn(),
|
||||
openhumanLocalAiAssetsStatus: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
result: {
|
||||
chat: { state: 'NotInstalled', id: '', provider: 'ollama' },
|
||||
vision: { state: 'NotInstalled', id: '', provider: 'ollama' },
|
||||
embedding: { state: 'NotInstalled', id: '', provider: 'ollama' },
|
||||
stt: { state: 'NotInstalled', id: '', provider: 'ollama' },
|
||||
tts: { state: 'NotInstalled', id: '', provider: 'ollama' },
|
||||
quantization: 'q4_k_m',
|
||||
},
|
||||
}),
|
||||
openhumanLocalAiDiagnostics: vi.fn().mockResolvedValue({
|
||||
ollama_running: true,
|
||||
ollama_binary_path: '/usr/local/bin/ollama',
|
||||
installed_models: [
|
||||
{ name: 'gemma3:1b-it-qat', size: 1_700_000_000, modified_at: null },
|
||||
{ name: 'bge-m3', size: 1_300_000_000, modified_at: null },
|
||||
],
|
||||
expected: {
|
||||
chat_model: 'gemma3:1b-it-qat',
|
||||
chat_found: true,
|
||||
embedding_model: 'bge-m3',
|
||||
embedding_found: true,
|
||||
vision_model: '',
|
||||
vision_found: false,
|
||||
},
|
||||
issues: [],
|
||||
ok: true,
|
||||
}),
|
||||
openhumanLocalAiStatus: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
result: {
|
||||
state: 'Ready',
|
||||
model_id: 'gemma3:1b-it-qat',
|
||||
chat_model_id: 'gemma3:1b-it-qat',
|
||||
vision_model_id: '',
|
||||
embedding_model_id: 'bge-m3',
|
||||
stt_model_id: '',
|
||||
tts_voice_id: '',
|
||||
quantization: 'q4_k_m',
|
||||
vision_state: 'idle',
|
||||
vision_mode: 'off',
|
||||
embedding_state: 'Ready',
|
||||
stt_state: 'idle',
|
||||
tts_state: 'idle',
|
||||
provider: 'ollama',
|
||||
active_backend: 'cpu',
|
||||
last_latency_ms: 142,
|
||||
},
|
||||
}),
|
||||
openhumanLocalAiPresets: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
presets: [],
|
||||
recommended_tier: 'minimal',
|
||||
current_tier: 'minimal',
|
||||
device: {
|
||||
total_ram_bytes: 16_000_000_000,
|
||||
cpu_count: 8,
|
||||
cpu_brand: 'Test CPU',
|
||||
os_name: 'macos',
|
||||
os_version: '14',
|
||||
has_gpu: false,
|
||||
gpu_description: null,
|
||||
},
|
||||
local_ai_enabled: false,
|
||||
}),
|
||||
openhumanLocalAiDownloadAsset: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
result: {
|
||||
chat: { state: 'Ready', id: 'gemma3:1b-it-qat', provider: 'ollama' },
|
||||
vision: { state: 'NotInstalled', id: '', provider: 'ollama' },
|
||||
embedding: { state: 'Ready', id: 'bge-m3', provider: 'ollama' },
|
||||
stt: { state: 'NotInstalled', id: '', provider: 'ollama' },
|
||||
tts: { state: 'NotInstalled', id: '', provider: 'ollama' },
|
||||
quantization: 'q4_k_m',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Pull mocked references after vi.mock() has hoisted. Cast through unknown
|
||||
// because the import here is the typed wrapper module shape.
|
||||
const { memoryTreeGetLlm, memoryTreeSetLlm } =
|
||||
(await import('../../../utils/tauriCommands')) as unknown as {
|
||||
memoryTreeGetLlm: Mock;
|
||||
memoryTreeSetLlm: Mock;
|
||||
};
|
||||
|
||||
describe('IntelligenceSettingsTab', () => {
|
||||
beforeEach(() => {
|
||||
let backend: 'cloud' | 'local' = 'cloud';
|
||||
memoryTreeGetLlm.mockReset();
|
||||
memoryTreeSetLlm.mockReset();
|
||||
memoryTreeGetLlm.mockImplementation(async () => ({ current: backend }));
|
||||
// Accept both legacy (bare string) and the new request-object shape so
|
||||
// tests can assert on either call form.
|
||||
memoryTreeSetLlm.mockImplementation(
|
||||
async (req: 'cloud' | 'local' | { backend: 'cloud' | 'local' }) => {
|
||||
backend = typeof req === 'string' ? req : req.backend;
|
||||
return { current: backend };
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Helper: bootstrap into Local mode so the model assignment + catalog
|
||||
// render. Cloud is the default; clicking the Advanced radio flips to
|
||||
// local and renders the Ollama-related sections.
|
||||
async function flipToLocal() {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AI backend')).toBeInTheDocument();
|
||||
});
|
||||
const radios = screen.getAllByRole('radio');
|
||||
const localCard = radios.find(el => /Advanced/.test(el.textContent ?? ''));
|
||||
expect(localCard).toBeDefined();
|
||||
fireEvent.click(localCard!);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Model assignment')).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
|
||||
it('renders the AI backend section in cloud mode (no local sections)', async () => {
|
||||
renderWithProviders(<IntelligenceSettingsTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AI backend')).toBeInTheDocument();
|
||||
});
|
||||
// Cloud is default — local-only sections are hidden so cloud users
|
||||
// never see Ollama-related UI.
|
||||
expect(screen.queryByText('Model assignment')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Model catalog')).not.toBeInTheDocument();
|
||||
// Currently-loaded panel was removed entirely (was dev-debug noise).
|
||||
expect(screen.queryByText('Currently loaded')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Model assignment in Cloud mode and reveals it in Local mode', async () => {
|
||||
renderWithProviders(<IntelligenceSettingsTab />);
|
||||
await flipToLocal();
|
||||
|
||||
// The new UI consolidates Extract + Summariser LLM into a single
|
||||
// Memory LLM picker (the underlying RPC still fans out to both
|
||||
// extract_model and summariser_model in config.toml).
|
||||
expect(screen.getByText('Memory LLM')).toBeInTheDocument();
|
||||
expect(screen.getByText('Embedder')).toBeInTheDocument();
|
||||
// Old separate dropdowns must be absent.
|
||||
expect(screen.queryByText('Extract LLM')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Summariser LLM')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows model catalog rows with sizes (in local mode)', async () => {
|
||||
renderWithProviders(<IntelligenceSettingsTab />);
|
||||
await flipToLocal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('qwen2.5:0.5b').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
// Each model can appear in the Memory LLM dropdown AND the catalog,
|
||||
// so use getAllByText. Just confirm the catalog has at least one of
|
||||
// each curated entry rendered somewhere on the screen.
|
||||
expect(screen.getAllByText('gemma3:1b-it-qat').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('llama3.1:8b').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('bge-m3').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// 4.9 GB is unique to llama3.1:8b in the catalog row meta.
|
||||
expect(screen.getByText('4.9 GB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a Download action for models that are not installed', async () => {
|
||||
renderWithProviders(<IntelligenceSettingsTab />);
|
||||
await flipToLocal();
|
||||
|
||||
// qwen2.5:0.5b is NOT in the diagnostics installed list, so it shows
|
||||
// a Download button.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('qwen2.5:0.5b')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const downloadButtons = screen.getAllByRole('button', { name: 'Download' });
|
||||
expect(downloadButtons.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('reads the backend via memoryTreeGetLlm on mount and persists toggles via memoryTreeSetLlm', async () => {
|
||||
renderWithProviders(<IntelligenceSettingsTab />);
|
||||
|
||||
// Bootstrap: getMemoryTreeLlm must run once on mount.
|
||||
await waitFor(() => {
|
||||
expect(memoryTreeGetLlm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Click Local — setMemoryTreeLlm must be called with the request
|
||||
// object form `{ backend: 'local' }`. settingsApi.ts always normalizes
|
||||
// to the request-object shape because the wrapper now accepts both
|
||||
// forms but the API layer translates camelCase options through the
|
||||
// object shape. Model fields are absent so the corresponding
|
||||
// config keys stay untouched.
|
||||
const radios = screen.getAllByRole('radio');
|
||||
const localCard = radios.find(el => /Advanced/.test(el.textContent ?? ''));
|
||||
fireEvent.click(localCard!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(memoryTreeSetLlm).toHaveBeenCalledWith({ backend: 'local' });
|
||||
});
|
||||
|
||||
// The mocked setter persists state in the closure, so the bootstrap
|
||||
// value of any subsequent get_llm call would now be 'local' — sanity
|
||||
// check that the closure flipped.
|
||||
const after = await memoryTreeGetLlm();
|
||||
expect(after.current).toBe('local');
|
||||
});
|
||||
|
||||
it('persists Memory LLM dropdown changes via memoryTreeSetLlm with both extract_model and summariser_model', async () => {
|
||||
// The single Memory LLM picker fans out to BOTH extract_model and
|
||||
// summariser_model in one atomic write — the underlying schema keeps
|
||||
// the two keys separate so power users can split via the RPC, but the
|
||||
// UI consolidates them into one cognitive unit.
|
||||
renderWithProviders(<IntelligenceSettingsTab />);
|
||||
await flipToLocal();
|
||||
|
||||
// Reset call history so the assertion below is scoped to the
|
||||
// dropdown change, not the earlier backend toggle.
|
||||
memoryTreeSetLlm.mockClear();
|
||||
|
||||
// Pick a different memory LLM. `llama3.1:8b` is in the curated
|
||||
// catalog with both `extract` and `summariser` roles.
|
||||
const memorySelect = screen.getByLabelText(
|
||||
'Memory LLM (extract + summarise)'
|
||||
) as HTMLSelectElement;
|
||||
fireEvent.change(memorySelect, { target: { value: 'llama3.1:8b' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(memoryTreeSetLlm).toHaveBeenCalledWith({
|
||||
backend: 'local',
|
||||
extract_model: 'llama3.1:8b',
|
||||
summariser_model: 'llama3.1:8b',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Chunk } from '../../../utils/tauriCommands';
|
||||
import { MemoryChunkLetterhead } from '../MemoryChunkLetterhead';
|
||||
|
||||
const BASE_CHUNK: Chunk = {
|
||||
id: 'chunk-letterhead-01',
|
||||
source_kind: 'email',
|
||||
source_id: 'gmail:steve@example.com|sanil@vezures.xyz',
|
||||
source_ref: 'gmail://msg/abc',
|
||||
owner: 'sanil@vezures.xyz',
|
||||
timestamp_ms: Date.UTC(2026, 4, 4, 9, 14, 0),
|
||||
token_count: 100,
|
||||
lifecycle_status: 'admitted',
|
||||
content_preview: 'hello',
|
||||
has_embedding: true,
|
||||
tags: [],
|
||||
};
|
||||
|
||||
describe('MemoryChunkLetterhead', () => {
|
||||
it('renders the from/to/date frontmatter from a personalized email source', () => {
|
||||
const chunk: Chunk = { ...BASE_CHUNK, tags: ['person/Steven-Enamakel'] };
|
||||
render(<MemoryChunkLetterhead chunk={chunk} />);
|
||||
|
||||
expect(screen.getByText('from')).toBeInTheDocument();
|
||||
expect(screen.getByText('to')).toBeInTheDocument();
|
||||
// Person tag wins over the raw email handle as the display name.
|
||||
expect(screen.getByText('Steven Enamakel')).toBeInTheDocument();
|
||||
// The raw address is rendered as secondary text.
|
||||
expect(screen.getByText('steve@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('sanil@vezures.xyz')).toBeInTheDocument();
|
||||
// Date formatted as YYYY·MM·DD · HH:MM utc (UTC components).
|
||||
expect(screen.getByText('2026·05·04 · 09:14 utc')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the raw email when no person/* tag is present', () => {
|
||||
render(<MemoryChunkLetterhead chunk={BASE_CHUNK} />);
|
||||
// Without a person tag, fromName === the raw email.
|
||||
expect(screen.getByText('steve@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the chunk owner when the source_id has no recipient half', () => {
|
||||
const chunk: Chunk = {
|
||||
...BASE_CHUNK,
|
||||
source_id: 'notion:launch-plan',
|
||||
owner: 'sanil@vezures.xyz',
|
||||
};
|
||||
render(<MemoryChunkLetterhead chunk={chunk} />);
|
||||
// No `|` → recipient defaults to owner.
|
||||
expect(screen.getByText('sanil@vezures.xyz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses source_kind as the display when source_id is bare', () => {
|
||||
const chunk: Chunk = { ...BASE_CHUNK, source_kind: 'doc', source_id: '', tags: [] };
|
||||
render(<MemoryChunkLetterhead chunk={chunk} />);
|
||||
// Empty source_id → fromName falls back to the source_kind label.
|
||||
expect(screen.getByText('doc')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { EntityRef } from '../../../utils/tauriCommands';
|
||||
import { MemoryChunkMentioned } from '../MemoryChunkMentioned';
|
||||
|
||||
const ENTITIES: EntityRef[] = [
|
||||
{ entity_id: 'person:steve', kind: 'person', surface: 'Steven Enamakel', count: 4 },
|
||||
{ entity_id: 'org:tinyhumans', kind: 'organization', surface: 'TinyHumans', count: 1 },
|
||||
{ entity_id: 'event:launch', kind: 'event', surface: 'Phoenix launch', count: 7 },
|
||||
];
|
||||
|
||||
describe('MemoryChunkMentioned', () => {
|
||||
it('renders nothing when the entity list is empty', () => {
|
||||
const { container } = render(<MemoryChunkMentioned entities={[]} onSelectEntity={vi.fn()} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders one row per entity with kind, surface, and a pluralised count', () => {
|
||||
render(<MemoryChunkMentioned entities={ENTITIES} onSelectEntity={vi.fn()} />);
|
||||
expect(screen.getByText('Steven Enamakel')).toBeInTheDocument();
|
||||
expect(screen.getByText('TinyHumans')).toBeInTheDocument();
|
||||
expect(screen.getByText('Phoenix launch')).toBeInTheDocument();
|
||||
|
||||
// Singular vs plural — the surface display has to switch on count.
|
||||
expect(screen.getByText('1 chunk')).toBeInTheDocument();
|
||||
expect(screen.getByText('4 chunks')).toBeInTheDocument();
|
||||
expect(screen.getByText('7 chunks')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires onSelectEntity with the clicked entity', () => {
|
||||
const onSelectEntity = vi.fn();
|
||||
render(<MemoryChunkMentioned entities={ENTITIES} onSelectEntity={onSelectEntity} />);
|
||||
|
||||
fireEvent.click(screen.getByText('TinyHumans').closest('button')!);
|
||||
expect(onSelectEntity).toHaveBeenCalledTimes(1);
|
||||
expect(onSelectEntity).toHaveBeenCalledWith(ENTITIES[1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ScoreBreakdown } from '../../../utils/tauriCommands';
|
||||
import { MemoryChunkScoreBars } from '../MemoryChunkScoreBars';
|
||||
|
||||
describe('MemoryChunkScoreBars', () => {
|
||||
it('renders one row per signal with a clamped, formatted value', () => {
|
||||
const breakdown: ScoreBreakdown = {
|
||||
total: 0.65,
|
||||
threshold: 0.5,
|
||||
kept: true,
|
||||
llm_consulted: false,
|
||||
signals: [
|
||||
{ name: 'recency', weight: 0.5, value: 0.83 },
|
||||
{ name: 'salience', weight: 0.3, value: 0.4 },
|
||||
// Out-of-range and NaN both clamp to 0..1 — the bar must not crash
|
||||
// or render past the track.
|
||||
{ name: 'pinned', weight: 0.1, value: 1.7 },
|
||||
{ name: 'broken', weight: 0.1, value: Number.NaN },
|
||||
],
|
||||
};
|
||||
render(<MemoryChunkScoreBars breakdown={breakdown} />);
|
||||
|
||||
expect(screen.getByText('recency')).toBeInTheDocument();
|
||||
expect(screen.getByText('0.83')).toBeInTheDocument();
|
||||
expect(screen.getByText('0.40')).toBeInTheDocument();
|
||||
// Clamped to 1.00 (over-range) and 0.00 (NaN).
|
||||
expect(screen.getByText('1.00')).toBeInTheDocument();
|
||||
expect(screen.getByText('0.00')).toBeInTheDocument();
|
||||
|
||||
// ARIA labels on the bars are how a screen reader would surface the
|
||||
// percentage; check the over-range one collapsed to "100 percent".
|
||||
expect(screen.getByLabelText('pinned score 100 percent')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('broken score 0 percent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the threshold footer with kept/dropped state', () => {
|
||||
const breakdown: ScoreBreakdown = {
|
||||
total: 0.2,
|
||||
threshold: 0.5,
|
||||
kept: false,
|
||||
llm_consulted: false,
|
||||
signals: [{ name: 'recency', weight: 1, value: 0.2 }],
|
||||
};
|
||||
render(<MemoryChunkScoreBars breakdown={breakdown} />);
|
||||
expect(screen.getByText(/dropped at 0\.50/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,257 +1,279 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
import type { Chunk, EntityRef, ScoreBreakdown, Source } from '../../../utils/tauriCommands';
|
||||
import { MemoryWorkspace } from '../MemoryWorkspace';
|
||||
|
||||
// Mock useIntelligenceStats — the hook used by MemoryWorkspace
|
||||
vi.mock('../../../hooks/useIntelligenceStats', () => ({
|
||||
useIntelligenceStats: () => ({
|
||||
sessions: { total: 5, totalTokens: 1200 },
|
||||
memoryFiles: 3,
|
||||
entities: { contact: 2, message: 10 },
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock channelConnectionsApi so listStatus doesn't hit the network
|
||||
vi.mock('../../../services/api/channelConnectionsApi', () => ({
|
||||
channelConnectionsApi: {
|
||||
listStatus: vi.fn().mockResolvedValue([
|
||||
{
|
||||
channel_id: 'telegram-main',
|
||||
auth_mode: 'managed_dm',
|
||||
connected: true,
|
||||
has_credentials: true,
|
||||
},
|
||||
{ channel_id: 'discord-bot', auth_mode: 'bot_token', connected: true, has_credentials: true },
|
||||
]),
|
||||
},
|
||||
}));
|
||||
|
||||
// Override the global tauriCommands mock from setup.ts with memory-specific stubs
|
||||
// The MemoryWorkspace orchestrator + its detail-pane child both fan out
|
||||
// to the `memory_tree_*` JSON-RPC wrappers. The setup.ts global mock
|
||||
// stubs auth helpers; we extend it here with the read-side surface so
|
||||
// the workspace can render against a deterministic fixture set.
|
||||
vi.mock('../../../utils/tauriCommands', () => ({
|
||||
isTauri: vi.fn(() => true),
|
||||
memoryListDocuments: vi.fn().mockResolvedValue({
|
||||
documents: [
|
||||
{ documentId: 'doc-1', namespace: 'research', title: 'Paper A' },
|
||||
{ documentId: 'doc-2', namespace: 'research', title: 'Paper B' },
|
||||
],
|
||||
}),
|
||||
memoryListNamespaces: vi.fn().mockResolvedValue(['research', 'conversations']),
|
||||
aiListMemoryFiles: vi.fn().mockResolvedValue(['2026-03-31.md']),
|
||||
aiReadMemoryFile: vi.fn().mockResolvedValue('# Memory\nSome content'),
|
||||
aiWriteMemoryFile: vi.fn().mockResolvedValue(undefined),
|
||||
memoryDeleteDocument: vi.fn().mockResolvedValue(undefined),
|
||||
memoryQueryNamespace: vi.fn().mockResolvedValue({ text: 'query result', entities: [] }),
|
||||
memoryRecallNamespace: vi.fn().mockResolvedValue({ text: 'recall result', entities: [] }),
|
||||
memorySyncAll: vi.fn().mockResolvedValue({ requested: true }),
|
||||
memorySyncChannel: vi.fn().mockResolvedValue({ requested: true, channel_id: 'telegram-main' }),
|
||||
memoryLearnAll: vi.fn().mockResolvedValue({
|
||||
namespaces_processed: 2,
|
||||
results: [
|
||||
{ namespace: 'research', status: 'ok' },
|
||||
{ namespace: 'conversations', status: 'ok' },
|
||||
],
|
||||
}),
|
||||
memoryGraphQuery: vi.fn().mockResolvedValue([
|
||||
{
|
||||
namespace: 'research',
|
||||
subject: 'Alice',
|
||||
predicate: 'AUTHORED',
|
||||
object: 'Paper A',
|
||||
attrs: { entity_types: { subject: 'person', object: 'document' } },
|
||||
updatedAt: 1700000000,
|
||||
evidenceCount: 3,
|
||||
orderIndex: null,
|
||||
documentIds: ['doc-1'],
|
||||
chunkIds: ['doc-1#chunk-1'],
|
||||
},
|
||||
{
|
||||
namespace: 'research',
|
||||
subject: 'Bob',
|
||||
predicate: 'REVIEWED',
|
||||
object: 'Paper A',
|
||||
attrs: { entity_types: { subject: 'person', object: 'document' } },
|
||||
updatedAt: 1700000001,
|
||||
evidenceCount: 1,
|
||||
orderIndex: null,
|
||||
documentIds: ['doc-1'],
|
||||
chunkIds: [],
|
||||
},
|
||||
]),
|
||||
memoryTreeListChunks: vi.fn(),
|
||||
memoryTreeListSources: vi.fn(),
|
||||
memoryTreeTopEntities: vi.fn(),
|
||||
memoryTreeEntityIndexFor: vi.fn(),
|
||||
memoryTreeChunkScore: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('MemoryWorkspace', () => {
|
||||
const onToast = vi.fn();
|
||||
const {
|
||||
memoryTreeListChunks,
|
||||
memoryTreeListSources,
|
||||
memoryTreeTopEntities,
|
||||
memoryTreeEntityIndexFor,
|
||||
memoryTreeChunkScore,
|
||||
} = (await import('../../../utils/tauriCommands')) as unknown as {
|
||||
memoryTreeListChunks: Mock;
|
||||
memoryTreeListSources: Mock;
|
||||
memoryTreeTopEntities: Mock;
|
||||
memoryTreeEntityIndexFor: Mock;
|
||||
memoryTreeChunkScore: Mock;
|
||||
};
|
||||
|
||||
it('renders the Memory heading', async () => {
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
expect(screen.getByText('Memory')).toBeInTheDocument();
|
||||
// ── Fixtures — small but realistic ───────────────────────────────────────
|
||||
|
||||
const NOW_MS = Date.UTC(2026, 4, 4, 9, 14, 0);
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
const FIXTURE_CHUNKS: Chunk[] = [
|
||||
{
|
||||
id: 'chunk-today-01',
|
||||
source_kind: 'email',
|
||||
source_id: 'gmail:enamakel@mail.tinyhumans.ai|sanil@vezures.xyz',
|
||||
source_ref: 'gmail://msg/aaa',
|
||||
owner: 'sanil@vezures.xyz',
|
||||
timestamp_ms: NOW_MS,
|
||||
token_count: 312,
|
||||
lifecycle_status: 'admitted',
|
||||
content_preview:
|
||||
'welcome to the future of ai assistants — openhuman. hey hey Sanil Jain! steve here.',
|
||||
has_embedding: true,
|
||||
tags: ['person/Steven-Enamakel', 'organization/TinyHumans', 'product/openhuman'],
|
||||
},
|
||||
{
|
||||
id: 'chunk-today-02',
|
||||
source_kind: 'email',
|
||||
source_id: 'gmail:notifications@github.com|sanil@vezures.xyz',
|
||||
source_ref: 'gmail://msg/bbb',
|
||||
owner: 'sanil@vezures.xyz',
|
||||
timestamp_ms: NOW_MS - 90 * 60 * 1000,
|
||||
token_count: 94,
|
||||
lifecycle_status: 'admitted',
|
||||
content_preview: '[tinyhumansai/openhuman] PR #1175 merged.',
|
||||
has_embedding: true,
|
||||
tags: ['organization/GitHub', 'product/openhuman', 'event/pr-merged'],
|
||||
},
|
||||
{
|
||||
id: 'chunk-today-03',
|
||||
source_kind: 'chat',
|
||||
source_id: 'slack:T0123|C-engineering',
|
||||
source_ref: 'slack://channel/eng/p1',
|
||||
owner: 'sanil@vezures.xyz',
|
||||
timestamp_ms: NOW_MS - 3 * HOUR,
|
||||
token_count: 47,
|
||||
lifecycle_status: 'admitted',
|
||||
content_preview: 'maya patel: pushed the staging chart fix',
|
||||
has_embedding: true,
|
||||
tags: ['person/Maya-Patel', 'organization/TinyHumans'],
|
||||
},
|
||||
];
|
||||
|
||||
const FIXTURE_SOURCES: Source[] = [
|
||||
{
|
||||
source_id: 'gmail:enamakel@mail.tinyhumans.ai|sanil@vezures.xyz',
|
||||
display_name: 'Steven Enamakel',
|
||||
source_kind: 'email',
|
||||
chunk_count: 1,
|
||||
most_recent_ms: NOW_MS,
|
||||
lifecycle_status: 'admitted',
|
||||
},
|
||||
{
|
||||
source_id: 'gmail:notifications@github.com|sanil@vezures.xyz',
|
||||
display_name: 'GitHub notifications',
|
||||
source_kind: 'email',
|
||||
chunk_count: 1,
|
||||
most_recent_ms: NOW_MS - 90 * 60 * 1000,
|
||||
lifecycle_status: 'admitted',
|
||||
},
|
||||
{
|
||||
source_id: 'slack:T0123|C-engineering',
|
||||
display_name: 'Slack: #engineering',
|
||||
source_kind: 'chat',
|
||||
chunk_count: 1,
|
||||
most_recent_ms: NOW_MS - 3 * HOUR,
|
||||
lifecycle_status: 'admitted',
|
||||
},
|
||||
];
|
||||
|
||||
const FIXTURE_PEOPLE: EntityRef[] = [
|
||||
{ entity_id: 'person:Steven Enamakel', kind: 'person', surface: 'Steven Enamakel', count: 2 },
|
||||
{ entity_id: 'person:Maya Patel', kind: 'person', surface: 'Maya Patel', count: 1 },
|
||||
];
|
||||
|
||||
const FIXTURE_TOPICS: EntityRef[] = [
|
||||
{ entity_id: 'product:openhuman', kind: 'product', surface: 'openhuman', count: 3 },
|
||||
{ entity_id: 'event:pr-merged', kind: 'event', surface: 'pr-merged', count: 1 },
|
||||
];
|
||||
|
||||
const FIXTURE_SCORE: ScoreBreakdown = {
|
||||
signals: [
|
||||
{ name: 'source', weight: 0.3, value: 0.8 },
|
||||
{ name: 'entities', weight: 0.4, value: 0.7 },
|
||||
{ name: 'recency', weight: 0.3, value: 0.9 },
|
||||
],
|
||||
total: 0.79,
|
||||
threshold: 0.85,
|
||||
kept: true,
|
||||
llm_consulted: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
memoryTreeListChunks.mockReset();
|
||||
memoryTreeListSources.mockReset();
|
||||
memoryTreeTopEntities.mockReset();
|
||||
memoryTreeEntityIndexFor.mockReset();
|
||||
memoryTreeChunkScore.mockReset();
|
||||
|
||||
memoryTreeListChunks.mockResolvedValue({ chunks: FIXTURE_CHUNKS, total: FIXTURE_CHUNKS.length });
|
||||
memoryTreeListSources.mockResolvedValue(FIXTURE_SOURCES);
|
||||
// The workspace calls topEntities twice: ('person', 12) and (undefined, 40).
|
||||
memoryTreeTopEntities.mockImplementation((kind?: string) => {
|
||||
if (kind === 'person') return Promise.resolve(FIXTURE_PEOPLE);
|
||||
return Promise.resolve([...FIXTURE_PEOPLE, ...FIXTURE_TOPICS]);
|
||||
});
|
||||
memoryTreeEntityIndexFor.mockResolvedValue([
|
||||
{ entity_id: 'person:Steven Enamakel', kind: 'person', surface: 'Steven Enamakel', count: 1 },
|
||||
{ entity_id: 'organization:TinyHumans', kind: 'organization', surface: 'TinyHumans', count: 1 },
|
||||
]);
|
||||
memoryTreeChunkScore.mockResolvedValue(FIXTURE_SCORE);
|
||||
});
|
||||
|
||||
it('displays graph relations after loading', async () => {
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
|
||||
describe('MemoryWorkspace — 2-pane + overlay browser', () => {
|
||||
it('renders the navigator + result list scaffold and the search box', async () => {
|
||||
renderWithProviders(<MemoryWorkspace />);
|
||||
// Workspace renders the empty placeholder until the first fixture
|
||||
// round-trip lands — the full 2-pane shell only mounts once allChunks
|
||||
// is populated. Wait for the post-load state, then assert all four
|
||||
// anchors exist together.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alice', { selector: 'span' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('memory-workspace')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('AUTHORED', { selector: 'span' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob', { selector: 'span' })).toBeInTheDocument();
|
||||
expect(screen.getByText('REVIEWED', { selector: 'span' })).toBeInTheDocument();
|
||||
// "Paper A" appears in both graph relations and documents list,
|
||||
// so just verify at least one instance is present
|
||||
expect(screen.getAllByText('Paper A').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByTestId('memory-navigator')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('memory-result-list')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Search memory')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows evidence count badge when > 1', async () => {
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
|
||||
it('calls the canonical memory_tree_* RPCs on mount', async () => {
|
||||
renderWithProviders(<MemoryWorkspace />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('x3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Bob's relation has evidenceCount 1 — should NOT show a badge
|
||||
expect(screen.queryByText('x1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Relations stat in the stats bar', async () => {
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
|
||||
// The stats bar has a "Relations" label
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Relations')).toBeInTheDocument();
|
||||
expect(memoryTreeListChunks).toHaveBeenCalledWith({ limit: 500 });
|
||||
expect(memoryTreeListSources).toHaveBeenCalled();
|
||||
expect(memoryTreeTopEntities).toHaveBeenCalledWith('person', 12);
|
||||
expect(memoryTreeTopEntities).toHaveBeenCalledWith(undefined, 40);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the Memory Graph section', async () => {
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
it('renders navigator section headings (recent, sources, people, topics)', async () => {
|
||||
renderWithProviders(<MemoryWorkspace />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('recent')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('sources')).toBeInTheDocument();
|
||||
expect(screen.getByText('people')).toBeInTheDocument();
|
||||
expect(screen.getByText('topics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT auto-open the detail overlay on mount (2-pane is the default rest state)', async () => {
|
||||
renderWithProviders(<MemoryWorkspace />);
|
||||
// Wait for fixtures to land so we know the workspace is fully rendered.
|
||||
await waitFor(() => screen.getByTestId('memory-result-list'));
|
||||
// The new layout opens detail only on row click; no overlay until then.
|
||||
expect(screen.queryByTestId('memory-chunk-detail')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the Sources section count + per-kind nesting after the load resolves', async () => {
|
||||
renderWithProviders(<MemoryWorkspace />);
|
||||
// Inside the Sources NavSection, fixtures group into Email (2) + Chat (1).
|
||||
// Each per-kind sub-section is rendered as its own NavSection — closed by
|
||||
// default, but the labels are visible.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Email')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chat')).toBeInTheDocument();
|
||||
});
|
||||
// Person entities (from FIXTURE_PEOPLE) ARE visible by default — the
|
||||
// people NavSection is `defaultOpen`.
|
||||
expect(screen.getAllByText('Steven Enamakel').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Maya Patel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('typing in the search box narrows the result-list rows', async () => {
|
||||
renderWithProviders(<MemoryWorkspace />);
|
||||
await waitFor(() => {
|
||||
const rows = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
|
||||
expect(rows.length).toBe(FIXTURE_CHUNKS.length);
|
||||
});
|
||||
|
||||
const search = screen.getByLabelText('Search memory') as HTMLInputElement;
|
||||
fireEvent.change(search, { target: { value: 'PR #1175' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Memory Graph')).toBeInTheDocument();
|
||||
const visible = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
|
||||
expect(visible.length).toBe(1);
|
||||
expect(visible[0]?.textContent ?? '').toMatch(/PR #1175|github/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the detail overlay when a result row is clicked', async () => {
|
||||
renderWithProviders(<MemoryWorkspace />);
|
||||
await waitFor(() => {
|
||||
const rows = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const rows = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
|
||||
fireEvent.click(rows[0]!);
|
||||
|
||||
await waitFor(() => {
|
||||
// Detail overlay mounts the ChunkDetail (data-testid="memory-chunk-detail")
|
||||
// along with the letterhead — both show only after a row click in the
|
||||
// 2-pane + overlay layout.
|
||||
expect(screen.getByTestId('memory-chunk-detail')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('memory-chunk-letterhead')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes the detail overlay on Escape key', async () => {
|
||||
renderWithProviders(<MemoryWorkspace />);
|
||||
await waitFor(() => {
|
||||
const rows = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const rows = screen.getAllByRole('button').filter(b => b.dataset.chunkId);
|
||||
fireEvent.click(rows[0]!);
|
||||
await waitFor(() => screen.getByTestId('memory-chunk-detail'));
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('memory-chunk-detail')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MemoryWorkspace – no graph relations', () => {
|
||||
const onToast = vi.fn();
|
||||
describe('MemoryWorkspace — empty state', () => {
|
||||
it('renders the empty placeholder when the core returns zero chunks', async () => {
|
||||
memoryTreeListChunks.mockResolvedValueOnce({ chunks: [], total: 0 });
|
||||
memoryTreeListSources.mockResolvedValueOnce([]);
|
||||
memoryTreeTopEntities.mockResolvedValue([]);
|
||||
|
||||
it('shows empty-state message when no relations exist', async () => {
|
||||
// Override only memoryGraphQuery to return empty
|
||||
const tauriMod = await import('../../../utils/tauriCommands');
|
||||
vi.mocked(tauriMod.memoryGraphQuery).mockResolvedValueOnce([]);
|
||||
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
renderWithProviders(<MemoryWorkspace />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No memory graph data yet')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MemoryWorkspace – non-Tauri environment', () => {
|
||||
const onToast = vi.fn();
|
||||
|
||||
it('shows Tauri-required warning when not running in Tauri', async () => {
|
||||
const tauriMod = await import('../../../utils/tauriCommands');
|
||||
vi.mocked(tauriMod.isTauri).mockReturnValue(false);
|
||||
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Memory workspace requires the desktop Tauri runtime to load real data.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Restore for other tests
|
||||
vi.mocked(tauriMod.isTauri).mockReturnValue(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MemoryWorkspace – Sync section', () => {
|
||||
const onToast = vi.fn();
|
||||
|
||||
it('renders the Sync collapsible button', async () => {
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sync')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('expands and shows Sync all button when toggled', async () => {
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sync')).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByText('Sync').closest('button')!);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sync all')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls memorySyncAll when Sync all button clicked', async () => {
|
||||
const tauriMod = await import('../../../utils/tauriCommands');
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
await waitFor(() => screen.getByText('Sync'));
|
||||
fireEvent.click(screen.getByText('Sync').closest('button')!);
|
||||
await waitFor(() => screen.getByText('Sync all'));
|
||||
fireEvent.click(screen.getByText('Sync all'));
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(tauriMod.memorySyncAll)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls memorySyncChannel when per-channel Sync button clicked', async () => {
|
||||
const tauriMod = await import('../../../utils/tauriCommands');
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
await waitFor(() => screen.getByText('Sync'));
|
||||
fireEvent.click(screen.getByText('Sync').closest('button')!);
|
||||
await waitFor(() => screen.getAllByText('Sync').length > 1);
|
||||
// The per-channel sync buttons appear after channels load
|
||||
await waitFor(() => screen.getByText('telegram-main'));
|
||||
const syncBtns = screen.getAllByText('Sync');
|
||||
// Last Sync buttons are per-channel (first is the header)
|
||||
fireEvent.click(syncBtns[syncBtns.length - 1]);
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(tauriMod.memorySyncChannel)).toHaveBeenCalledWith('discord-bot');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MemoryWorkspace – Learn section', () => {
|
||||
const onToast = vi.fn();
|
||||
|
||||
it('renders the Learn collapsible button', async () => {
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Learn')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('expands and shows Learn all button when toggled', async () => {
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
await waitFor(() => screen.getByText('Learn'));
|
||||
fireEvent.click(screen.getByText('Learn').closest('button')!);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Learn all')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls memoryLearnAll and shows result summary', async () => {
|
||||
const tauriMod = await import('../../../utils/tauriCommands');
|
||||
renderWithProviders(<MemoryWorkspace onToast={onToast} />);
|
||||
await waitFor(() => screen.getByText('Learn'));
|
||||
fireEvent.click(screen.getByText('Learn').closest('button')!);
|
||||
await waitFor(() => screen.getByText('Learn all'));
|
||||
fireEvent.click(screen.getByText('Learn all'));
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(tauriMod.memoryLearnAll)).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/2 processed/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('memory-empty-placeholder')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Nothing yet.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import ModelCatalog from '../ModelCatalog';
|
||||
|
||||
describe('ModelCatalog', () => {
|
||||
it('renders one row per recommended model', () => {
|
||||
render(
|
||||
<ModelCatalog
|
||||
installedModelIds={[]}
|
||||
activeModelIds={[]}
|
||||
onDownload={vi.fn()}
|
||||
onUse={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// Each id from RECOMMENDED_MODEL_CATALOG appears as a row title.
|
||||
expect(screen.getByText('qwen2.5:0.5b')).toBeInTheDocument();
|
||||
expect(screen.getByText('gemma3:1b-it-qat')).toBeInTheDocument();
|
||||
expect(screen.getByText('llama3.1:8b')).toBeInTheDocument();
|
||||
expect(screen.getByText('bge-m3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Download" for models that are not installed', () => {
|
||||
render(
|
||||
<ModelCatalog
|
||||
installedModelIds={[]}
|
||||
activeModelIds={[]}
|
||||
onDownload={vi.fn()}
|
||||
onUse={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// Four models, all available → four Download buttons.
|
||||
expect(screen.getAllByRole('button', { name: /download/i })).toHaveLength(4);
|
||||
expect(screen.getAllByText('not downloaded').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows "Use" for installed-but-not-active models, "in use" for active', () => {
|
||||
render(
|
||||
<ModelCatalog
|
||||
installedModelIds={['gemma3:1b-it-qat', 'bge-m3:latest']}
|
||||
activeModelIds={['bge-m3']}
|
||||
onDownload={vi.fn()}
|
||||
onUse={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// bge-m3 is installed AND active → "in use" pill, no Use button for it.
|
||||
expect(screen.getAllByText('in use').length).toBeGreaterThan(0);
|
||||
// gemma3 is installed but not active → Use button visible.
|
||||
expect(screen.getByRole('button', { name: 'Use' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('matches `bge-m3` against `bge-m3:latest` via the :latest normalization', () => {
|
||||
// Ollama tags everything as `:latest` by default; the catalog uses bare
|
||||
// names. The component must treat them as the same id.
|
||||
render(
|
||||
<ModelCatalog
|
||||
installedModelIds={['bge-m3:latest']}
|
||||
activeModelIds={[]}
|
||||
onDownload={vi.fn()}
|
||||
onUse={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// bge-m3 row is now in the "installed" state — at least one Use button
|
||||
// appears (for bge-m3 specifically).
|
||||
expect(screen.getByRole('button', { name: 'Use' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires onUse with the matching model when the Use button is clicked', () => {
|
||||
const onUse = vi.fn();
|
||||
render(
|
||||
<ModelCatalog
|
||||
installedModelIds={['llama3.1:8b']}
|
||||
activeModelIds={[]}
|
||||
onDownload={vi.fn()}
|
||||
onUse={onUse}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use' }));
|
||||
expect(onUse).toHaveBeenCalledTimes(1);
|
||||
expect(onUse.mock.calls[0][0]).toMatchObject({ id: 'llama3.1:8b' });
|
||||
});
|
||||
|
||||
it('renders Delete buttons only when onDelete is provided', () => {
|
||||
const { rerender } = render(
|
||||
<ModelCatalog
|
||||
installedModelIds={['llama3.1:8b']}
|
||||
activeModelIds={[]}
|
||||
onDownload={vi.fn()}
|
||||
onUse={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByLabelText('Delete model')).toBeNull();
|
||||
|
||||
rerender(
|
||||
<ModelCatalog
|
||||
installedModelIds={['llama3.1:8b']}
|
||||
activeModelIds={[]}
|
||||
onDownload={vi.fn()}
|
||||
onUse={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByLabelText('Delete model')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a progress bar while a download is in flight, then clears it', async () => {
|
||||
let resolveDownload!: () => void;
|
||||
const onDownload = vi.fn(
|
||||
() =>
|
||||
new Promise<void>(resolve => {
|
||||
resolveDownload = resolve;
|
||||
})
|
||||
);
|
||||
render(
|
||||
<ModelCatalog
|
||||
installedModelIds={[]}
|
||||
activeModelIds={[]}
|
||||
onDownload={onDownload}
|
||||
onUse={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /download/i })[0]);
|
||||
|
||||
// Mid-flight: a progressbar is rendered for that row.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByRole('progressbar').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
resolveDownload();
|
||||
// After settle (~600 ms on success), the bar disappears and the row
|
||||
// returns to its post-install state. We just confirm the state
|
||||
// eventually clears — not the exact timing.
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.queryByRole('progressbar')).toBeNull();
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,617 @@
|
||||
/**
|
||||
* Locked design tokens for the three-pane MemoryWorkspace browser.
|
||||
*
|
||||
* Scoped under `.memory-workspace-root` so the rest of the app's surfaces
|
||||
* (which use the global stone/primary palette) are unaffected.
|
||||
*/
|
||||
.memory-workspace-root {
|
||||
/* Match the rest of the app's stone palette — dropped the cream tones
|
||||
(#faf7f2 / #f4f0e9) that read yellowish next to the bottom nav bar. */
|
||||
--paper: #fafaf9; /* stone-50 — base surface */
|
||||
--paper-elevated: #ffffff; /* white — active/selected/list */
|
||||
--paper-recessed: #f5f5f4; /* stone-100 — hover */
|
||||
--paper-recessed-darker: #e7e5e4; /* stone-200 — pressed */
|
||||
--hairline: #e7e5e4; /* stone-200 — borders */
|
||||
--ink: #1c1917;
|
||||
--ink-soft: #44403c;
|
||||
--ink-whisper: #78716c;
|
||||
--ocean: #4a83dd;
|
||||
--ocean-deep: #2e5baa;
|
||||
--ocean-mist: rgba(74, 131, 221, 0.08);
|
||||
--sage: #7a9b7e;
|
||||
--amber: #c8954d;
|
||||
--coral: #c26b5c;
|
||||
--shadow-rest: 0 1px 2px rgba(26, 31, 46, 0.04);
|
||||
--r-tight: 4px;
|
||||
--r-card: 8px;
|
||||
--r-room: 12px;
|
||||
}
|
||||
|
||||
.memory-workspace-root,
|
||||
.memory-workspace-root * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.memory-workspace-root {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
border-radius: var(--r-room);
|
||||
border: 1px solid var(--hairline);
|
||||
box-shadow: var(--shadow-rest);
|
||||
overflow: hidden;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.memory-workspace-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 380px 1fr;
|
||||
min-height: 640px;
|
||||
height: calc(100vh - 240px);
|
||||
max-height: 900px;
|
||||
}
|
||||
|
||||
.memory-workspace-grid > * + * {
|
||||
border-left: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.memory-workspace-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.memory-workspace-grid .mw-pane-detail {
|
||||
display: none;
|
||||
}
|
||||
.memory-workspace-grid.mw-show-detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.memory-workspace-grid.mw-show-detail .mw-pane-navigator,
|
||||
.memory-workspace-grid.mw-show-detail .mw-pane-results {
|
||||
display: none;
|
||||
}
|
||||
.memory-workspace-grid.mw-show-detail .mw-pane-detail {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.mw-pane-navigator {
|
||||
background: var(--paper-recessed);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mw-pane-results {
|
||||
background: var(--paper);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mw-pane-detail {
|
||||
background: var(--paper-elevated);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mw-pane-scroll {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Section headings — Inter uppercase, modern app-rail style. Replaces
|
||||
the Cabinet Grotesk lowercase tracked treatment which read like a
|
||||
magazine masthead in a left-rail context. */
|
||||
.mw-section-heading {
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-whisper);
|
||||
padding: 10px 16px 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
.mw-section-heading:hover {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.mw-section-chev {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
opacity: 0.5;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
.mw-section-chev.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.mw-section {
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.mw-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Nested sections — sources expanded shows Email/Slack/etc. as
|
||||
sub-collapsibles. Indent + tone-shift the nested level so the
|
||||
hierarchy reads visually. */
|
||||
.mw-section .mw-section {
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
border-left: 2px solid var(--hairline);
|
||||
margin: 0 0 0 12px;
|
||||
background: var(--paper-recessed);
|
||||
}
|
||||
.mw-section .mw-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.mw-section .mw-section .mw-section-heading {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 6px 12px 4px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.mw-section .mw-section .mw-section-heading:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
.mw-section .mw-section .mw-list-item {
|
||||
/* Source rows under a kind heading need a stronger indent so the
|
||||
hierarchy is unambiguous: kind heading at one level, individual
|
||||
senders deeper. Bump left padding so each row sits visibly under
|
||||
the kind's chevron, with a sub-rail line to continue the nesting. */
|
||||
padding: 5px 12px 5px 26px;
|
||||
position: relative;
|
||||
}
|
||||
.mw-section .mw-section .mw-list-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--hairline);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.mw-section .mw-section .mw-list-item.is-active::before {
|
||||
background: var(--ocean);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mw-search-row {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.mw-search-input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
padding: 6px 0;
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
font-style: italic;
|
||||
}
|
||||
.mw-search-input:focus {
|
||||
border-bottom-color: var(--ocean);
|
||||
}
|
||||
.mw-search-input::placeholder {
|
||||
color: var(--ink-whisper);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.mw-heatmap-host {
|
||||
padding: 8px 12px 12px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.mw-heatmap-host > div {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.mw-heatmap-host h3 {
|
||||
display: none;
|
||||
}
|
||||
.mw-heatmap-host p {
|
||||
font-size: 10px !important;
|
||||
color: var(--ink-whisper) !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.mw-recent-summary {
|
||||
padding: 8px 16px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
|
||||
}
|
||||
.mw-recent-summary span + span {
|
||||
margin-left: 8px;
|
||||
color: var(--ink-whisper);
|
||||
}
|
||||
|
||||
.mw-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
.mw-list-item {
|
||||
display: grid;
|
||||
grid-template-columns: 8px 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 6px 16px;
|
||||
cursor: pointer;
|
||||
border-left: 2px solid transparent;
|
||||
transition:
|
||||
background 80ms,
|
||||
border-color 80ms;
|
||||
background: transparent;
|
||||
border-top: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
.mw-list-item:hover {
|
||||
background: var(--paper-recessed-darker);
|
||||
}
|
||||
.mw-list-item.is-active {
|
||||
background: var(--paper-elevated); /* white — matches bottom nav active state */
|
||||
border-left-color: var(--ocean);
|
||||
box-shadow: 0 1px 2px rgba(28, 25, 23, 0.04);
|
||||
}
|
||||
.mw-list-item.is-active .mw-list-name {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
.mw-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--ink-whisper);
|
||||
}
|
||||
.mw-dot.dot-admitted {
|
||||
background: var(--sage);
|
||||
}
|
||||
.mw-dot.dot-pending {
|
||||
background: var(--amber);
|
||||
}
|
||||
.mw-dot.dot-buffered {
|
||||
background: var(--ocean);
|
||||
}
|
||||
.mw-dot.dot-dropped {
|
||||
background: var(--coral);
|
||||
}
|
||||
.mw-list-name {
|
||||
font-size: 14px;
|
||||
color: var(--ink-soft);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
.mw-list-count {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--ink-whisper);
|
||||
}
|
||||
|
||||
/* Result list */
|
||||
.mw-results-empty,
|
||||
.mw-detail-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.mw-results-empty {
|
||||
font-size: 13px;
|
||||
color: var(--ink-whisper);
|
||||
}
|
||||
.mw-detail-empty {
|
||||
font-family: 'Cabinet Grotesk', Inter, system-ui, sans-serif;
|
||||
}
|
||||
.mw-detail-empty .mw-empty-title {
|
||||
font-size: 22px;
|
||||
margin: 0 0 12px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.mw-detail-empty .mw-empty-body {
|
||||
font-size: 14px;
|
||||
color: var(--ink-whisper);
|
||||
max-width: 320px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.mw-results-section {
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.mw-results-section-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--paper);
|
||||
font-family: 'Cabinet Grotesk', Inter, sans-serif;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-whisper);
|
||||
padding: 10px 16px 8px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.mw-result-row {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
border-left: 2px solid transparent;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
transition: background 80ms;
|
||||
background: transparent;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
.mw-result-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.mw-result-row:hover {
|
||||
background: var(--paper-recessed);
|
||||
}
|
||||
.mw-result-row.is-active {
|
||||
background: var(--ocean-mist);
|
||||
border-left-color: var(--ocean);
|
||||
}
|
||||
.mw-result-row.is-active .mw-result-subject {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
.mw-result-time {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--ink-whisper);
|
||||
padding-top: 1px;
|
||||
}
|
||||
.mw-result-content {
|
||||
min-width: 0;
|
||||
}
|
||||
.mw-result-subject {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.mw-result-meta {
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mw-result-kind {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-whisper);
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 999px;
|
||||
background: var(--paper);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
/* Chunk detail letter */
|
||||
.mw-detail-scroll {
|
||||
padding: 40px 48px;
|
||||
}
|
||||
.mw-letter {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.mw-letterhead {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.mw-letterhead-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.mw-letterhead-label {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
text-transform: lowercase;
|
||||
color: var(--ink-whisper);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.mw-letterhead-label::after {
|
||||
content: '';
|
||||
display: block;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
width: 40px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mw-letterhead-value {
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.mw-letterhead-value-secondary {
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mw-letterhead-date {
|
||||
margin-top: 12px;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.mw-rule {
|
||||
border: none;
|
||||
border-top: 1px solid var(--hairline);
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.mw-letter-subject {
|
||||
font-family: 'Cabinet Grotesk', Inter, system-ui, sans-serif;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.mw-letter-body {
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: var(--ink);
|
||||
white-space: pre-wrap;
|
||||
/* `word-wrap` is the legacy alias of `overflow-wrap`; modern engines
|
||||
respect `overflow-wrap` directly without the legacy fallback. */
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.mw-letter-body p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
/* Mentioned section */
|
||||
.mw-mentioned-heading,
|
||||
.mw-whykept-heading {
|
||||
font-family: 'Cabinet Grotesk', Inter, sans-serif;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.3em;
|
||||
color: var(--ink-whisper);
|
||||
text-transform: lowercase;
|
||||
margin: 16px 0 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mw-mentioned-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.mw-mentioned-row {
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
/* Single padding declaration — was duplicated (`padding: 0` then
|
||||
`padding: 6px 0` later in the block). Combined into the right
|
||||
value below. */
|
||||
padding: 6px 0;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr auto;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--ink);
|
||||
align-items: baseline;
|
||||
text-align: left;
|
||||
border-bottom: 1px dotted var(--hairline);
|
||||
}
|
||||
.mw-mentioned-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.mw-mentioned-row:hover {
|
||||
background: var(--paper-recessed);
|
||||
}
|
||||
.mw-mentioned-kind {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--ink-whisper);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.mw-mentioned-surface {
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
}
|
||||
.mw-mentioned-count {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--ink-whisper);
|
||||
}
|
||||
|
||||
/* Score bars */
|
||||
.mw-scorebar-row {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr 60px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mw-scorebar-label {
|
||||
font-size: 13px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.mw-scorebar-value {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--ink);
|
||||
text-align: right;
|
||||
}
|
||||
.mw-scorebar-threshold {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--ink-whisper);
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.mw-letter-footer {
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--hairline);
|
||||
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--ink-whisper);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
}
|
||||
.mw-letter-footer button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.mw-letter-footer button:hover {
|
||||
color: var(--ocean);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
capabilityForModel,
|
||||
downloadAsset,
|
||||
fetchInstalledAssets,
|
||||
fetchInstalledModels,
|
||||
fetchLocalAiStatus,
|
||||
fetchPresets,
|
||||
formatBytes,
|
||||
getMemoryTreeLlm,
|
||||
type ModelDescriptor,
|
||||
setMemoryTreeLlm,
|
||||
} from '../settingsApi';
|
||||
|
||||
// Stub the underlying tauri-command wrappers; we're testing the
|
||||
// camelCase→snake_case translation + simple try/catch shells, not the
|
||||
// RPC plumbing.
|
||||
vi.mock('../../../utils/tauriCommands', () => ({
|
||||
isTauri: vi.fn(() => true),
|
||||
memoryTreeGetLlm: vi.fn(),
|
||||
memoryTreeSetLlm: vi.fn(),
|
||||
openhumanLocalAiAssetsStatus: vi.fn(),
|
||||
openhumanLocalAiStatus: vi.fn(),
|
||||
openhumanLocalAiDiagnostics: vi.fn(),
|
||||
openhumanLocalAiPresets: vi.fn(),
|
||||
openhumanLocalAiDownloadAsset: vi.fn(),
|
||||
}));
|
||||
|
||||
const tauri = (await import('../../../utils/tauriCommands')) as unknown as {
|
||||
memoryTreeGetLlm: ReturnType<typeof vi.fn>;
|
||||
memoryTreeSetLlm: ReturnType<typeof vi.fn>;
|
||||
openhumanLocalAiAssetsStatus: ReturnType<typeof vi.fn>;
|
||||
openhumanLocalAiStatus: ReturnType<typeof vi.fn>;
|
||||
openhumanLocalAiDiagnostics: ReturnType<typeof vi.fn>;
|
||||
openhumanLocalAiPresets: ReturnType<typeof vi.fn>;
|
||||
openhumanLocalAiDownloadAsset: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.values(tauri).forEach(fn => fn.mockReset());
|
||||
});
|
||||
|
||||
describe('getMemoryTreeLlm', () => {
|
||||
it('returns the current backend value from the RPC', async () => {
|
||||
tauri.memoryTreeGetLlm.mockResolvedValueOnce({ current: 'cloud' });
|
||||
await expect(getMemoryTreeLlm()).resolves.toBe('cloud');
|
||||
expect(tauri.memoryTreeGetLlm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMemoryTreeLlm', () => {
|
||||
it('passes only the backend when no options are supplied', async () => {
|
||||
tauri.memoryTreeSetLlm.mockResolvedValueOnce({ current: 'cloud' });
|
||||
await setMemoryTreeLlm('cloud');
|
||||
expect(tauri.memoryTreeSetLlm).toHaveBeenCalledWith({ backend: 'cloud' });
|
||||
});
|
||||
|
||||
it('translates camelCase options to snake_case wire fields and passes only those that are set', async () => {
|
||||
tauri.memoryTreeSetLlm.mockResolvedValueOnce({ current: 'local' });
|
||||
await setMemoryTreeLlm('local', { extractModel: 'a:b', summariserModel: 'c:d' });
|
||||
expect(tauri.memoryTreeSetLlm).toHaveBeenCalledWith({
|
||||
backend: 'local',
|
||||
extract_model: 'a:b',
|
||||
summariser_model: 'c:d',
|
||||
});
|
||||
// cloudModel was unset → cloud_model must NOT be on the wire payload.
|
||||
expect(tauri.memoryTreeSetLlm.mock.calls[0][0]).not.toHaveProperty('cloud_model');
|
||||
});
|
||||
|
||||
it('returns the effective backend value the core decided on', async () => {
|
||||
tauri.memoryTreeSetLlm.mockResolvedValueOnce({ current: 'cloud' });
|
||||
const out = await setMemoryTreeLlm('local');
|
||||
expect(out).toEqual({ effective: 'cloud' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchInstalledAssets', () => {
|
||||
it('unwraps the `result` field on success', async () => {
|
||||
tauri.openhumanLocalAiAssetsStatus.mockResolvedValueOnce({ result: { foo: 1 } });
|
||||
await expect(fetchInstalledAssets()).resolves.toEqual({ foo: 1 });
|
||||
});
|
||||
it('swallows RPC errors and returns null', async () => {
|
||||
tauri.openhumanLocalAiAssetsStatus.mockRejectedValueOnce(new Error('boom'));
|
||||
await expect(fetchInstalledAssets()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchLocalAiStatus', () => {
|
||||
it('returns null on RPC failure', async () => {
|
||||
tauri.openhumanLocalAiStatus.mockRejectedValueOnce(new Error('nope'));
|
||||
await expect(fetchLocalAiStatus()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchInstalledModels', () => {
|
||||
it('returns the installed_models array', async () => {
|
||||
tauri.openhumanLocalAiDiagnostics.mockResolvedValueOnce({
|
||||
installed_models: [{ name: 'bge-m3', size_bytes: 1 }],
|
||||
});
|
||||
const got = await fetchInstalledModels();
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0]?.name).toBe('bge-m3');
|
||||
});
|
||||
it('returns [] when the RPC rejects', async () => {
|
||||
tauri.openhumanLocalAiDiagnostics.mockRejectedValueOnce(new Error('rpc down'));
|
||||
await expect(fetchInstalledModels()).resolves.toEqual([]);
|
||||
});
|
||||
it('returns [] when installed_models is missing', async () => {
|
||||
tauri.openhumanLocalAiDiagnostics.mockResolvedValueOnce({});
|
||||
await expect(fetchInstalledModels()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchPresets', () => {
|
||||
it('forwards the response on success', async () => {
|
||||
tauri.openhumanLocalAiPresets.mockResolvedValueOnce({ presets: [] });
|
||||
await expect(fetchPresets()).resolves.toEqual({ presets: [] });
|
||||
});
|
||||
it('returns null on failure', async () => {
|
||||
tauri.openhumanLocalAiPresets.mockRejectedValueOnce(new Error('x'));
|
||||
await expect(fetchPresets()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadAsset', () => {
|
||||
it('returns the result envelope on success', async () => {
|
||||
tauri.openhumanLocalAiDownloadAsset.mockResolvedValueOnce({ result: { ok: true } });
|
||||
await expect(downloadAsset('chat')).resolves.toEqual({ ok: true });
|
||||
expect(tauri.openhumanLocalAiDownloadAsset).toHaveBeenCalledWith('chat');
|
||||
});
|
||||
it('returns null on failure (and does not throw)', async () => {
|
||||
tauri.openhumanLocalAiDownloadAsset.mockRejectedValueOnce(new Error('disconnected'));
|
||||
await expect(downloadAsset('embedding')).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilityForModel', () => {
|
||||
const make = (roles: ModelDescriptor['roles']): ModelDescriptor => ({
|
||||
id: 'x',
|
||||
size: '0',
|
||||
approxBytes: 0,
|
||||
ramHint: '0',
|
||||
category: 'fast',
|
||||
note: '',
|
||||
roles,
|
||||
});
|
||||
it('maps embedder → embedding', () => {
|
||||
expect(capabilityForModel(make(['embedder']))).toBe('embedding');
|
||||
});
|
||||
it('maps extract / summariser → chat', () => {
|
||||
expect(capabilityForModel(make(['extract']))).toBe('chat');
|
||||
expect(capabilityForModel(make(['summariser']))).toBe('chat');
|
||||
expect(capabilityForModel(make(['extract', 'summariser']))).toBe('chat');
|
||||
});
|
||||
it('returns null when no role binds to a known capability', () => {
|
||||
expect(capabilityForModel(make([]))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('falls back to em-dash for non-finite or zero', () => {
|
||||
expect(formatBytes(Number.NaN)).toBe('—');
|
||||
expect(formatBytes(0)).toBe('—');
|
||||
expect(formatBytes(Number.POSITIVE_INFINITY)).toBe('—');
|
||||
});
|
||||
it('formats GB for >= 1 GiB', () => {
|
||||
expect(formatBytes(2.5 * 1024 ** 3)).toBe('2.5 GB');
|
||||
});
|
||||
it('formats MB (rounded) for sub-GB inputs', () => {
|
||||
expect(formatBytes(150 * 1024 ** 2)).toBe('150 MB');
|
||||
expect(formatBytes(1024 ** 2 + 1)).toBe('1 MB');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Settings tab API layer for the Intelligence page.
|
||||
*
|
||||
* Wraps the existing `local_ai_*` core RPCs (re-exported with cleaner names)
|
||||
* and the canonical `openhuman.memory_tree_get_llm` / `set_llm` JSON-RPC
|
||||
* methods that drive the AI-backend selector. Both come from the shared
|
||||
* `utils/tauriCommands` barrel.
|
||||
*
|
||||
* Logging convention: `[intelligence-settings-api]` prefix for grep-friendly
|
||||
* tracing of the new flow per the project debug-logging rule.
|
||||
*/
|
||||
import {
|
||||
type LlmBackend,
|
||||
type LocalAiAssetsStatus,
|
||||
type LocalAiDiagnostics,
|
||||
type LocalAiStatus,
|
||||
memoryTreeGetLlm,
|
||||
memoryTreeSetLlm,
|
||||
openhumanLocalAiAssetsStatus,
|
||||
openhumanLocalAiDiagnostics,
|
||||
openhumanLocalAiDownloadAsset,
|
||||
openhumanLocalAiPresets,
|
||||
openhumanLocalAiStatus,
|
||||
type PresetsResponse,
|
||||
} from '../../utils/tauriCommands';
|
||||
|
||||
/**
|
||||
* AI backend the assistant is currently using for chat. Re-exports the
|
||||
* canonical `LlmBackend` from the wrapper so both names remain valid as
|
||||
* call-sites migrate.
|
||||
*/
|
||||
export type Backend = LlmBackend;
|
||||
|
||||
/** Static descriptor used by ModelAssignment + ModelCatalog. */
|
||||
export interface ModelDescriptor {
|
||||
/** Ollama-style identifier (e.g. `qwen2.5:0.5b`). */
|
||||
id: string;
|
||||
/** Pretty label shown in the UI (defaults to `id` when omitted). */
|
||||
label?: string;
|
||||
/** Human-readable disk size, e.g. `400 MB`. */
|
||||
size: string;
|
||||
/** Bytes — approximate; surfaced for sort / filter. */
|
||||
approxBytes: number;
|
||||
/** Approx RAM hint, e.g. `≤4 GB RAM`. */
|
||||
ramHint: string;
|
||||
/** Speed / quality tier — used for the inline annotation under each row. */
|
||||
category: 'fast' | 'balanced' | 'high quality' | 'embedder';
|
||||
/** One-sentence note about when to pick this model. */
|
||||
note: string;
|
||||
/** Role(s) this model is suitable for. */
|
||||
roles: ReadonlyArray<'extract' | 'summariser' | 'embedder'>;
|
||||
}
|
||||
|
||||
export type ModelRole = 'extract' | 'summariser' | 'embedder';
|
||||
|
||||
/**
|
||||
* Hard-coded recommended catalog. In a future wave this should come from
|
||||
* a `local_ai.recommended_catalog` RPC; for v1 we ship a curated list so
|
||||
* the UI is fully populated without a server roundtrip.
|
||||
*/
|
||||
export const RECOMMENDED_MODEL_CATALOG: ReadonlyArray<ModelDescriptor> = [
|
||||
{
|
||||
id: 'qwen2.5:0.5b',
|
||||
size: '400 MB',
|
||||
approxBytes: 400 * 1024 * 1024,
|
||||
ramHint: '≤4 GB RAM',
|
||||
category: 'fast',
|
||||
note: 'compact, lower quality',
|
||||
roles: ['extract'],
|
||||
},
|
||||
{
|
||||
id: 'gemma3:1b-it-qat',
|
||||
size: '1.7 GB',
|
||||
approxBytes: Math.round(1.7 * 1024 * 1024 * 1024),
|
||||
ramHint: '≤8 GB RAM',
|
||||
category: 'balanced',
|
||||
note: 'default summariser',
|
||||
roles: ['extract', 'summariser'],
|
||||
},
|
||||
{
|
||||
id: 'llama3.1:8b',
|
||||
size: '4.9 GB',
|
||||
approxBytes: Math.round(4.9 * 1024 * 1024 * 1024),
|
||||
ramHint: '≥8 GB RAM',
|
||||
category: 'high quality',
|
||||
note: 'for capable machines',
|
||||
roles: ['extract', 'summariser'],
|
||||
},
|
||||
{
|
||||
id: 'bge-m3',
|
||||
size: '1.3 GB',
|
||||
approxBytes: Math.round(1.3 * 1024 * 1024 * 1024),
|
||||
ramHint: '≥4 GB RAM',
|
||||
category: 'embedder',
|
||||
note: 'required for embeddings',
|
||||
roles: ['embedder'],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_EXTRACT_MODEL = 'qwen2.5:0.5b';
|
||||
export const DEFAULT_SUMMARISER_MODEL = 'gemma3:1b-it-qat';
|
||||
export const REQUIRED_EMBEDDER_MODEL = 'bge-m3';
|
||||
|
||||
/**
|
||||
* Reads the currently configured chat backend from the core.
|
||||
*
|
||||
* Backed by `openhuman.memory_tree_get_llm` — the value persists across
|
||||
* sidecar restarts via `config.toml`.
|
||||
*/
|
||||
export async function getMemoryTreeLlm(): Promise<Backend> {
|
||||
console.debug('[intelligence-settings-api] getMemoryTreeLlm: entry');
|
||||
const resp = await memoryTreeGetLlm();
|
||||
console.debug('[intelligence-settings-api] getMemoryTreeLlm: exit current=%s', resp.current);
|
||||
return resp.current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional per-role model picks for {@link setMemoryTreeLlm}. Field names
|
||||
* are camelCase here to match TS conventions; the wrapper translates them
|
||||
* to the snake_case wire shape the Rust `SetLlmRequest` expects:
|
||||
*
|
||||
* | TS option | Rust / wire field | Targets `memory_tree.*` |
|
||||
* | ----------------- | ------------------- | ----------------------- |
|
||||
* | `cloudModel` | `cloud_model` | `cloud_llm_model` |
|
||||
* | `extractModel` | `extract_model` | `llm_extractor_model` |
|
||||
* | `summariserModel` | `summariser_model` | `llm_summariser_model` |
|
||||
*
|
||||
* Each field follows "absent → unchanged, present → overwritten" so a
|
||||
* caller flipping just the backend doesn't have to re-supply every model
|
||||
* id, and a caller persisting just one role doesn't have to re-supply
|
||||
* the others.
|
||||
*/
|
||||
export interface SetMemoryTreeLlmOptions {
|
||||
cloudModel?: string;
|
||||
extractModel?: string;
|
||||
summariserModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the chat backend and (optionally) persists per-role model
|
||||
* choices in the same atomic `config.toml` write. Returns the effective
|
||||
* value the core agreed on — today the handler accepts the input
|
||||
* verbatim, but a future revision may downgrade `local` → `cloud` when
|
||||
* the host can't satisfy the local minimums.
|
||||
*
|
||||
* Backed by `openhuman.memory_tree_set_llm`.
|
||||
*
|
||||
* Existing one-arg callers — `setMemoryTreeLlm('cloud')` — keep working
|
||||
* unchanged because `options` is optional.
|
||||
*/
|
||||
export async function setMemoryTreeLlm(
|
||||
next: Backend,
|
||||
options?: SetMemoryTreeLlmOptions
|
||||
): Promise<{ effective: Backend }> {
|
||||
console.debug(
|
||||
'[intelligence-settings-api] setMemoryTreeLlm: entry next=%s cloudModel=%s extractModel=%s summariserModel=%s',
|
||||
next,
|
||||
options?.cloudModel ?? '<none>',
|
||||
options?.extractModel ?? '<none>',
|
||||
options?.summariserModel ?? '<none>'
|
||||
);
|
||||
// camelCase → snake_case translation lives here, in one place. The
|
||||
// wrapper layer just forwards the snake_case shape to the wire.
|
||||
const resp = await memoryTreeSetLlm({
|
||||
backend: next,
|
||||
...(options?.cloudModel !== undefined && { cloud_model: options.cloudModel }),
|
||||
...(options?.extractModel !== undefined && { extract_model: options.extractModel }),
|
||||
...(options?.summariserModel !== undefined && { summariser_model: options.summariserModel }),
|
||||
});
|
||||
console.debug('[intelligence-settings-api] setMemoryTreeLlm: exit effective=%s', resp.current);
|
||||
return { effective: resp.current };
|
||||
}
|
||||
|
||||
/** Re-export the existing assets status fetch with a friendlier name. */
|
||||
export async function fetchInstalledAssets(): Promise<LocalAiAssetsStatus | null> {
|
||||
try {
|
||||
const response = await openhumanLocalAiAssetsStatus();
|
||||
return response.result;
|
||||
} catch (err) {
|
||||
console.debug('[intelligence-settings-api] fetchInstalledAssets failed', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch local AI status (includes per-capability state + last latency).
|
||||
* Used by `CurrentlyLoaded` to render Ollama-side telemetry.
|
||||
*/
|
||||
export async function fetchLocalAiStatus(): Promise<LocalAiStatus | null> {
|
||||
try {
|
||||
const response = await openhumanLocalAiStatus();
|
||||
return response.result;
|
||||
} catch (err) {
|
||||
console.debug('[intelligence-settings-api] fetchLocalAiStatus failed', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reach into the existing diagnostics RPC for the list of installed Ollama
|
||||
* models. The diagnostics endpoint already enumerates them and is the
|
||||
* cleanest single source of truth — we do not duplicate the model table.
|
||||
*/
|
||||
export async function fetchInstalledModels(): Promise<LocalAiDiagnostics['installed_models']> {
|
||||
try {
|
||||
const response = await openhumanLocalAiDiagnostics();
|
||||
return response.installed_models ?? [];
|
||||
} catch (err) {
|
||||
console.debug('[intelligence-settings-api] fetchInstalledModels failed', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPresets(): Promise<PresetsResponse | null> {
|
||||
try {
|
||||
return await openhumanLocalAiPresets();
|
||||
} catch (err) {
|
||||
console.debug('[intelligence-settings-api] fetchPresets failed', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a download for a capability (chat / vision / embedding / stt / tts).
|
||||
* Used by ModelCatalog when the user clicks "Download".
|
||||
*
|
||||
* NOTE: the real RPC is per-capability, not per-model-id, so the catalog
|
||||
* picks the closest matching capability. This is acceptable for v1; future
|
||||
* iterations can swap in a per-model RPC.
|
||||
*/
|
||||
export async function downloadAsset(
|
||||
capability: 'chat' | 'vision' | 'embedding' | 'stt' | 'tts'
|
||||
): Promise<LocalAiAssetsStatus | null> {
|
||||
try {
|
||||
const response = await openhumanLocalAiDownloadAsset(capability);
|
||||
return response.result;
|
||||
} catch (err) {
|
||||
console.debug('[intelligence-settings-api] downloadAsset failed', { capability, err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a model descriptor to the closest capability bucket the core exposes. */
|
||||
export function capabilityForModel(model: ModelDescriptor): 'chat' | 'embedding' | null {
|
||||
if (model.roles.includes('embedder')) return 'embedding';
|
||||
if (model.roles.includes('extract') || model.roles.includes('summariser')) return 'chat';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap pretty-printer for a byte count. Mirrors the `JetBrains Mono`-style
|
||||
* compact format we want in the technical-readout sections.
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '—';
|
||||
const gb = bytes / (1024 * 1024 * 1024);
|
||||
if (gb >= 1) return `${gb.toFixed(1)} GB`;
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return `${Math.round(mb)} MB`;
|
||||
}
|
||||
+41
-203
@@ -1,55 +1,45 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
|
||||
import IntelligenceDreamsTab from '../components/intelligence/IntelligenceDreamsTab';
|
||||
import IntelligenceMemoryTab from '../components/intelligence/IntelligenceMemoryTab';
|
||||
import IntelligenceSettingsTab from '../components/intelligence/IntelligenceSettingsTab';
|
||||
import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab';
|
||||
import { MemoryWorkspace } from '../components/intelligence/MemoryWorkspace';
|
||||
import { ToastContainer } from '../components/intelligence/Toast';
|
||||
import { filterItems, getItemStats, groupItemsByTime } from '../components/intelligence/utils';
|
||||
import PillTabBar from '../components/PillTabBar';
|
||||
import { useConsciousItems } from '../hooks/useConsciousItems';
|
||||
import {
|
||||
useSnoozeActionableItem,
|
||||
useUpdateActionableItem,
|
||||
} from '../hooks/useIntelligenceApiFallback';
|
||||
import {
|
||||
useIntelligenceSocket,
|
||||
useIntelligenceSocketManager,
|
||||
} from '../hooks/useIntelligenceSocket';
|
||||
import { useIntelligenceStats } from '../hooks/useIntelligenceStats';
|
||||
import { useMemoryIngestionStatus } from '../hooks/useMemoryIngestionStatus';
|
||||
import { useScreenIntelligenceItems } from '../hooks/useScreenIntelligenceItems';
|
||||
import { useSubconscious } from '../hooks/useSubconscious';
|
||||
import type {
|
||||
ActionableItem,
|
||||
ActionableItemSource,
|
||||
ActionableItemStatus,
|
||||
ConfirmationModal as ConfirmationModalType,
|
||||
ToastNotification,
|
||||
} from '../types/intelligence';
|
||||
|
||||
type IntelligenceTab = 'memory' | 'subconscious' | 'dreams';
|
||||
type IntelligenceTab = 'memory' | 'subconscious' | 'dreams' | 'settings';
|
||||
|
||||
export default function Intelligence() {
|
||||
const { aiStatus } = useIntelligenceStats();
|
||||
const { status: ingestionStatus } = useMemoryIngestionStatus();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<IntelligenceTab>('memory');
|
||||
const [sourceFilter, setSourceFilter] = useState<ActionableItemSource | 'all'>('all');
|
||||
const [priorityFilter] = useState<'critical' | 'important' | 'normal' | 'all'>('all');
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
|
||||
// Conscious memory items (real data from the background analysis loop)
|
||||
const {
|
||||
items: consciousItems,
|
||||
loading: consciousLoading,
|
||||
isRunning,
|
||||
refresh: refreshConscious,
|
||||
triggerAnalysis,
|
||||
} = useConsciousItems();
|
||||
// `useConsciousItems` is kept solely for the `isRunning` signal that
|
||||
// drives the system-status pill in the Memory-tab header. The items
|
||||
// themselves used to feed the actionable-cards count badge (now hidden,
|
||||
// and the rendering surface — IntelligenceMemoryTab — is gone). When
|
||||
// the status pill is rewired to a memory_tree-native source, drop this
|
||||
// hook entirely.
|
||||
const { isRunning } = useConsciousItems();
|
||||
|
||||
const { mutateAsync: updateItemStatus } = useUpdateActionableItem();
|
||||
const { mutateAsync: snoozeItem } = useSnoozeActionableItem();
|
||||
// useUpdateActionableItem / useSnoozeActionableItem hooks were the
|
||||
// mutations behind handleComplete / Dismiss / Snooze. Removed along
|
||||
// with those handlers since the Memory tab no longer renders the
|
||||
// actionable-card surface.
|
||||
|
||||
// Subconscious engine data
|
||||
const {
|
||||
@@ -92,17 +82,6 @@ export default function Intelligence() {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const { items: screenIntelligenceItems, loading: screenIntelligenceLoading } =
|
||||
useScreenIntelligenceItems();
|
||||
|
||||
const usingMemoryData = consciousItems.length > 0 || screenIntelligenceItems.length > 0;
|
||||
const items: ActionableItem[] = useMemo(
|
||||
() => [...consciousItems, ...screenIntelligenceItems],
|
||||
[consciousItems, screenIntelligenceItems]
|
||||
);
|
||||
|
||||
const itemsLoading = consciousLoading || screenIntelligenceLoading;
|
||||
|
||||
// Initialize socket connection
|
||||
useEffect(() => {
|
||||
if (!socketConnected) {
|
||||
@@ -110,131 +89,18 @@ export default function Intelligence() {
|
||||
}
|
||||
}, [socketConnected, socketManager]);
|
||||
|
||||
// Filter and group items
|
||||
const filteredItems = useMemo(() => {
|
||||
const activeItems = items.filter(item => item.status === 'active');
|
||||
return filterItems(activeItems, {
|
||||
source: sourceFilter,
|
||||
priority: priorityFilter,
|
||||
searchTerm: searchFilter,
|
||||
});
|
||||
}, [items, priorityFilter, searchFilter, sourceFilter]);
|
||||
|
||||
const timeGroups = useMemo(() => groupItemsByTime(filteredItems), [filteredItems]);
|
||||
const stats = useMemo(() => getItemStats(filteredItems), [filteredItems]);
|
||||
|
||||
// Item action handlers
|
||||
const handleUpdateItemStatus = useCallback(
|
||||
async (itemId: string, status: ActionableItemStatus) => {
|
||||
try {
|
||||
await updateItemStatus({ itemId, status });
|
||||
|
||||
let message = '';
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
message = 'Task marked as completed';
|
||||
break;
|
||||
case 'dismissed':
|
||||
message = 'Task dismissed';
|
||||
break;
|
||||
case 'active':
|
||||
message = 'Task reactivated';
|
||||
break;
|
||||
default:
|
||||
message = 'Status updated';
|
||||
}
|
||||
|
||||
addToast({ type: 'success', title: 'Status Updated', message });
|
||||
} catch (error) {
|
||||
console.error('Failed to update item status:', error);
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Update Failed',
|
||||
message: error instanceof Error ? error.message : 'Failed to update item status',
|
||||
});
|
||||
}
|
||||
},
|
||||
[updateItemStatus, addToast]
|
||||
);
|
||||
|
||||
const handleComplete = useCallback(
|
||||
async (item: ActionableItem) => {
|
||||
await handleUpdateItemStatus(item.id, 'completed');
|
||||
},
|
||||
[handleUpdateItemStatus]
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
(item: ActionableItem) => {
|
||||
setConfirmationModal({
|
||||
isOpen: true,
|
||||
title: 'Dismiss item?',
|
||||
message: `Are you sure you want to dismiss "${item.title}"?`,
|
||||
confirmText: 'Dismiss',
|
||||
cancelText: 'Cancel',
|
||||
destructive: item.priority === 'critical',
|
||||
showDontShowAgain: !item.requiresConfirmation,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await handleUpdateItemStatus(item.id, 'dismissed');
|
||||
addToast({
|
||||
type: 'info',
|
||||
title: 'Dismissed',
|
||||
message: item.title.length > 40 ? `${item.title.substring(0, 40)}...` : item.title,
|
||||
action: { label: 'Undo', handler: () => handleUpdateItemStatus(item.id, 'active') },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to dismiss item:', error);
|
||||
}
|
||||
},
|
||||
onCancel: () => {},
|
||||
});
|
||||
},
|
||||
[handleUpdateItemStatus, addToast]
|
||||
);
|
||||
|
||||
const handleSnooze = useCallback(
|
||||
async (item: ActionableItem, duration: number) => {
|
||||
try {
|
||||
const snoozeUntil = new Date(Date.now() + duration);
|
||||
await snoozeItem({ itemId: item.id, snoozeUntil });
|
||||
const hours = Math.round(duration / (1000 * 60 * 60));
|
||||
addToast({
|
||||
type: 'info',
|
||||
title: 'Snoozed',
|
||||
message: `Reminded in ${hours === 1 ? '1 hour' : `${hours} hours`}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to snooze item:', error);
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Snooze Failed',
|
||||
message: 'Failed to snooze item. Please try again.',
|
||||
});
|
||||
}
|
||||
},
|
||||
[snoozeItem, addToast]
|
||||
);
|
||||
|
||||
const handleAnalyzeNow = useCallback(async () => {
|
||||
await triggerAnalysis();
|
||||
addToast({
|
||||
type: 'info',
|
||||
title: 'Analysis Started',
|
||||
message: 'Analyzing your connected skills for actionable items…',
|
||||
});
|
||||
}, [triggerAnalysis, addToast]);
|
||||
|
||||
// System status
|
||||
// System status — `itemsLoading` (the actionable-items + screen-items
|
||||
// loading flag) used to feed the "loading" branch here, but both feeds
|
||||
// are gone now. `isRunning` from useConsciousItems still surfaces the
|
||||
// background analysis loop signal until that pill is rewired to
|
||||
// memory_tree.
|
||||
const systemStatus = isRunning
|
||||
? 'loading'
|
||||
: socketConnected && aiStatus === 'ready'
|
||||
? 'ready'
|
||||
: itemsLoading
|
||||
? 'loading'
|
||||
: !socketConnected
|
||||
? 'disconnected'
|
||||
: aiStatus;
|
||||
: !socketConnected
|
||||
? 'disconnected'
|
||||
: aiStatus;
|
||||
|
||||
const systemStatusLabel = isRunning
|
||||
? 'Analyzing…'
|
||||
@@ -265,6 +131,7 @@ export default function Intelligence() {
|
||||
{ id: 'memory', label: 'Memory' },
|
||||
{ id: 'subconscious', label: 'Subconscious' },
|
||||
{ id: 'dreams', label: 'Dreams', comingSoon: true },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -301,11 +168,14 @@ export default function Intelligence() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-stone-900">Intelligence</h1>
|
||||
{activeTab === 'memory' && stats.total > 0 && (
|
||||
<div className="text-xs bg-stone-100 text-stone-900 px-2 py-1 rounded-full">
|
||||
{stats.total}
|
||||
</div>
|
||||
)}
|
||||
{/* Header count badge was sourced from `stats.total` which
|
||||
in turn came from the legacy actionable-items pipeline
|
||||
(`filterItems(items, ...)`). The Memory tab now mounts
|
||||
`MemoryWorkspace`, which renders chunks from
|
||||
`memory_tree` and has nothing to do with that pipeline,
|
||||
so the badge would have shown a count that no longer
|
||||
matches anything visible. Hidden until a memory_tree
|
||||
-native count signal is exposed. */}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{activeTab === 'memory' && (
|
||||
@@ -332,51 +202,17 @@ export default function Intelligence() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'memory' && (
|
||||
<button
|
||||
onClick={usingMemoryData ? refreshConscious : handleAnalyzeNow}
|
||||
disabled={isRunning || itemsLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-stone-50 hover:bg-stone-100 disabled:opacity-40 disabled:cursor-not-allowed border border-stone-200 rounded-lg text-stone-600 transition-colors">
|
||||
{isRunning || itemsLoading ? (
|
||||
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{usingMemoryData ? 'Refresh' : 'Analyze Now'}
|
||||
</button>
|
||||
)}
|
||||
{/* Analyze Now / Refresh button removed — the new
|
||||
MemoryWorkspace fetches via memory_tree RPCs that
|
||||
don't need a manual trigger. The actionable-cards
|
||||
flow (handleAnalyzeNow) is no longer reachable from
|
||||
the Memory tab; left in scope only for the legacy
|
||||
subconscious/dreams tabs that still use it. */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'memory' && (
|
||||
<IntelligenceMemoryTab
|
||||
handleAnalyzeNow={handleAnalyzeNow}
|
||||
handleComplete={handleComplete}
|
||||
handleDismiss={handleDismiss}
|
||||
handleSnooze={handleSnooze}
|
||||
isRunning={isRunning}
|
||||
items={items}
|
||||
itemsLoading={itemsLoading}
|
||||
searchFilter={searchFilter}
|
||||
setSearchFilter={setSearchFilter}
|
||||
setSourceFilter={setSourceFilter}
|
||||
sourceFilter={sourceFilter}
|
||||
timeGroups={timeGroups}
|
||||
usingMemoryData={usingMemoryData}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'memory' && <MemoryWorkspace onToast={addToast} />}
|
||||
|
||||
{activeTab === 'subconscious' && (
|
||||
<IntelligenceSubconsciousTab
|
||||
@@ -400,6 +236,8 @@ export default function Intelligence() {
|
||||
)}
|
||||
|
||||
{activeTab === 'dreams' && <IntelligenceDreamsTab />}
|
||||
|
||||
{activeTab === 'settings' && <IntelligenceSettingsTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from './auth';
|
||||
export * from './window';
|
||||
export * from './core';
|
||||
export * from './memory';
|
||||
export * from './memoryTree';
|
||||
export * from './webhooks';
|
||||
export * from './composio';
|
||||
export * from './conscious';
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Unit tests for memory_tree RPC wrappers. Mirror the pattern used by
|
||||
* `memory.test.ts` — mock the underlying `callCoreRpc` and assert that
|
||||
* each helper dispatches the right method name + params and unwraps
|
||||
* `RpcOutcome`'s `{ result, logs }` envelope correctly.
|
||||
*/
|
||||
import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
|
||||
|
||||
import { callCoreRpc } from '../../services/coreRpcClient';
|
||||
import {
|
||||
memoryTreeChunkScore,
|
||||
memoryTreeDeleteChunk,
|
||||
memoryTreeEntityIndexFor,
|
||||
memoryTreeGetLlm,
|
||||
memoryTreeListChunks,
|
||||
memoryTreeListSources,
|
||||
memoryTreeRecall,
|
||||
memoryTreeSearch,
|
||||
memoryTreeSetLlm,
|
||||
memoryTreeTopEntities,
|
||||
} from './memoryTree';
|
||||
|
||||
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
|
||||
const mockCallCoreRpc = callCoreRpc as Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('memoryTreeListChunks', () => {
|
||||
test('dispatches openhuman.memory_tree_list_chunks with the filter as params', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({
|
||||
result: { chunks: [], total: 0 },
|
||||
logs: ['memory_tree::read: list_chunks n=0 total=0'],
|
||||
});
|
||||
|
||||
const out = await memoryTreeListChunks({ limit: 50 });
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.memory_tree_list_chunks',
|
||||
params: { limit: 50 },
|
||||
});
|
||||
expect(out).toEqual({ chunks: [], total: 0 });
|
||||
});
|
||||
|
||||
test('handles bare-value responses (no logs envelope)', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ chunks: [{ id: 'c1' }], total: 1 });
|
||||
const out = await memoryTreeListChunks({});
|
||||
expect(out.total).toBe(1);
|
||||
expect(out.chunks[0]?.id).toBe('c1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoryTreeListSources', () => {
|
||||
test('omits user_email_hint when not provided', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ result: [], logs: ['stub'] });
|
||||
|
||||
await memoryTreeListSources();
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.memory_tree_list_sources',
|
||||
params: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('forwards user_email_hint when provided', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ result: [], logs: ['stub'] });
|
||||
|
||||
await memoryTreeListSources('alice@example.com');
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.memory_tree_list_sources',
|
||||
params: { user_email_hint: 'alice@example.com' },
|
||||
});
|
||||
});
|
||||
|
||||
test('returns the unwrapped Source array', async () => {
|
||||
const sources = [
|
||||
{
|
||||
source_id: 'gmail:x|y',
|
||||
display_name: 'X',
|
||||
source_kind: 'email',
|
||||
chunk_count: 2,
|
||||
most_recent_ms: 1,
|
||||
},
|
||||
];
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ result: sources, logs: ['stub'] });
|
||||
const out = await memoryTreeListSources();
|
||||
expect(out).toEqual(sources);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoryTreeSearch', () => {
|
||||
test('dispatches with query + k', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ result: [], logs: ['stub'] });
|
||||
|
||||
await memoryTreeSearch('phoenix', 25);
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.memory_tree_search',
|
||||
params: { query: 'phoenix', k: 25 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoryTreeRecall', () => {
|
||||
test('dispatches with query + k and unwraps the recall envelope', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({
|
||||
result: { chunks: [{ id: 'c1' }], scores: [0.9] },
|
||||
logs: ['stub'],
|
||||
});
|
||||
|
||||
const out = await memoryTreeRecall('design sync', 10);
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.memory_tree_recall',
|
||||
params: { query: 'design sync', k: 10 },
|
||||
});
|
||||
expect(out.chunks).toHaveLength(1);
|
||||
expect(out.scores[0]).toBe(0.9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoryTreeEntityIndexFor', () => {
|
||||
test('dispatches with chunk_id', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ result: [], logs: ['stub'] });
|
||||
|
||||
await memoryTreeEntityIndexFor('chunk-abc');
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.memory_tree_entity_index_for',
|
||||
params: { chunk_id: 'chunk-abc' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoryTreeTopEntities', () => {
|
||||
test('omits kind when not provided and defaults limit to 50', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ result: [], logs: ['stub'] });
|
||||
|
||||
await memoryTreeTopEntities();
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.memory_tree_top_entities',
|
||||
params: { limit: 50 },
|
||||
});
|
||||
});
|
||||
|
||||
test('forwards kind + custom limit when provided', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ result: [], logs: ['stub'] });
|
||||
|
||||
await memoryTreeTopEntities('person', 12);
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.memory_tree_top_entities',
|
||||
params: { limit: 12, kind: 'person' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoryTreeChunkScore', () => {
|
||||
test('returns null when the core reports no score row', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ result: null, logs: ['stub'] });
|
||||
|
||||
const out = await memoryTreeChunkScore('chunk-missing');
|
||||
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
test('unwraps the breakdown when present', async () => {
|
||||
const breakdown = {
|
||||
signals: [{ name: 'token_count', weight: 1, value: 0.5 }],
|
||||
total: 0.5,
|
||||
threshold: 0.85,
|
||||
kept: false,
|
||||
llm_consulted: false,
|
||||
};
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ result: breakdown, logs: ['stub'] });
|
||||
|
||||
const out = await memoryTreeChunkScore('chunk-real');
|
||||
|
||||
expect(out).toEqual(breakdown);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoryTreeDeleteChunk', () => {
|
||||
test('dispatches with chunk_id and surfaces the full DeleteChunkResponse', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({
|
||||
result: { deleted: true, score_rows_removed: 1, entity_index_rows_removed: 3 },
|
||||
logs: ['stub'],
|
||||
});
|
||||
|
||||
const out = await memoryTreeDeleteChunk('chunk-xyz');
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.memory_tree_delete_chunk',
|
||||
params: { chunk_id: 'chunk-xyz' },
|
||||
});
|
||||
expect(out).toEqual({ deleted: true, score_rows_removed: 1, entity_index_rows_removed: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoryTreeGetLlm / memoryTreeSetLlm', () => {
|
||||
test('get_llm dispatches without params', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ result: { current: 'cloud' }, logs: ['stub'] });
|
||||
|
||||
const out = await memoryTreeGetLlm();
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.memory_tree_get_llm' });
|
||||
expect(out.current).toBe('cloud');
|
||||
});
|
||||
|
||||
test('set_llm dispatches with backend param and returns the effective value', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ result: { current: 'local' }, logs: ['stub'] });
|
||||
|
||||
const out = await memoryTreeSetLlm('local');
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.memory_tree_set_llm',
|
||||
params: { backend: 'local' },
|
||||
});
|
||||
expect(out.current).toBe('local');
|
||||
});
|
||||
|
||||
test('set_llm forwards optional per-role model fields verbatim as snake_case', async () => {
|
||||
// The wrapper takes either a bare backend string (legacy) or the full
|
||||
// request object. When the caller passes a request, the snake_case
|
||||
// field names must reach the wire untouched — no camelCase
|
||||
// translation lives in this layer.
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ result: { current: 'local' }, logs: ['stub'] });
|
||||
|
||||
const out = await memoryTreeSetLlm({
|
||||
backend: 'local',
|
||||
extract_model: 'qwen2.5:0.5b',
|
||||
summariser_model: 'gemma3:1b-it-qat',
|
||||
});
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.memory_tree_set_llm',
|
||||
params: {
|
||||
backend: 'local',
|
||||
extract_model: 'qwen2.5:0.5b',
|
||||
summariser_model: 'gemma3:1b-it-qat',
|
||||
},
|
||||
});
|
||||
expect(out.current).toBe('local');
|
||||
});
|
||||
|
||||
test('set_llm with cloud_model only flips backend + cloud model', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ result: { current: 'cloud' }, logs: ['stub'] });
|
||||
|
||||
await memoryTreeSetLlm({ backend: 'cloud', cloud_model: 'summarizer-v2' });
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.memory_tree_set_llm',
|
||||
params: { backend: 'cloud', cloud_model: 'summarizer-v2' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* memory_tree subsystem commands.
|
||||
*
|
||||
* Thin wrappers over the `openhuman.memory_tree_*` JSON-RPC surface that
|
||||
* powers the Memory tab and the Settings → AI backend chooser. Method
|
||||
* shapes mirror the Rust handlers in `src/openhuman/memory/tree/read_rpc.rs`
|
||||
* and `schemas.rs`.
|
||||
*
|
||||
* Responses come back wrapped by `RpcOutcome::single_log` as
|
||||
* `{ result: <T>, logs: string[] }` (single-log envelope). Each helper
|
||||
* unwraps `result` so callers see the bare value the Rust handler
|
||||
* returned, falling back gracefully if a future handler stops emitting
|
||||
* logs and the bare value flows through.
|
||||
*
|
||||
* Logging convention: `[memory-tree-rpc]` prefix for grep-friendly tracing
|
||||
* per the project debug-logging rule.
|
||||
*/
|
||||
import { callCoreRpc } from '../../services/coreRpcClient';
|
||||
|
||||
// ── Public types — match the memory_tree RPC contract ────────────────────
|
||||
|
||||
/**
|
||||
* Source kind values the Rust core uses for canonical chunk metadata.
|
||||
* The list is closed for the surfaces the Memory tab cares about, but
|
||||
* the wire type is `string` so any future kind round-trips through the
|
||||
* UI without a recompile.
|
||||
*/
|
||||
export type SourceKind = 'email' | 'chat' | 'screen' | 'voice' | 'doc';
|
||||
|
||||
/** Chunk lifecycle phase as emitted by the admission gate. */
|
||||
export type LifecycleStatus = 'admitted' | 'buffered' | 'pending_extraction' | 'dropped';
|
||||
|
||||
/**
|
||||
* Canonical entity-kind strings emitted by the entity index. Kept
|
||||
* permissive (`string`) on the Rust side; the TS union is the curated
|
||||
* subset the UI knows how to render.
|
||||
*/
|
||||
export type EntityKind =
|
||||
| 'person'
|
||||
| 'organization'
|
||||
| 'location'
|
||||
| 'event'
|
||||
| 'product'
|
||||
| 'datetime'
|
||||
| 'technology'
|
||||
| 'artifact'
|
||||
| 'quantity'
|
||||
| 'misc';
|
||||
|
||||
/**
|
||||
* A single chunk in the memory tree — one user-visible message-sized unit
|
||||
* (an email, a chat turn, a doc page, a transcribed voice clip).
|
||||
*
|
||||
* Wire shape mirrors Rust's [`ChunkRow`](src/openhuman/memory/tree/read_rpc.rs)
|
||||
* — body is replaced with a `≤500-char preview` plus a flag indicating
|
||||
* whether the row has an embedding.
|
||||
*/
|
||||
export interface Chunk {
|
||||
id: string;
|
||||
source_kind: SourceKind;
|
||||
source_id: string;
|
||||
source_ref?: string;
|
||||
owner: string;
|
||||
timestamp_ms: number;
|
||||
token_count: number;
|
||||
lifecycle_status: LifecycleStatus;
|
||||
content_path?: string;
|
||||
/** Up to 500 chars; used as the result-list subject preview. */
|
||||
content_preview?: string;
|
||||
has_embedding: boolean;
|
||||
/** Hierarchical: ["person/Steve-Enamakel", "organization/TinyHumans"]. */
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface ChunkFilter {
|
||||
source_kinds?: string[];
|
||||
source_ids?: string[];
|
||||
entity_ids?: string[];
|
||||
since_ms?: number;
|
||||
until_ms?: number;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface ListChunksResponse {
|
||||
chunks: Chunk[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct ingest source as returned by `memory_tree_list_sources`.
|
||||
*
|
||||
* `lifecycle_status` is **optional** — the Rust handler does not emit it
|
||||
* (it's a UI-derived aggregate), but the navigator pane wants a per-source
|
||||
* dot color. Consumers compute it from chunk-level state and pass it in,
|
||||
* or omit it and the UI falls back to a neutral dot.
|
||||
*/
|
||||
export interface Source {
|
||||
source_id: string;
|
||||
/** Un-slugged readable; user-email stripped when `user_email_hint` matched. */
|
||||
display_name: string;
|
||||
source_kind: string;
|
||||
chunk_count: number;
|
||||
most_recent_ms: number;
|
||||
lifecycle_status?: LifecycleStatus;
|
||||
}
|
||||
|
||||
export interface EntityRef {
|
||||
/** Canonical id (e.g. `person:Steven Enamakel`, `email:alice@example.com`). */
|
||||
entity_id: string;
|
||||
kind: string;
|
||||
surface: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ScoreSignal {
|
||||
name: string;
|
||||
weight: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ScoreBreakdown {
|
||||
signals: ScoreSignal[];
|
||||
total: number;
|
||||
threshold: number;
|
||||
kept: boolean;
|
||||
llm_consulted: boolean;
|
||||
}
|
||||
|
||||
export interface RecallResponse {
|
||||
chunks: Chunk[];
|
||||
scores: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response shape for `memory_tree_delete_chunk`. The Rust handler also
|
||||
* surfaces the number of dependent rows removed so UIs can render a
|
||||
* detailed "purged X / Y / Z" toast.
|
||||
*/
|
||||
export interface DeleteChunkResponse {
|
||||
deleted: boolean;
|
||||
score_rows_removed: number;
|
||||
entity_index_rows_removed: number;
|
||||
}
|
||||
|
||||
/** Backend selector value. */
|
||||
export type LlmBackend = 'cloud' | 'local';
|
||||
|
||||
export interface LlmResponse {
|
||||
current: LlmBackend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire shape for `openhuman.memory_tree_set_llm`.
|
||||
*
|
||||
* `backend` is required and always overwrites `memory_tree.llm_backend`.
|
||||
*
|
||||
* The three model fields are optional; absent means "leave the
|
||||
* corresponding `memory_tree.*_model` config key untouched", present
|
||||
* means "overwrite it". This lets the UI flip the backend without
|
||||
* touching models, or persist a per-role model selection without having
|
||||
* to re-supply every other model id. Field names are snake_case to match
|
||||
* the Rust `SetLlmRequest` struct verbatim — the wrapper does not
|
||||
* translate.
|
||||
*/
|
||||
export interface SetLlmRequest {
|
||||
backend: LlmBackend;
|
||||
cloud_model?: string;
|
||||
extract_model?: string;
|
||||
summariser_model?: string;
|
||||
}
|
||||
|
||||
// ── Envelope unwrap helper ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Internal envelope shape produced by `RpcOutcome::single_log` on the
|
||||
* Rust side. Every read_rpc handler emits at least one log line, so the
|
||||
* shape will be `{ result, logs }` in practice — but we keep the
|
||||
* fallback path for defensive parsing.
|
||||
*/
|
||||
interface ResultEnvelope<T> {
|
||||
result?: T;
|
||||
logs?: string[];
|
||||
}
|
||||
|
||||
function unwrapResult<T>(resp: T | ResultEnvelope<T>): T {
|
||||
if (resp && typeof resp === 'object' && 'result' in resp) {
|
||||
return (resp as ResultEnvelope<T>).result as T;
|
||||
}
|
||||
return resp as T;
|
||||
}
|
||||
|
||||
// ── memory_tree_list_chunks ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Paginated chunk listing with optional filters. Backed by
|
||||
* `openhuman.memory_tree_list_chunks`.
|
||||
*/
|
||||
export async function memoryTreeListChunks(filter: ChunkFilter): Promise<ListChunksResponse> {
|
||||
console.debug('[memory-tree-rpc] memoryTreeListChunks: entry filter=%o', filter);
|
||||
const resp = await callCoreRpc<ListChunksResponse | ResultEnvelope<ListChunksResponse>>({
|
||||
method: 'openhuman.memory_tree_list_chunks',
|
||||
params: filter,
|
||||
});
|
||||
const out = unwrapResult(resp);
|
||||
console.debug(
|
||||
'[memory-tree-rpc] memoryTreeListChunks: exit n=%d total=%d',
|
||||
out.chunks?.length ?? 0,
|
||||
out.total ?? 0
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── memory_tree_list_sources ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Distinct (source_kind, source_id) pairs with chunk counts and most-recent
|
||||
* timestamps. `user_email_hint` (when supplied) tells the Rust handler to
|
||||
* strip that address from email-thread display names.
|
||||
*/
|
||||
export async function memoryTreeListSources(userEmailHint?: string): Promise<Source[]> {
|
||||
console.debug(
|
||||
'[memory-tree-rpc] memoryTreeListSources: entry hint=%s',
|
||||
userEmailHint ?? '<none>'
|
||||
);
|
||||
const params = userEmailHint ? { user_email_hint: userEmailHint } : {};
|
||||
const resp = await callCoreRpc<Source[] | ResultEnvelope<Source[]>>({
|
||||
method: 'openhuman.memory_tree_list_sources',
|
||||
params,
|
||||
});
|
||||
const out = unwrapResult(resp);
|
||||
console.debug('[memory-tree-rpc] memoryTreeListSources: exit n=%d', out?.length ?? 0);
|
||||
return out ?? [];
|
||||
}
|
||||
|
||||
// ── memory_tree_search ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Keyword `LIKE`-search over chunk bodies. Cheap, deterministic; useful
|
||||
* as a fallback when semantic recall is unavailable.
|
||||
*/
|
||||
export async function memoryTreeSearch(query: string, k: number): Promise<Chunk[]> {
|
||||
console.debug('[memory-tree-rpc] memoryTreeSearch: entry query_len=%d k=%d', query.length, k);
|
||||
const resp = await callCoreRpc<Chunk[] | ResultEnvelope<Chunk[]>>({
|
||||
method: 'openhuman.memory_tree_search',
|
||||
params: { query, k },
|
||||
});
|
||||
const out = unwrapResult(resp);
|
||||
console.debug('[memory-tree-rpc] memoryTreeSearch: exit n=%d', out?.length ?? 0);
|
||||
return out ?? [];
|
||||
}
|
||||
|
||||
// ── memory_tree_recall ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Semantic recall via the Phase 4 cosine rerank path. Returns leaf chunks
|
||||
* and a parallel `scores` array.
|
||||
*/
|
||||
export async function memoryTreeRecall(query: string, k: number): Promise<RecallResponse> {
|
||||
console.debug('[memory-tree-rpc] memoryTreeRecall: entry query_len=%d k=%d', query.length, k);
|
||||
const resp = await callCoreRpc<RecallResponse | ResultEnvelope<RecallResponse>>({
|
||||
method: 'openhuman.memory_tree_recall',
|
||||
params: { query, k },
|
||||
});
|
||||
const out = unwrapResult(resp);
|
||||
console.debug('[memory-tree-rpc] memoryTreeRecall: exit n=%d', out?.chunks?.length ?? 0);
|
||||
return out ?? { chunks: [], scores: [] };
|
||||
}
|
||||
|
||||
// ── memory_tree_entity_index_for ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* All canonical entities indexed against a single chunk (or summary node) id.
|
||||
*/
|
||||
export async function memoryTreeEntityIndexFor(chunkId: string): Promise<EntityRef[]> {
|
||||
console.debug('[memory-tree-rpc] memoryTreeEntityIndexFor: entry chunk_id=%s', chunkId);
|
||||
const resp = await callCoreRpc<EntityRef[] | ResultEnvelope<EntityRef[]>>({
|
||||
method: 'openhuman.memory_tree_entity_index_for',
|
||||
params: { chunk_id: chunkId },
|
||||
});
|
||||
const out = unwrapResult(resp);
|
||||
console.debug('[memory-tree-rpc] memoryTreeEntityIndexFor: exit n=%d', out?.length ?? 0);
|
||||
return out ?? [];
|
||||
}
|
||||
|
||||
// ── memory_tree_chunks_for_entity ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Inverse of `memoryTreeEntityIndexFor` — return chunk IDs that reference
|
||||
* the given entity. Used by the Memory tab's People/Topics lenses to
|
||||
* filter the chunk list to those mentioning a selected entity.
|
||||
*/
|
||||
export async function memoryTreeChunksForEntity(entityId: string): Promise<string[]> {
|
||||
console.debug('[memory-tree-rpc] memoryTreeChunksForEntity: entry entity_id=%s', entityId);
|
||||
const resp = await callCoreRpc<string[] | ResultEnvelope<string[]>>({
|
||||
method: 'openhuman.memory_tree_chunks_for_entity',
|
||||
params: { entity_id: entityId },
|
||||
});
|
||||
const out = unwrapResult(resp);
|
||||
console.debug('[memory-tree-rpc] memoryTreeChunksForEntity: exit n=%d', out?.length ?? 0);
|
||||
return out ?? [];
|
||||
}
|
||||
|
||||
// ── memory_tree_top_entities ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Most-frequent canonical entities across the workspace, optionally narrowed
|
||||
* by `kind`. The Rust handler treats `limit` as required; we default to 50
|
||||
* to match the navigator's lens cardinality.
|
||||
*/
|
||||
export async function memoryTreeTopEntities(kind?: string, limit = 50): Promise<EntityRef[]> {
|
||||
console.debug(
|
||||
'[memory-tree-rpc] memoryTreeTopEntities: entry kind=%s limit=%d',
|
||||
kind ?? '<all>',
|
||||
limit
|
||||
);
|
||||
const params: Record<string, unknown> = { limit };
|
||||
if (kind) params.kind = kind;
|
||||
const resp = await callCoreRpc<EntityRef[] | ResultEnvelope<EntityRef[]>>({
|
||||
method: 'openhuman.memory_tree_top_entities',
|
||||
params,
|
||||
});
|
||||
const out = unwrapResult(resp);
|
||||
console.debug('[memory-tree-rpc] memoryTreeTopEntities: exit n=%d', out?.length ?? 0);
|
||||
return out ?? [];
|
||||
}
|
||||
|
||||
// ── memory_tree_chunk_score ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Score breakdown stored in `mem_tree_score` for one chunk. Returns
|
||||
* `null` when the chunk has no score row (e.g. it was admitted before
|
||||
* scoring was enabled, or it is a synthesized fixture in tests).
|
||||
*/
|
||||
export async function memoryTreeChunkScore(chunkId: string): Promise<ScoreBreakdown | null> {
|
||||
console.debug('[memory-tree-rpc] memoryTreeChunkScore: entry chunk_id=%s', chunkId);
|
||||
const resp = await callCoreRpc<ScoreBreakdown | null | ResultEnvelope<ScoreBreakdown | null>>({
|
||||
method: 'openhuman.memory_tree_chunk_score',
|
||||
params: { chunk_id: chunkId },
|
||||
});
|
||||
const out = unwrapResult(resp);
|
||||
console.debug('[memory-tree-rpc] memoryTreeChunkScore: exit kept=%o', out?.kept);
|
||||
return out ?? null;
|
||||
}
|
||||
|
||||
// ── memory_tree_delete_chunk ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Purge one chunk plus its score row, entity-index rows, and on-disk .md
|
||||
* file. Idempotent — missing chunk returns `deleted=false`.
|
||||
*/
|
||||
export async function memoryTreeDeleteChunk(chunkId: string): Promise<DeleteChunkResponse> {
|
||||
console.debug('[memory-tree-rpc] memoryTreeDeleteChunk: entry chunk_id=%s', chunkId);
|
||||
const resp = await callCoreRpc<DeleteChunkResponse | ResultEnvelope<DeleteChunkResponse>>({
|
||||
method: 'openhuman.memory_tree_delete_chunk',
|
||||
params: { chunk_id: chunkId },
|
||||
});
|
||||
const out = unwrapResult(resp);
|
||||
console.debug(
|
||||
'[memory-tree-rpc] memoryTreeDeleteChunk: exit deleted=%o score_rows=%d entity_rows=%d',
|
||||
out?.deleted,
|
||||
out?.score_rows_removed,
|
||||
out?.entity_index_rows_removed
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── memory_tree_get_llm / memory_tree_set_llm ────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the currently configured LLM backend (`cloud` or `local`).
|
||||
*/
|
||||
export async function memoryTreeGetLlm(): Promise<LlmResponse> {
|
||||
console.debug('[memory-tree-rpc] memoryTreeGetLlm: entry');
|
||||
const resp = await callCoreRpc<LlmResponse | ResultEnvelope<LlmResponse>>({
|
||||
method: 'openhuman.memory_tree_get_llm',
|
||||
});
|
||||
const out = unwrapResult(resp);
|
||||
console.debug('[memory-tree-rpc] memoryTreeGetLlm: exit current=%s', out?.current);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the LLM backend selector — and, optionally, per-role model
|
||||
* choices (`cloud_model`, `extract_model`, `summariser_model`) — and
|
||||
* persist the result to `config.toml` in a single atomic write. Survives
|
||||
* sidecar restart.
|
||||
*
|
||||
* Returns the effective backend after the call (the core may downgrade
|
||||
* `local` → `cloud` if the host can't satisfy the local minimums; today
|
||||
* the handler accepts the value verbatim).
|
||||
*
|
||||
* Accepts either a bare backend string (legacy callers) or the full
|
||||
* {@link SetLlmRequest} object, so call-sites that only flip the mode
|
||||
* stay terse while sites that want to persist model picks pass the
|
||||
* extended shape.
|
||||
*/
|
||||
export async function memoryTreeSetLlm(
|
||||
reqOrBackend: LlmBackend | SetLlmRequest
|
||||
): Promise<LlmResponse> {
|
||||
const params: SetLlmRequest =
|
||||
typeof reqOrBackend === 'string' ? { backend: reqOrBackend } : reqOrBackend;
|
||||
console.debug(
|
||||
'[memory-tree-rpc] memoryTreeSetLlm: entry backend=%s cloud_model=%s extract_model=%s summariser_model=%s',
|
||||
params.backend,
|
||||
params.cloud_model ?? '<none>',
|
||||
params.extract_model ?? '<none>',
|
||||
params.summariser_model ?? '<none>'
|
||||
);
|
||||
const resp = await callCoreRpc<LlmResponse | ResultEnvelope<LlmResponse>>({
|
||||
method: 'openhuman.memory_tree_set_llm',
|
||||
params,
|
||||
});
|
||||
const out = unwrapResult(resp);
|
||||
console.debug('[memory-tree-rpc] memoryTreeSetLlm: exit current=%s', out?.current);
|
||||
return out;
|
||||
}
|
||||
@@ -624,6 +624,40 @@ async fn run_server_inner(
|
||||
});
|
||||
crate::core::auth::init_rpc_token(&token_dir)?;
|
||||
|
||||
// Initialize the global MemoryClient so composio providers
|
||||
// (gmail/slack/notion) can persist their sync_state via kv_get/kv_set,
|
||||
// and so any subsystem that calls `memory::global::client_if_ready()`
|
||||
// gets a live handle. Without this, every periodic sync bails with
|
||||
// "[composio:gmail] memory client not ready".
|
||||
{
|
||||
// Surface a config-load failure explicitly. Falling silently to
|
||||
// `Config::default()` would hide a serious operator-visible
|
||||
// problem (corrupt toml, permissions, missing OPENHUMAN_WORKSPACE
|
||||
// workspace dir) and the memory client would init against the
|
||||
// wrong workspace — leading to chunk loss / cross-workspace
|
||||
// bleed-over. We log loud, then proceed with default so the
|
||||
// server still comes up; the operator sees the error in stderr
|
||||
// and can fix their config.
|
||||
let cfg = match crate::openhuman::config::Config::load_or_init().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[boot] memory::global init: Config::load_or_init failed ({e:#}); \
|
||||
falling back to default workspace dir — fix your config.toml \
|
||||
or OPENHUMAN_WORKSPACE before relying on memory persistence"
|
||||
);
|
||||
Default::default()
|
||||
}
|
||||
};
|
||||
match crate::openhuman::memory::global::init(cfg.workspace_dir.clone()) {
|
||||
Ok(_) => log::info!(
|
||||
"[boot] memory::global initialized (workspace={})",
|
||||
cfg.workspace_dir.display()
|
||||
),
|
||||
Err(e) => log::warn!("[boot] memory::global init failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
let (resolved_port, port_source) = match port {
|
||||
Some(p) => (p, "CLI --port"),
|
||||
None => (
|
||||
|
||||
@@ -28,15 +28,15 @@ pub use schema::{
|
||||
BrowserConfig, ChannelsConfig, ComposioConfig, Config, ContextConfig, CostConfig, CronConfig,
|
||||
CurlConfig, DelegateAgentConfig, DictationActivationMode, DictationConfig, DiscordConfig,
|
||||
DockerRuntimeConfig, EmbeddingRouteConfig, GitbooksConfig, HeartbeatConfig, HttpRequestConfig,
|
||||
IMessageConfig, IntegrationToggle, IntegrationsConfig, LarkConfig, LearningConfig,
|
||||
LocalAiConfig, MatrixConfig, MemoryConfig, ModelRouteConfig, MultimodalConfig,
|
||||
ObservabilityConfig, ProxyConfig, ProxyScope, ReflectionSource, ReliabilityConfig,
|
||||
ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig,
|
||||
SchedulerGateConfig, SchedulerGateMode, ScreenIntelligenceConfig, SecretsConfig,
|
||||
SecurityConfig, SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection,
|
||||
StreamMode, TelegramConfig, UpdateConfig, VoiceActivationMode, VoiceServerConfig,
|
||||
WebSearchConfig, WebhookConfig, DEFAULT_MODEL, MODEL_AGENTIC_V1, MODEL_CODING_V1,
|
||||
MODEL_REASONING_V1,
|
||||
IMessageConfig, IntegrationToggle, IntegrationsConfig, LarkConfig, LearningConfig, LlmBackend,
|
||||
LocalAiConfig, MatrixConfig, MemoryConfig, MemoryTreeConfig, ModelRouteConfig,
|
||||
MultimodalConfig, ObservabilityConfig, ProxyConfig, ProxyScope, ReflectionSource,
|
||||
ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig,
|
||||
SchedulerConfig, SchedulerGateConfig, SchedulerGateMode, ScreenIntelligenceConfig,
|
||||
SecretsConfig, SecurityConfig, SlackConfig, StorageConfig, StorageProviderConfig,
|
||||
StorageProviderSection, StreamMode, TelegramConfig, UpdateConfig, VoiceActivationMode,
|
||||
VoiceServerConfig, WebSearchConfig, WebhookConfig, DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MODEL,
|
||||
MODEL_AGENTIC_V1, MODEL_CODING_V1, MODEL_REASONING_V1,
|
||||
};
|
||||
pub use schema::{
|
||||
clear_active_user, default_root_openhuman_dir, pre_login_user_dir, read_active_user_id,
|
||||
|
||||
@@ -998,7 +998,9 @@ impl Config {
|
||||
|
||||
// Phase MD-content: chunk body directory override. Empty string means
|
||||
// "fall back to default", consistent with other memory_tree env vars.
|
||||
if let Ok(dir) = std::env::var("OPENHUMAN_MEMORY_TREE_CONTENT_DIR") {
|
||||
// Routed through `env.get` so `HashMapEnv`-style test callers see the
|
||||
// override too — same seam as every other branch in this function.
|
||||
if let Some(dir) = env.get("OPENHUMAN_MEMORY_TREE_CONTENT_DIR") {
|
||||
let trimmed = dir.trim();
|
||||
self.memory_tree.content_dir = if trimmed.is_empty() {
|
||||
None
|
||||
@@ -1007,6 +1009,44 @@ impl Config {
|
||||
};
|
||||
}
|
||||
|
||||
// Memory-tree LLM backend selector: "cloud" (default) routes through
|
||||
// the OpenHuman backend's summarizer model; "local" keeps the legacy
|
||||
// Ollama-direct path. Empty / unset / unknown leaves the existing
|
||||
// value untouched (and we warn on unknown). The embedder is unaffected.
|
||||
if let Some(raw) = env.get("OPENHUMAN_MEMORY_TREE_LLM_BACKEND") {
|
||||
let trimmed = raw.trim();
|
||||
if !trimmed.is_empty() {
|
||||
match crate::openhuman::config::LlmBackend::parse(trimmed) {
|
||||
Ok(b) => {
|
||||
log::debug!(
|
||||
"[memory_tree] OPENHUMAN_MEMORY_TREE_LLM_BACKEND override applied: {}",
|
||||
b.as_str()
|
||||
);
|
||||
self.memory_tree.llm_backend = b;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
value = trimmed,
|
||||
error = %e,
|
||||
"ignoring invalid OPENHUMAN_MEMORY_TREE_LLM_BACKEND (valid: cloud, local)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cloud LLM model override (only meaningful when llm_backend = cloud).
|
||||
// Empty string explicitly clears the default — useful for tests that
|
||||
// want to assert the absence of a configured cloud model. Non-empty
|
||||
// strings are stored verbatim.
|
||||
if let Some(raw) = env.get("OPENHUMAN_MEMORY_TREE_CLOUD_LLM_MODEL") {
|
||||
let trimmed = raw.trim();
|
||||
self.memory_tree.cloud_llm_model = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-update overrides
|
||||
if let Some(flag) = env.get("OPENHUMAN_AUTO_UPDATE_ENABLED") {
|
||||
let normalized = flag.trim().to_ascii_lowercase();
|
||||
|
||||
@@ -56,7 +56,8 @@ pub use routes::{EmbeddingRouteConfig, ModelRouteConfig};
|
||||
pub use runtime::{DockerRuntimeConfig, ReliabilityConfig, RuntimeConfig, SchedulerConfig};
|
||||
pub use scheduler_gate::{SchedulerGateConfig, SchedulerGateMode};
|
||||
pub use storage_memory::{
|
||||
MemoryConfig, MemoryTreeConfig, StorageConfig, StorageProviderConfig, StorageProviderSection,
|
||||
LlmBackend, MemoryConfig, MemoryTreeConfig, StorageConfig, StorageProviderConfig,
|
||||
StorageProviderSection, DEFAULT_CLOUD_LLM_MODEL,
|
||||
};
|
||||
pub use tools::{
|
||||
BrowserComputerUseConfig, BrowserConfig, ComposioConfig, ComputerControlConfig, CurlConfig,
|
||||
|
||||
@@ -74,6 +74,66 @@ impl Default for MemoryConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which inference backend the memory_tree's LLM calls (extractor +
|
||||
/// summariser) should use.
|
||||
///
|
||||
/// - `Cloud` (default): route through `providers::router` against the
|
||||
/// OpenHuman backend with the `summarization-v1` model. No local Ollama
|
||||
/// required.
|
||||
/// - `Local`: keep using the legacy Ollama-direct path (the
|
||||
/// `llm_extractor_endpoint` / `llm_summariser_endpoint` config). Useful
|
||||
/// for offline development and CI smoke tests.
|
||||
///
|
||||
/// Embedder selection is unchanged — `OllamaEmbedder` (bge-m3) stays
|
||||
/// local-only and isn't governed by this enum.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LlmBackend {
|
||||
/// Route through the OpenHuman backend (default).
|
||||
Cloud,
|
||||
/// Use the local Ollama path configured via `llm_extractor_*` /
|
||||
/// `llm_summariser_*`.
|
||||
Local,
|
||||
}
|
||||
|
||||
impl LlmBackend {
|
||||
/// Stable wire string for env vars / RPCs / logs.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Cloud => "cloud",
|
||||
Self::Local => "local",
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`Self::as_str`]; case-insensitive parse.
|
||||
pub fn parse(s: &str) -> Result<Self, String> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"cloud" => Ok(Self::Cloud),
|
||||
"local" => Ok(Self::Local),
|
||||
other => Err(format!("unknown llm (expected cloud|local): {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LlmBackend {
|
||||
fn default() -> Self {
|
||||
Self::Cloud
|
||||
}
|
||||
}
|
||||
|
||||
fn default_llm_backend() -> LlmBackend {
|
||||
LlmBackend::default()
|
||||
}
|
||||
|
||||
/// Default model identifier to use when `llm_backend = "cloud"`. Routed
|
||||
/// through the OpenHuman backend; keep in sync with the backend's
|
||||
/// summariser model registry.
|
||||
pub const DEFAULT_CLOUD_LLM_MODEL: &str = "summarization-v1";
|
||||
|
||||
fn default_cloud_llm_model() -> Option<String> {
|
||||
Some(DEFAULT_CLOUD_LLM_MODEL.to_string())
|
||||
}
|
||||
|
||||
/// Phase 4 memory-tree configuration — embedding provider wiring for the
|
||||
/// hierarchical memory (#710).
|
||||
///
|
||||
@@ -95,6 +155,8 @@ impl Default for MemoryConfig {
|
||||
/// - `OPENHUMAN_MEMORY_SUMMARISE_MODEL`
|
||||
/// - `OPENHUMAN_MEMORY_SUMMARISE_TIMEOUT_MS`
|
||||
/// - `OPENHUMAN_MEMORY_TREE_CONTENT_DIR` (Phase MD-content)
|
||||
/// - `OPENHUMAN_MEMORY_TREE_LLM_BACKEND` (cloud|local)
|
||||
/// - `OPENHUMAN_MEMORY_TREE_CLOUD_LLM_MODEL`
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct MemoryTreeConfig {
|
||||
/// Ollama endpoint for the embedder (e.g. `http://localhost:11434`).
|
||||
@@ -164,6 +226,25 @@ pub struct MemoryTreeConfig {
|
||||
/// back to default, consistent with other memory_tree env vars).
|
||||
#[serde(default = "default_memory_tree_content_dir")]
|
||||
pub content_dir: Option<PathBuf>,
|
||||
|
||||
/// Backend selector for the memory_tree's LLM calls (extractor +
|
||||
/// summariser). Defaults to [`LlmBackend::Cloud`] so a fresh install
|
||||
/// works without requiring a local Ollama daemon. Set to
|
||||
/// [`LlmBackend::Local`] (or `OPENHUMAN_MEMORY_TREE_LLM_BACKEND=local`) to
|
||||
/// keep the legacy Ollama-direct path.
|
||||
///
|
||||
/// The embedder is unaffected by this setting — `OllamaEmbedder` (bge-m3)
|
||||
/// stays local-only.
|
||||
#[serde(default = "default_llm_backend")]
|
||||
pub llm_backend: LlmBackend,
|
||||
|
||||
/// Model identifier used when `llm_backend = "cloud"`. Routed through the
|
||||
/// OpenHuman backend's chat-completions surface.
|
||||
///
|
||||
/// Defaults to [`DEFAULT_CLOUD_LLM_MODEL`] (`summarization-v1`).
|
||||
/// Env override: `OPENHUMAN_MEMORY_TREE_CLOUD_LLM_MODEL`.
|
||||
#[serde(default = "default_cloud_llm_model")]
|
||||
pub cloud_llm_model: Option<String>,
|
||||
}
|
||||
|
||||
/// Returns `None` so that existing installs that never opted into Phase 4
|
||||
@@ -229,6 +310,8 @@ impl Default for MemoryTreeConfig {
|
||||
llm_summariser_model: default_memory_tree_llm_endpoint(),
|
||||
llm_summariser_timeout_ms: default_memory_tree_llm_summariser_timeout_ms(),
|
||||
content_dir: default_memory_tree_content_dir(),
|
||||
llm_backend: default_llm_backend(),
|
||||
cloud_llm_model: default_cloud_llm_model(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,6 +320,41 @@ impl Default for MemoryTreeConfig {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn llm_default_is_cloud() {
|
||||
assert_eq!(LlmBackend::default(), LlmBackend::Cloud);
|
||||
assert_eq!(MemoryTreeConfig::default().llm_backend, LlmBackend::Cloud);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_round_trip() {
|
||||
for v in [LlmBackend::Cloud, LlmBackend::Local] {
|
||||
assert_eq!(LlmBackend::parse(v.as_str()).unwrap(), v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_parse_is_case_insensitive() {
|
||||
assert_eq!(LlmBackend::parse("CLOUD").unwrap(), LlmBackend::Cloud);
|
||||
assert_eq!(LlmBackend::parse(" Local ").unwrap(), LlmBackend::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_parse_rejects_unknown() {
|
||||
assert!(LlmBackend::parse("hybrid").is_err());
|
||||
assert!(LlmBackend::parse("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_llm_model_default_is_summarizer_v1() {
|
||||
let cfg = MemoryTreeConfig::default();
|
||||
assert_eq!(
|
||||
cfg.cloud_llm_model.as_deref(),
|
||||
Some(DEFAULT_CLOUD_LLM_MODEL)
|
||||
);
|
||||
assert_eq!(DEFAULT_CLOUD_LLM_MODEL, "summarization-v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_tree_config_default_content_dir_is_none() {
|
||||
let cfg = MemoryTreeConfig::default();
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Cloud chat provider — routes through the OpenHuman backend's
|
||||
//! `/openai/v1/chat/completions` surface using the existing
|
||||
//! [`crate::openhuman::providers::openhuman_backend::OpenHumanBackendProvider`].
|
||||
//!
|
||||
//! Used when `memory_tree.llm_backend = "cloud"` (the default). The
|
||||
//! request shape is the standard OpenAI-compatible chat-completions
|
||||
//! protocol, with `temperature: 0.0` and a `summarization-v1` (or
|
||||
//! caller-configured) model.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::openhuman::providers::openhuman_backend::OpenHumanBackendProvider;
|
||||
use crate::openhuman::providers::traits::{ChatMessage, Provider};
|
||||
use crate::openhuman::providers::ProviderRuntimeOptions;
|
||||
|
||||
use super::{ChatPrompt, ChatProvider};
|
||||
|
||||
/// Cloud-routed chat provider. Holds an [`OpenHumanBackendProvider`] and
|
||||
/// forwards each [`ChatProvider::chat_for_json`] call through its
|
||||
/// `chat_with_history` method.
|
||||
pub struct CloudChatProvider {
|
||||
inner: OpenHumanBackendProvider,
|
||||
model: String,
|
||||
/// Cached display name `"cloud:<model>"` for logs.
|
||||
display: String,
|
||||
}
|
||||
|
||||
impl CloudChatProvider {
|
||||
/// Build a new cloud provider against `api_url` (or the default
|
||||
/// `effective_api_url` when `None`) for `model`. The provider does NOT
|
||||
/// resolve the bearer token at construction — it does so per request,
|
||||
/// matching the existing `OpenHumanBackendProvider` contract. That way
|
||||
/// a session refresh between memory-tree calls is picked up
|
||||
/// transparently.
|
||||
///
|
||||
/// `openhuman_dir` is the directory containing `auth-profiles.json` (i.e.
|
||||
/// the parent of `config.config_path`). Without it the inner provider
|
||||
/// would fall back to `~/.openhuman` and fail with "No backend session"
|
||||
/// on workspaces not located at the home default.
|
||||
pub fn new(
|
||||
api_url: Option<String>,
|
||||
model: String,
|
||||
openhuman_dir: Option<PathBuf>,
|
||||
secrets_encrypt: bool,
|
||||
) -> Self {
|
||||
let opts = ProviderRuntimeOptions {
|
||||
openhuman_dir,
|
||||
secrets_encrypt,
|
||||
..ProviderRuntimeOptions::default()
|
||||
};
|
||||
let inner = OpenHumanBackendProvider::new(api_url.as_deref(), &opts);
|
||||
let display = format!("cloud:{model}");
|
||||
Self {
|
||||
inner,
|
||||
model,
|
||||
display,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatProvider for CloudChatProvider {
|
||||
fn name(&self) -> &str {
|
||||
&self.display
|
||||
}
|
||||
|
||||
async fn chat_for_json(&self, prompt: &ChatPrompt) -> Result<String> {
|
||||
log::debug!(
|
||||
"[memory_tree::chat::cloud] kind={} model={} sys_chars={} user_chars={}",
|
||||
prompt.kind,
|
||||
self.model,
|
||||
prompt.system.len(),
|
||||
prompt.user.len()
|
||||
);
|
||||
|
||||
let messages = vec![
|
||||
ChatMessage::system(prompt.system.clone()),
|
||||
ChatMessage::user(prompt.user.clone()),
|
||||
];
|
||||
|
||||
let text = self
|
||||
.inner
|
||||
.chat_with_history(&messages, &self.model, prompt.temperature)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"cloud chat request kind={} model={} failed",
|
||||
prompt.kind, self.model
|
||||
)
|
||||
})?;
|
||||
|
||||
log::debug!(
|
||||
"[memory_tree::chat::cloud] response chars={} kind={}",
|
||||
text.len(),
|
||||
prompt.kind
|
||||
);
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn name_includes_model() {
|
||||
let p = CloudChatProvider::new(None, "summarization-v1".into(), None, true);
|
||||
assert_eq!(p.name(), "cloud:summarization-v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_changes_with_model() {
|
||||
let p = CloudChatProvider::new(None, "claude-haiku-4.5".into(), None, true);
|
||||
assert!(p.name().contains("claude-haiku-4.5"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Local Ollama chat provider — the legacy `llm_backend = "local"` path.
|
||||
//!
|
||||
//! Speaks Ollama's `/api/chat` with `format: "json"` and
|
||||
//! `temperature: 0.0`. Mirrors what the per-extractor/summariser HTTP client
|
||||
//! used to do, but behind the [`super::ChatProvider`] trait so the same
|
||||
//! call site can be cloud-routed instead.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{ChatPrompt, ChatProvider};
|
||||
|
||||
/// Ollama-direct chat provider.
|
||||
pub struct OllamaChatProvider {
|
||||
endpoint: String,
|
||||
model: String,
|
||||
http: Client,
|
||||
/// Cached display name `"local:ollama:<model>"` for logs.
|
||||
display: String,
|
||||
}
|
||||
|
||||
impl OllamaChatProvider {
|
||||
/// Build the provider. `endpoint` and `model` may be `None` — when
|
||||
/// either is unset, [`ChatProvider::chat_for_json`] returns a clear
|
||||
/// error so the caller's soft-fallback path engages and the seal/admit
|
||||
/// pipeline keeps running.
|
||||
pub fn new(endpoint: Option<String>, model: Option<String>, timeout: Duration) -> Result<Self> {
|
||||
// No body-read timeout. Ollama is a local process — slow responses
|
||||
// mean the model is genuinely processing under CPU load (e.g.
|
||||
// gemma3:1b on CPU-only inference can take minutes per call), not
|
||||
// that the network broke. A body-read timeout here would cancel
|
||||
// mid-flight generation and force pointless retries against the
|
||||
// same slow model. `timeout` becomes the TCP connect timeout —
|
||||
// short enough to fail fast when Ollama is actually unreachable.
|
||||
let http = Client::builder()
|
||||
.connect_timeout(timeout)
|
||||
.build()
|
||||
.context("build ollama http client")?;
|
||||
let endpoint = endpoint.unwrap_or_default();
|
||||
let model = model.unwrap_or_default();
|
||||
let display = format!(
|
||||
"local:ollama:{}",
|
||||
if model.is_empty() { "<unset>" } else { &model }
|
||||
);
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
model,
|
||||
http,
|
||||
display,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatProvider for OllamaChatProvider {
|
||||
fn name(&self) -> &str {
|
||||
&self.display
|
||||
}
|
||||
|
||||
async fn chat_for_json(&self, prompt: &ChatPrompt) -> Result<String> {
|
||||
if self.endpoint.is_empty() || self.model.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"[memory_tree::chat::local] Ollama endpoint or model not configured \
|
||||
(endpoint_set={}, model_set={}); set memory_tree.llm_*_endpoint / \
|
||||
_model in config or switch memory_tree.llm_backend to cloud",
|
||||
!self.endpoint.is_empty(),
|
||||
!self.model.is_empty()
|
||||
));
|
||||
}
|
||||
let url = format!("{}/api/chat", self.endpoint.trim_end_matches('/'));
|
||||
let body = OllamaChatRequest {
|
||||
model: self.model.clone(),
|
||||
messages: vec![
|
||||
OllamaMessage {
|
||||
role: "system".to_string(),
|
||||
content: prompt.system.clone(),
|
||||
},
|
||||
OllamaMessage {
|
||||
role: "user".to_string(),
|
||||
content: prompt.user.clone(),
|
||||
},
|
||||
],
|
||||
format: "json".to_string(),
|
||||
stream: false,
|
||||
options: OllamaOptions {
|
||||
temperature: prompt.temperature as f32,
|
||||
},
|
||||
};
|
||||
|
||||
log::debug!(
|
||||
"[memory_tree::chat::local] POST {url} kind={} model={} sys_chars={} user_chars={}",
|
||||
prompt.kind,
|
||||
self.model,
|
||||
prompt.system.len(),
|
||||
prompt.user.len()
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("ollama POST {url}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let snippet = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"ollama non-success status {status}: {}",
|
||||
truncate_for_log(&snippet, 200)
|
||||
));
|
||||
}
|
||||
|
||||
let envelope: OllamaChatResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.context("decode ollama chat response envelope")?;
|
||||
log::debug!(
|
||||
"[memory_tree::chat::local] ollama response chars={} kind={}",
|
||||
envelope.message.content.len(),
|
||||
prompt.kind
|
||||
);
|
||||
Ok(envelope.message.content)
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_for_log(s: &str, max_chars: usize) -> String {
|
||||
if s.chars().count() <= max_chars {
|
||||
return s.to_string();
|
||||
}
|
||||
let truncated: String = s.chars().take(max_chars).collect();
|
||||
format!("{truncated}…")
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaChatRequest {
|
||||
model: String,
|
||||
messages: Vec<OllamaMessage>,
|
||||
format: String,
|
||||
stream: bool,
|
||||
options: OllamaOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaOptions {
|
||||
temperature: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaChatResponse {
|
||||
message: OllamaResponseMessage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaResponseMessage {
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn errors_clearly_when_endpoint_missing() {
|
||||
let p = OllamaChatProvider::new(None, Some("m".into()), Duration::from_millis(50)).unwrap();
|
||||
let err = p
|
||||
.chat_for_json(&ChatPrompt {
|
||||
system: "s".into(),
|
||||
user: "u".into(),
|
||||
temperature: 0.0,
|
||||
kind: "test",
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("not configured"),
|
||||
"expected config error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn errors_when_model_missing() {
|
||||
let p = OllamaChatProvider::new(
|
||||
Some("http://localhost:11434".into()),
|
||||
None,
|
||||
Duration::from_millis(50),
|
||||
)
|
||||
.unwrap();
|
||||
let err = p
|
||||
.chat_for_json(&ChatPrompt {
|
||||
system: "s".into(),
|
||||
user: "u".into(),
|
||||
temperature: 0.0,
|
||||
kind: "test",
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(format!("{err}").contains("not configured"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_failure_returns_err() {
|
||||
// Endpoint pointing at an unreachable port. The provider returns
|
||||
// Err — the consumer is responsible for soft-fallback.
|
||||
let p = OllamaChatProvider::new(
|
||||
Some("http://127.0.0.1:1".into()),
|
||||
Some("m".into()),
|
||||
Duration::from_millis(50),
|
||||
)
|
||||
.unwrap();
|
||||
let err = p
|
||||
.chat_for_json(&ChatPrompt {
|
||||
system: "s".into(),
|
||||
user: "u".into(),
|
||||
temperature: 0.0,
|
||||
kind: "test",
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
// Connection error chain — message contains "ollama POST" prefix.
|
||||
assert!(format!("{err}").contains("ollama POST"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_includes_model() {
|
||||
let p =
|
||||
OllamaChatProvider::new(None, Some("qwen2.5:0.5b".into()), Duration::from_millis(50))
|
||||
.unwrap();
|
||||
assert!(p.name().contains("qwen2.5:0.5b"));
|
||||
assert!(p.name().starts_with("local:ollama:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_handles_unset_model() {
|
||||
let p = OllamaChatProvider::new(None, None, Duration::from_millis(50)).unwrap();
|
||||
assert!(p.name().contains("<unset>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_log_short_unchanged() {
|
||||
assert_eq!(truncate_for_log("hi", 10), "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_log_long_appends_ellipsis() {
|
||||
let long = "x".repeat(500);
|
||||
let out = truncate_for_log(&long, 10);
|
||||
assert_eq!(out.chars().count(), 11);
|
||||
assert!(out.ends_with('…'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Memory-tree chat backend abstraction.
|
||||
//!
|
||||
//! The memory_tree's two LLM consumers (the entity extractor and the
|
||||
//! summariser) both want a small, structured "give me JSON for this prompt"
|
||||
//! call. Historically each built its own `reqwest::Client` and talked to a
|
||||
//! local Ollama daemon directly. This module replaces that with a single
|
||||
//! [`ChatProvider`] trait so the same call site can be served by either:
|
||||
//!
|
||||
//! - **Cloud** — `providers::router` against the OpenHuman backend with
|
||||
//! the `summarization-v1` model. No local daemon required. Default for new
|
||||
//! installs.
|
||||
//! - **Local** — the legacy Ollama-direct path. Opt-in via
|
||||
//! `memory_tree.llm_backend = "local"` in config or
|
||||
//! `OPENHUMAN_MEMORY_TREE_LLM_BACKEND=local`.
|
||||
//!
|
||||
//! ## Why a memory-tree-local trait
|
||||
//!
|
||||
//! The existing top-level [`crate::openhuman::providers::Provider`] trait
|
||||
//! is rich (streaming, native tool calling, vision, …) and depends on the
|
||||
//! agent's full conversation surface. The extractor and summariser only
|
||||
//! need:
|
||||
//!
|
||||
//! 1. Send a (system, user) prompt pair.
|
||||
//! 2. Get a JSON-shaped string back.
|
||||
//!
|
||||
//! Defining [`ChatProvider`] here keeps the memory_tree decoupled from
|
||||
//! the agent's prompt/tool-calling stack, makes the extractor/summariser
|
||||
//! trivial to mock in unit tests, and lets us route either the cloud or
|
||||
//! the local backend through the same trait object.
|
||||
//!
|
||||
//! ## Soft-fallback contract
|
||||
//!
|
||||
//! Implementations of `chat_for_json` MUST NOT return `Err` for transient
|
||||
//! upstream issues. Both memory_tree consumers fall back to a deterministic
|
||||
//! no-op when the LLM is unavailable; bubbling the error up would abort
|
||||
//! ingest cascades. Real bugs (e.g. malformed config) are still acceptable
|
||||
//! `Err` cases — they should be rare and surfaced loudly.
|
||||
//!
|
||||
//! See [`local::OllamaChatProvider`] and [`cloud::CloudChatProvider`] for
|
||||
//! the two production implementations.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::openhuman::config::{Config, LlmBackend, DEFAULT_CLOUD_LLM_MODEL};
|
||||
|
||||
pub mod cloud;
|
||||
pub mod local;
|
||||
|
||||
/// One pair of prompt messages handed to the chat backend.
|
||||
///
|
||||
/// Keeps the surface deliberately tiny — the memory_tree's two consumers
|
||||
/// both build a system prompt + a single user message. Multi-turn,
|
||||
/// streaming, and tool calling are out of scope.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatPrompt {
|
||||
/// System prompt anchoring the model's role and expected output schema.
|
||||
pub system: String,
|
||||
/// User prompt carrying the dynamic input (the chunk text, the inputs
|
||||
/// to summarise, etc.).
|
||||
pub user: String,
|
||||
/// Sampling temperature. Both consumers use 0.0 today (max determinism).
|
||||
pub temperature: f64,
|
||||
/// Diagnostic tag included in tracing logs so seal-time and admit-time
|
||||
/// calls are easy to disambiguate. Stable, lowercase, no PII.
|
||||
pub kind: &'static str,
|
||||
}
|
||||
|
||||
/// Pluggable chat surface used by the memory_tree's extractor + summariser.
|
||||
///
|
||||
/// Returns the model's raw output as a string. Callers parse it themselves
|
||||
/// (typically as JSON conforming to a schema embedded in the system prompt)
|
||||
/// because the parsing logic is consumer-specific.
|
||||
#[async_trait]
|
||||
pub trait ChatProvider: Send + Sync {
|
||||
/// Stable, grep-friendly name for logs. e.g. `"cloud:summarization-v1"`.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Run one chat completion and return the assistant's content.
|
||||
///
|
||||
/// Implementations should log entry / exit at debug level under the
|
||||
/// `[memory_tree::chat]` prefix.
|
||||
async fn chat_for_json(&self, prompt: &ChatPrompt) -> Result<String>;
|
||||
}
|
||||
|
||||
/// Build the [`ChatProvider`] dictated by `config.memory_tree.llm_backend`.
|
||||
///
|
||||
/// - `Cloud` (default): wires [`cloud::CloudChatProvider`] against the
|
||||
/// OpenHuman backend with `cloud_llm_model` (defaulting to
|
||||
/// `summarization-v1`).
|
||||
/// - `Local`: wires [`local::OllamaChatProvider`] against the legacy
|
||||
/// `llm_extractor_endpoint` / `llm_extractor_model` config — the same
|
||||
/// knobs that drove the Ollama-direct path before this refactor.
|
||||
///
|
||||
/// `consumer` is one of `"extract"` / `"summarise"` and selects the local
|
||||
/// endpoint+model pair (extract uses `llm_extractor_*`, summarise uses
|
||||
/// `llm_summariser_*`). For cloud both consumers share the same model.
|
||||
pub fn build_chat_provider(
|
||||
config: &Config,
|
||||
consumer: ChatConsumer,
|
||||
) -> Result<Arc<dyn ChatProvider>> {
|
||||
match config.memory_tree.llm_backend {
|
||||
LlmBackend::Cloud => {
|
||||
let model = config
|
||||
.memory_tree
|
||||
.cloud_llm_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_CLOUD_LLM_MODEL.to_string());
|
||||
// The `auth-profiles.json` lives next to `config.toml`, so the
|
||||
// openhuman_dir is the parent of config_path. Without this the
|
||||
// inner OpenHumanBackendProvider falls back to `~/.openhuman`
|
||||
// and fails with "No backend session" on any workspace not
|
||||
// located at the home default — the bug observed when running
|
||||
// with `OPENHUMAN_WORKSPACE` pointed elsewhere.
|
||||
let openhuman_dir = config.config_path.parent().map(std::path::PathBuf::from);
|
||||
log::debug!(
|
||||
"[memory_tree::chat] building Cloud provider consumer={} model={} \
|
||||
openhuman_dir={:?}",
|
||||
consumer.as_str(),
|
||||
model,
|
||||
openhuman_dir
|
||||
);
|
||||
Ok(Arc::new(cloud::CloudChatProvider::new(
|
||||
config.api_url.clone(),
|
||||
model,
|
||||
openhuman_dir,
|
||||
config.secrets.encrypt,
|
||||
)))
|
||||
}
|
||||
LlmBackend::Local => {
|
||||
let (endpoint, model, timeout_ms) = match consumer {
|
||||
ChatConsumer::Extract => (
|
||||
config.memory_tree.llm_extractor_endpoint.clone(),
|
||||
config.memory_tree.llm_extractor_model.clone(),
|
||||
config
|
||||
.memory_tree
|
||||
.llm_extractor_timeout_ms
|
||||
.unwrap_or(15_000),
|
||||
),
|
||||
ChatConsumer::Summarise => (
|
||||
config.memory_tree.llm_summariser_endpoint.clone(),
|
||||
config.memory_tree.llm_summariser_model.clone(),
|
||||
config
|
||||
.memory_tree
|
||||
.llm_summariser_timeout_ms
|
||||
.unwrap_or(120_000),
|
||||
),
|
||||
};
|
||||
log::debug!(
|
||||
"[memory_tree::chat] building Local (Ollama) provider consumer={} \
|
||||
endpoint_set={} model_set={} timeout_ms={}",
|
||||
consumer.as_str(),
|
||||
endpoint.is_some(),
|
||||
model.is_some(),
|
||||
timeout_ms
|
||||
);
|
||||
Ok(Arc::new(local::OllamaChatProvider::new(
|
||||
endpoint,
|
||||
model,
|
||||
std::time::Duration::from_millis(timeout_ms),
|
||||
)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which memory-tree consumer is requesting a chat provider. Determines
|
||||
/// which `llm_*_endpoint` / `llm_*_model` config fields are read in the
|
||||
/// `Local` branch. Both consumers share the same cloud model.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ChatConsumer {
|
||||
/// `LlmEntityExtractor` (per-chunk NER + importance rating).
|
||||
Extract,
|
||||
/// `LlmSummariser` (bucket-seal summary of N children).
|
||||
Summarise,
|
||||
}
|
||||
|
||||
impl ChatConsumer {
|
||||
/// Stable wire string used in logs.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Extract => "extract",
|
||||
Self::Summarise => "summarise",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// In-memory chat provider for unit tests. Returns a canned response
|
||||
/// regardless of the prompt and counts invocations so tests can assert
|
||||
/// they were exercised.
|
||||
pub struct StaticChatProvider {
|
||||
pub response: String,
|
||||
pub calls: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl StaticChatProvider {
|
||||
pub fn new(response: impl Into<String>) -> Self {
|
||||
Self {
|
||||
response: response.into(),
|
||||
calls: std::sync::atomic::AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatProvider for StaticChatProvider {
|
||||
fn name(&self) -> &str {
|
||||
"test:static"
|
||||
}
|
||||
async fn chat_for_json(&self, _prompt: &ChatPrompt) -> Result<String> {
|
||||
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok(self.response.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_provider_returns_cloud_when_default() {
|
||||
let cfg = Config::default();
|
||||
// Default is LlmBackend::Cloud — provider construction must succeed
|
||||
// without a configured local Ollama endpoint.
|
||||
let provider = build_chat_provider(&cfg, ChatConsumer::Extract).unwrap();
|
||||
assert!(provider.name().contains("cloud"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_provider_returns_local_when_configured() {
|
||||
let mut cfg = Config::default();
|
||||
cfg.memory_tree.llm_backend = LlmBackend::Local;
|
||||
cfg.memory_tree.llm_extractor_endpoint = Some("http://localhost:11434".into());
|
||||
cfg.memory_tree.llm_extractor_model = Some("qwen2.5:0.5b".into());
|
||||
let provider = build_chat_provider(&cfg, ChatConsumer::Extract).unwrap();
|
||||
assert!(provider.name().contains("ollama") || provider.name().contains("local"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_consumer_str_round_trip() {
|
||||
assert_eq!(ChatConsumer::Extract.as_str(), "extract");
|
||||
assert_eq!(ChatConsumer::Summarise.as_str(), "summarise");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn static_chat_provider_returns_response_and_counts() {
|
||||
let p = StaticChatProvider::new("hello");
|
||||
let prompt = ChatPrompt {
|
||||
system: "sys".into(),
|
||||
user: "u".into(),
|
||||
temperature: 0.0,
|
||||
kind: "test",
|
||||
};
|
||||
assert_eq!(p.chat_for_json(&prompt).await.unwrap(), "hello");
|
||||
assert_eq!(p.calls.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,9 @@ pub fn claim_next(config: &Config, lock_duration_ms: i64) -> Result<Option<Job>>
|
||||
|
||||
let row = conn
|
||||
.query_row(
|
||||
// Drain forward, don't widen. Most-downstream kinds run
|
||||
// first so a slow LLM-bound `extract_chunk` can't starve
|
||||
// the routing/seal/digest pipeline behind it.
|
||||
"UPDATE mem_tree_jobs
|
||||
SET status = 'running',
|
||||
attempts = attempts + 1,
|
||||
@@ -113,7 +116,16 @@ pub fn claim_next(config: &Config, lock_duration_ms: i64) -> Result<Option<Job>>
|
||||
SELECT id FROM mem_tree_jobs
|
||||
WHERE status = 'ready'
|
||||
AND available_at_ms <= ?1
|
||||
ORDER BY available_at_ms ASC
|
||||
ORDER BY
|
||||
CASE kind
|
||||
WHEN 'digest_daily' THEN 0
|
||||
WHEN 'seal' THEN 1
|
||||
WHEN 'flush_stale' THEN 2
|
||||
WHEN 'topic_route' THEN 3
|
||||
WHEN 'append_buffer' THEN 4
|
||||
ELSE 5
|
||||
END ASC,
|
||||
available_at_ms ASC
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id, kind, payload_json, dedupe_key, status, attempts,
|
||||
|
||||
@@ -14,7 +14,13 @@ use crate::openhuman::memory::tree::jobs::store::{
|
||||
claim_next, mark_done, mark_failed, recover_stale_locks, DEFAULT_LOCK_DURATION_MS,
|
||||
};
|
||||
|
||||
const WORKER_COUNT: usize = 3;
|
||||
// Held at 1 to keep concurrent bge-m3 embed calls (8K context, ~1.3 GB
|
||||
// resident each) from saturating local RAM. The cloud-chat path itself
|
||||
// would be fine at higher concurrency, but every worker also runs a
|
||||
// bge-m3 embed step, and 3 concurrent embeds at 8K context have
|
||||
// crashed the laptop in practice. See feedback memory
|
||||
// `feedback_local_llm_load.md`.
|
||||
const WORKER_COUNT: usize = 1;
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
static WORKER_NOTIFY: OnceLock<Arc<Notify>> = OnceLock::new();
|
||||
@@ -41,7 +47,7 @@ pub fn start(config: Config) {
|
||||
let notify = WORKER_NOTIFY
|
||||
.get_or_init(|| Arc::new(Notify::new()))
|
||||
.clone();
|
||||
let llm_slots = Arc::new(Semaphore::new(3));
|
||||
let llm_slots = Arc::new(Semaphore::new(1));
|
||||
if let Err(err) = recover_stale_locks(&config) {
|
||||
log::warn!("[memory_tree::jobs] recover_stale_locks failed at startup: {err:#}");
|
||||
}
|
||||
|
||||
@@ -23,10 +23,12 @@
|
||||
//! top of these chunks without modifying the Phase 1 surface.
|
||||
|
||||
pub mod canonicalize;
|
||||
pub mod chat;
|
||||
pub mod chunker;
|
||||
pub mod content_store;
|
||||
pub mod ingest;
|
||||
pub mod jobs;
|
||||
pub mod read_rpc;
|
||||
pub mod retrieval;
|
||||
pub mod rpc;
|
||||
pub mod schemas;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,14 @@
|
||||
//! Controller schemas for the memory tree (Phase 1 / #707).
|
||||
//! Controller schemas for the memory tree.
|
||||
//!
|
||||
//! Registered JSON-RPC methods:
|
||||
//! - `openhuman.memory_tree_ingest` — unified ingest (source_kind + JSON payload)
|
||||
//! - `openhuman.memory_tree_list_chunks`
|
||||
//! - `openhuman.memory_tree_get_chunk`
|
||||
//! Registered JSON-RPC methods include the original Phase 1 surface
|
||||
//! (`ingest`, `list_chunks`, `get_chunk`, `trigger_digest`) plus the new
|
||||
//! Memory-tab read RPCs added by the cloud-default backend refactor:
|
||||
//! `list_sources`, `search`, `recall`, `entity_index_for`,
|
||||
//! `top_entities`, `chunk_score`, `delete_chunk`, plus
|
||||
//! `get_llm` / `set_llm` for the backend-selector UI.
|
||||
//!
|
||||
//! Handlers delegate to [`super::rpc`].
|
||||
//! Handlers delegate to [`super::rpc`] (write side) or
|
||||
//! [`super::read_rpc`] (UI read side).
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Map, Value};
|
||||
@@ -13,6 +16,7 @@ use serde_json::{Map, Value};
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::openhuman::config::rpc as config_rpc;
|
||||
use crate::openhuman::memory::tree::read_rpc;
|
||||
use crate::openhuman::memory::tree::rpc as tree_rpc;
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
@@ -26,6 +30,16 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("list_chunks"),
|
||||
schemas("get_chunk"),
|
||||
schemas("trigger_digest"),
|
||||
schemas("list_sources"),
|
||||
schemas("search"),
|
||||
schemas("recall"),
|
||||
schemas("entity_index_for"),
|
||||
schemas("chunks_for_entity"),
|
||||
schemas("top_entities"),
|
||||
schemas("chunk_score"),
|
||||
schemas("delete_chunk"),
|
||||
schemas("get_llm"),
|
||||
schemas("set_llm"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -49,6 +63,46 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("trigger_digest"),
|
||||
handler: handle_trigger_digest,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("list_sources"),
|
||||
handler: handle_list_sources,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("search"),
|
||||
handler: handle_search,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("recall"),
|
||||
handler: handle_recall,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("entity_index_for"),
|
||||
handler: handle_entity_index_for,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("chunks_for_entity"),
|
||||
handler: handle_chunks_for_entity,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("top_entities"),
|
||||
handler: handle_top_entities,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("chunk_score"),
|
||||
handler: handle_chunk_score,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("delete_chunk"),
|
||||
handler: handle_delete_chunk,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("get_llm"),
|
||||
handler: handle_get_llm,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("set_llm"),
|
||||
handler: handle_set_llm,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -129,26 +183,26 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
namespace: NAMESPACE,
|
||||
function: "list_chunks",
|
||||
description:
|
||||
"List stored chunks, newest first, optionally filtered by source / owner / time.",
|
||||
"Paginated list of chunks with optional filters by source kind / source id / \
|
||||
entity ids / time window / keyword. Returns chunks plus total match count for \
|
||||
pagination.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "source_kind",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Enum {
|
||||
variants: vec!["chat", "email", "document"],
|
||||
})),
|
||||
comment: "Restrict to a single source kind.",
|
||||
name: "source_kinds",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(TypeSchema::String)))),
|
||||
comment: "Restrict to one or more source kinds (chat / email / document).",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "source_id",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Restrict to a single logical source (channel/thread/doc id).",
|
||||
name: "source_ids",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(TypeSchema::String)))),
|
||||
comment: "Restrict to one or more logical source ids.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "owner",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Restrict to a single owner/account.",
|
||||
name: "entity_ids",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(TypeSchema::String)))),
|
||||
comment: "Restrict to chunks indexed against any of these canonical entity ids.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
@@ -163,19 +217,39 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
comment: "Inclusive upper bound on chunk timestamp (ms since epoch).",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "query",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Substring keyword filter over chunk preview content.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "limit",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||
comment: "Maximum rows to return (defaults to 100).",
|
||||
comment: "Maximum rows per page (defaults to 50, capped at 1000).",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "offset",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||
comment: "Pagination offset (defaults to 0).",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "chunks",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("Chunk"))),
|
||||
comment: "Matching chunks ordered by timestamp DESC.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "chunks",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("Chunk"))),
|
||||
comment: "Page of matching chunks ordered by timestamp DESC.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "total",
|
||||
ty: TypeSchema::U64,
|
||||
comment: "Total number of chunks matching the filter (pre-pagination).",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
"get_chunk" => ControllerSchema {
|
||||
namespace: NAMESPACE,
|
||||
@@ -194,6 +268,268 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
required: false,
|
||||
}],
|
||||
},
|
||||
"list_sources" => ControllerSchema {
|
||||
namespace: NAMESPACE,
|
||||
function: "list_sources",
|
||||
description:
|
||||
"Distinct (source_kind, source_id) pairs with chunk counts and most-recent timestamps. \
|
||||
`display_name` is computed from the source_id (un-slug + strip user email when known).",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "user_email_hint",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "When provided, source ids that contain this email get it stripped from \
|
||||
their display name so the UI shows the other party of an email thread.",
|
||||
required: false,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "sources",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("Source"))),
|
||||
comment: "All distinct ingest sources, newest activity first.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"search" => ControllerSchema {
|
||||
namespace: NAMESPACE,
|
||||
function: "search",
|
||||
description:
|
||||
"Keyword LIKE-search over chunk bodies. Cheap, deterministic; useful as a \
|
||||
fallback when semantic recall is unavailable.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "query",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Substring to match against chunk content.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "k",
|
||||
ty: TypeSchema::U64,
|
||||
comment: "Maximum chunks to return.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "chunks",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("Chunk"))),
|
||||
comment: "Matching chunks ordered by recency.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"recall" => ControllerSchema {
|
||||
namespace: NAMESPACE,
|
||||
function: "recall",
|
||||
description:
|
||||
"Semantic recall — runs the Phase 4 cosine rerank against the query embedding \
|
||||
and returns leaf chunks (not summaries) for UI display.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "query",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Free-text query — embedded once and reranked against summary embeddings.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "k",
|
||||
ty: TypeSchema::U64,
|
||||
comment: "Maximum chunks to return.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "chunks",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("Chunk"))),
|
||||
comment: "Recalled chunks, sorted in the same order as the rerank.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "scores",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::Json)),
|
||||
comment: "Parallel array of similarity scores (one per chunk).",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
"entity_index_for" => ControllerSchema {
|
||||
namespace: NAMESPACE,
|
||||
function: "entity_index_for",
|
||||
description: "Return all canonical entities indexed against a chunk (or summary node) id.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "chunk_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Chunk id (32 hex chars).",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "entities",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("EntityRef"))),
|
||||
comment: "Entities attached to the node, ordered by mention count DESC.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"chunks_for_entity" => ControllerSchema {
|
||||
namespace: NAMESPACE,
|
||||
function: "chunks_for_entity",
|
||||
description:
|
||||
"Return chunk IDs that reference an entity_id (inverse of entity_index_for). \
|
||||
Used by the Memory tab's People/Topics lenses to filter the chunk list.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "entity_id",
|
||||
ty: TypeSchema::String,
|
||||
comment:
|
||||
"Canonical entity id (e.g. `person:Steven Enamakel`, \
|
||||
`email:alice@example.com`).",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "chunk_ids",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
|
||||
comment: "Chunk ids that mention the entity, ordered by recency DESC.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"top_entities" => ControllerSchema {
|
||||
namespace: NAMESPACE,
|
||||
function: "top_entities",
|
||||
description:
|
||||
"Most-frequent canonical entities across the workspace, optionally narrowed by kind.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "kind",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Restrict to a single entity_kind (`person`, `email`, `topic`, …).",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "limit",
|
||||
ty: TypeSchema::U64,
|
||||
comment: "Maximum rows to return.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "entities",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("EntityRef"))),
|
||||
comment: "Top entities, ordered by mention count DESC.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"chunk_score" => ControllerSchema {
|
||||
namespace: NAMESPACE,
|
||||
function: "chunk_score",
|
||||
description:
|
||||
"Score breakdown stored in `mem_tree_score` for one chunk — used by the Memory \
|
||||
tab's 'why was this kept / dropped' panel.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "chunk_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Chunk id (32 hex chars).",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "breakdown",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Ref("ScoreBreakdown"))),
|
||||
comment: "Per-signal weight + value array, total, threshold, kept flag, llm_consulted flag.",
|
||||
required: false,
|
||||
}],
|
||||
},
|
||||
"delete_chunk" => ControllerSchema {
|
||||
namespace: NAMESPACE,
|
||||
function: "delete_chunk",
|
||||
description:
|
||||
"Purge one chunk plus its score row, entity-index rows, and on-disk .md file. \
|
||||
Idempotent — missing chunk returns deleted=false. Does NOT cascade through \
|
||||
sealed summaries; UIs warn the user.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "chunk_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Chunk id to remove.",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![
|
||||
FieldSchema {
|
||||
name: "deleted",
|
||||
ty: TypeSchema::Bool,
|
||||
comment: "True when the chunk row was found and removed.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "score_rows_removed",
|
||||
ty: TypeSchema::U64,
|
||||
comment: "Count of rows removed from `mem_tree_score`.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "entity_index_rows_removed",
|
||||
ty: TypeSchema::U64,
|
||||
comment: "Count of rows removed from `mem_tree_entity_index`.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
"get_llm" => ControllerSchema {
|
||||
namespace: NAMESPACE,
|
||||
function: "get_llm",
|
||||
description: "Read the currently configured LLM backend (`cloud` or `local`).",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "current",
|
||||
ty: TypeSchema::Enum {
|
||||
variants: vec!["cloud", "local"],
|
||||
},
|
||||
comment: "Active backend string.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"set_llm" => ControllerSchema {
|
||||
namespace: NAMESPACE,
|
||||
function: "set_llm",
|
||||
description:
|
||||
"Update the LLM backend selector and (optionally) per-role model choices \
|
||||
(`cloud_model`, `extract_model`, `summariser_model`) and persist the \
|
||||
result to config.toml in a single atomic write. Absent model fields \
|
||||
leave the corresponding config key unchanged so a caller flipping just \
|
||||
the backend doesn't have to re-supply every model id.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "backend",
|
||||
ty: TypeSchema::Enum {
|
||||
variants: vec!["cloud", "local"],
|
||||
},
|
||||
comment: "New backend value.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "cloud_model",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Cloud model id (used when backend=cloud). \
|
||||
Absent → leave existing memory_tree.cloud_llm_model unchanged.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "extract_model",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Ollama model id for the entity extractor (used when backend=local). \
|
||||
Absent → leave existing memory_tree.llm_extractor_model unchanged.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "summariser_model",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Ollama model id for the summariser (used when backend=local). \
|
||||
Absent → leave existing memory_tree.llm_summariser_model unchanged.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "current",
|
||||
ty: TypeSchema::Enum {
|
||||
variants: vec!["cloud", "local"],
|
||||
},
|
||||
comment: "The effective backend after the call.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"trigger_digest" => ControllerSchema {
|
||||
namespace: NAMESPACE,
|
||||
function: "trigger_digest",
|
||||
@@ -260,14 +596,6 @@ fn handle_ingest(params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_list_chunks(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let req = parse_value::<tree_rpc::ListChunksRequest>(Value::Object(params))?;
|
||||
to_json(tree_rpc::list_chunks_rpc(&config, req).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_get_chunk(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
@@ -284,6 +612,132 @@ fn handle_trigger_digest(params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
// ── New read RPCs (Memory-tab UI) ────────────────────────────────────────
|
||||
|
||||
fn handle_list_chunks(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let filter = parse_value::<read_rpc::ChunkFilter>(Value::Object(params))?;
|
||||
to_json(read_rpc::list_chunks_rpc(&config, filter).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_list_sources(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
struct Req {
|
||||
#[serde(default)]
|
||||
user_email_hint: Option<String>,
|
||||
}
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let req = parse_value::<Req>(Value::Object(params)).unwrap_or_default();
|
||||
to_json(read_rpc::list_sources_rpc(&config, req.user_email_hint).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_search(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Req {
|
||||
query: String,
|
||||
k: u32,
|
||||
}
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let req = parse_value::<Req>(Value::Object(params))?;
|
||||
to_json(read_rpc::search_rpc(&config, req.query, req.k).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_recall(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Req {
|
||||
query: String,
|
||||
k: u32,
|
||||
}
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let req = parse_value::<Req>(Value::Object(params))?;
|
||||
to_json(read_rpc::recall_rpc(&config, req.query, req.k).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_entity_index_for(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Req {
|
||||
chunk_id: String,
|
||||
}
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let req = parse_value::<Req>(Value::Object(params))?;
|
||||
to_json(read_rpc::entity_index_for_rpc(&config, req.chunk_id).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_chunks_for_entity(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Req {
|
||||
entity_id: String,
|
||||
}
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let req = parse_value::<Req>(Value::Object(params))?;
|
||||
to_json(read_rpc::chunks_for_entity_rpc(&config, req.entity_id).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_top_entities(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Req {
|
||||
#[serde(default)]
|
||||
kind: Option<String>,
|
||||
limit: u32,
|
||||
}
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let req = parse_value::<Req>(Value::Object(params))?;
|
||||
to_json(read_rpc::top_entities_rpc(&config, req.kind, req.limit).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_chunk_score(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Req {
|
||||
chunk_id: String,
|
||||
}
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let req = parse_value::<Req>(Value::Object(params))?;
|
||||
to_json(read_rpc::chunk_score_rpc(&config, req.chunk_id).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_delete_chunk(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Req {
|
||||
chunk_id: String,
|
||||
}
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let req = parse_value::<Req>(Value::Object(params))?;
|
||||
to_json(read_rpc::delete_chunk_rpc(&config, req.chunk_id).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_get_llm(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
to_json(read_rpc::get_llm_rpc(&config).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_set_llm(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let mut config = config_rpc::load_config_with_timeout().await?;
|
||||
let req = parse_value::<read_rpc::SetLlmRequest>(Value::Object(params))?;
|
||||
to_json(read_rpc::set_llm_rpc(&mut config, req).await?)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_value<T: DeserializeOwned>(v: Value) -> Result<T, String> {
|
||||
serde_json::from_value(v).map_err(|e| format!("invalid params: {e}"))
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
//! LLM-based entity + importance extractor.
|
||||
//!
|
||||
//! Talks to an Ollama-compatible chat-completions HTTP endpoint, asks for
|
||||
//! NER + an importance rating in one structured-JSON response, and parses
|
||||
//! the result into [`ExtractedEntities`].
|
||||
//! Builds a (system, user) prompt asking for NER + an importance rating
|
||||
//! in one structured-JSON response, hands the prompt to a
|
||||
//! [`ChatProvider`], and parses the result into [`ExtractedEntities`].
|
||||
//!
|
||||
//! ## Why this lives here
|
||||
//!
|
||||
//! Phase 2 ships a regex extractor only. Semantic NER (Person/Org/Loc/…)
|
||||
//! requires a model. We use a small local LLM (Ollama default:
|
||||
//! `qwen2.5:0.5b`) for two reasons:
|
||||
//!
|
||||
//! 1. **Reuse** — openhuman already runs Ollama for embeddings; no new
|
||||
//! deps, no native ONNX runtime to ship.
|
||||
//! 2. **Free importance signal** — extending the NER prompt with one extra
|
||||
//! JSON field (`importance`) gives us an LLM-rated quality score per
|
||||
//! chunk for the cost of one prompt instead of two LLM calls.
|
||||
//! requires a model. Originally we used a small local LLM (Ollama default:
|
||||
//! `qwen2.5:0.5b`) because openhuman already ran Ollama for embeddings.
|
||||
//! After the cloud-default refactor, the same prompt now routes through
|
||||
//! whichever backend the workspace selected — typically the OpenHuman
|
||||
//! backend's `summarization-v1`. The extractor itself is unchanged below the
|
||||
//! HTTP layer; only the transport moved.
|
||||
//!
|
||||
//! ## Span recovery
|
||||
//!
|
||||
@@ -25,38 +23,33 @@
|
||||
//!
|
||||
//! ## Soft fallback
|
||||
//!
|
||||
//! If the HTTP call fails (Ollama not running, model not pulled, timeout),
|
||||
//! we log a warn and return [`ExtractedEntities::default()`]. The
|
||||
//! If the chat call fails (provider unavailable, malformed JSON, …), we
|
||||
//! log a warn and return [`ExtractedEntities::default()`]. The
|
||||
//! [`super::CompositeExtractor`] already tolerates errors from individual
|
||||
//! extractors; ingestion never blocks on LLM availability.
|
||||
|
||||
use std::time::Duration;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::types::{EntityKind, ExtractedEntities, ExtractedEntity, ExtractedTopic};
|
||||
use super::EntityExtractor;
|
||||
use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider};
|
||||
|
||||
// ── Configuration ────────────────────────────────────────────────────────
|
||||
|
||||
/// Configuration for [`LlmEntityExtractor`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LlmExtractorConfig {
|
||||
/// Base URL of the Ollama-compatible endpoint (e.g. `http://localhost:11434`).
|
||||
/// Do NOT include `/api/chat` — the extractor appends it.
|
||||
pub endpoint: String,
|
||||
/// Model identifier as known to the endpoint (e.g. `qwen2.5:0.5b`).
|
||||
/// Model identifier the chat provider should target. For cloud this
|
||||
/// is e.g. `summarization-v1`; for local Ollama it's the Ollama tag
|
||||
/// (`qwen2.5:0.5b`). Threaded through to [`ChatPrompt`] so the
|
||||
/// provider can route to the right model.
|
||||
///
|
||||
/// Stored on the extractor for diagnostic logging only — the actual
|
||||
/// model selection happens inside the [`ChatProvider`].
|
||||
pub model: String,
|
||||
/// TCP connect timeout. Used only to fail fast when Ollama is
|
||||
/// down; no body-read timeout is applied (see `Self::new` for
|
||||
/// rationale — local Ollama on slow CPU inference can take
|
||||
/// minutes per call, and cancelling mid-stream triggered
|
||||
/// cancellation-path retry storms). The default is generous
|
||||
/// because the first request after a model swap may need to load
|
||||
/// weights.
|
||||
pub timeout: Duration,
|
||||
/// Which entity kinds the LLM is allowed to emit. Anything outside this
|
||||
/// set is mapped to [`EntityKind::Misc`] or dropped depending on
|
||||
/// `strict_kinds`.
|
||||
@@ -78,9 +71,7 @@ pub struct LlmExtractorConfig {
|
||||
impl Default for LlmExtractorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
endpoint: "http://localhost:11434".to_string(),
|
||||
model: "qwen2.5:0.5b".to_string(),
|
||||
timeout: Duration::from_secs(15),
|
||||
allowed_kinds: vec![
|
||||
EntityKind::Person,
|
||||
EntityKind::Organization,
|
||||
@@ -101,49 +92,32 @@ impl Default for LlmExtractorConfig {
|
||||
// ── Extractor ────────────────────────────────────────────────────────────
|
||||
|
||||
/// LLM-backed entity + importance extractor.
|
||||
///
|
||||
/// Holds an `Arc<dyn ChatProvider>` rather than a per-instance HTTP
|
||||
/// client. The provider abstraction lets a single workspace choose
|
||||
/// cloud vs local at runtime (see
|
||||
/// [`crate::openhuman::memory::tree::chat::build_chat_provider`]). Tests
|
||||
/// can mock the provider to assert the prompt / parse behaviour without
|
||||
/// a real Ollama or backend.
|
||||
pub struct LlmEntityExtractor {
|
||||
cfg: LlmExtractorConfig,
|
||||
http: Client,
|
||||
provider: Arc<dyn ChatProvider>,
|
||||
}
|
||||
|
||||
impl LlmEntityExtractor {
|
||||
/// Build the extractor and its inner HTTP client. Fails only when
|
||||
/// `reqwest` rejects the timeout configuration.
|
||||
pub fn new(cfg: LlmExtractorConfig) -> anyhow::Result<Self> {
|
||||
// No body-read timeout. Ollama is a local process — slow
|
||||
// responses mean the model is genuinely processing under CPU
|
||||
// load (e.g. gemma3:1b on CPU-only inference can take minutes
|
||||
// per call), not that the network broke. A body-read timeout
|
||||
// here would cancel mid-flight generation and force the
|
||||
// existing 3× retry-with-backoff to re-run the same prompt
|
||||
// against the same slow model, which empirically caused retry
|
||||
// storms during high-load ingest. `cfg.timeout` is now the
|
||||
// TCP connect timeout — short enough to fail fast when
|
||||
// Ollama is actually unreachable.
|
||||
let http = Client::builder()
|
||||
.connect_timeout(cfg.timeout)
|
||||
.build()
|
||||
.map_err(anyhow::Error::from)?;
|
||||
Ok(Self { cfg, http })
|
||||
/// Build the extractor with the supplied chat provider. Infallible —
|
||||
/// the caller is responsible for provider construction.
|
||||
pub fn new(cfg: LlmExtractorConfig, provider: Arc<dyn ChatProvider>) -> Self {
|
||||
Self { cfg, provider }
|
||||
}
|
||||
|
||||
/// Build the Ollama `/api/chat` request body.
|
||||
fn build_request(&self, text: &str) -> OllamaChatRequest {
|
||||
OllamaChatRequest {
|
||||
model: self.cfg.model.clone(),
|
||||
messages: vec![
|
||||
OllamaMessage {
|
||||
role: "system".to_string(),
|
||||
content: build_system_prompt(self.cfg.emit_topics),
|
||||
},
|
||||
OllamaMessage {
|
||||
role: "user".to_string(),
|
||||
content: format!("Text:\n{text}\n\nReturn JSON only."),
|
||||
},
|
||||
],
|
||||
format: "json".to_string(),
|
||||
stream: false,
|
||||
options: OllamaOptions { temperature: 0.0 },
|
||||
/// Build the chat prompt sent to the provider for `text`.
|
||||
fn build_prompt(&self, text: &str) -> ChatPrompt {
|
||||
ChatPrompt {
|
||||
system: build_system_prompt(self.cfg.emit_topics),
|
||||
user: format!("Text:\n{text}\n\nReturn JSON only."),
|
||||
temperature: 0.0,
|
||||
kind: "memory_tree::extract",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,64 +172,47 @@ impl EntityExtractor for LlmEntityExtractor {
|
||||
}
|
||||
|
||||
impl LlmEntityExtractor {
|
||||
/// Internal: one attempt at calling Ollama.
|
||||
/// Internal: one attempt at calling the chat provider.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Some(extracted)` — call completed (HTTP returned). Includes the
|
||||
/// "HTTP non-success" and "malformed JSON" cases, which return
|
||||
/// `Some(empty)` because retrying the same input won't help.
|
||||
/// - `None` — transport-level failure (DNS, connect refused, timeout
|
||||
/// before any HTTP response). Caller may retry.
|
||||
/// - `Some(extracted)` — call completed (provider returned content).
|
||||
/// Includes the "malformed JSON" case which returns `Some(empty)`
|
||||
/// because retrying the same input won't help.
|
||||
/// - `None` — transport-level / provider-level failure where retrying
|
||||
/// might help (e.g. unreachable backend, transient HTTP 5xx). Caller
|
||||
/// may retry.
|
||||
async fn try_extract(&self, text: &str) -> Option<ExtractedEntities> {
|
||||
let url = format!("{}/api/chat", self.cfg.endpoint.trim_end_matches('/'));
|
||||
let body = self.build_request(text);
|
||||
let prompt = self.build_prompt(text);
|
||||
log::debug!(
|
||||
"[memory_tree::extract::llm] POST {url} model={} text_chars={}",
|
||||
"[memory_tree::extract::llm] chat provider={} model={} text_chars={}",
|
||||
self.provider.name(),
|
||||
self.cfg.model,
|
||||
text.chars().count()
|
||||
);
|
||||
|
||||
let resp = match self.http.post(&url).json(&body).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::warn!("[memory_tree::extract::llm] transport failure to {url}: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
log::warn!(
|
||||
"[memory_tree::extract::llm] ollama non-success status {status}: {} — \
|
||||
returning empty extraction",
|
||||
truncate_for_log(&body, 200)
|
||||
);
|
||||
return Some(ExtractedEntities::default());
|
||||
}
|
||||
|
||||
let envelope: OllamaChatResponse = match resp.json().await {
|
||||
let raw = match self.provider.chat_for_json(&prompt).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[memory_tree::extract::llm] response body not Ollama-shaped JSON: {e} — \
|
||||
returning empty extraction"
|
||||
"[memory_tree::extract::llm] chat provider={} failed: {e:#}",
|
||||
self.provider.name()
|
||||
);
|
||||
return Some(ExtractedEntities::default());
|
||||
return None;
|
||||
}
|
||||
};
|
||||
log::debug!(
|
||||
"[memory_tree::extract::llm] response chars={}",
|
||||
envelope.message.content.len()
|
||||
"[memory_tree::extract::llm] response chars={} provider={}",
|
||||
raw.len(),
|
||||
self.provider.name()
|
||||
);
|
||||
|
||||
let parsed: LlmExtractionOutput = match serde_json::from_str(&envelope.message.content) {
|
||||
let parsed: LlmExtractionOutput = match serde_json::from_str(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[memory_tree::extract::llm] LLM returned non-JSON or wrong-shape \
|
||||
response: {e}; content was: {} — returning empty extraction",
|
||||
truncate_for_log(&envelope.message.content, 400)
|
||||
truncate_for_log(&raw, 400)
|
||||
);
|
||||
return Some(ExtractedEntities::default());
|
||||
}
|
||||
@@ -349,38 +306,6 @@ Importance guide:
|
||||
)
|
||||
}
|
||||
|
||||
// ── Wire types (Ollama API) ──────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaChatRequest {
|
||||
model: String,
|
||||
messages: Vec<OllamaMessage>,
|
||||
format: String,
|
||||
stream: bool,
|
||||
options: OllamaOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaOptions {
|
||||
temperature: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaChatResponse {
|
||||
message: OllamaResponseMessage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaResponseMessage {
|
||||
content: String,
|
||||
}
|
||||
|
||||
// ── LLM JSON output ──────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -181,22 +181,104 @@ fn into_extracted_entities_drops_extra_duplicate_when_source_only_has_one() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extract_soft_fallback_on_unreachable_endpoint() {
|
||||
// Point at an unreachable port so the transport fails. extract()
|
||||
// must NOT return Err — it must return an empty ExtractedEntities
|
||||
// with a warn log.
|
||||
let cfg = LlmExtractorConfig {
|
||||
endpoint: "http://127.0.0.1:1".to_string(),
|
||||
timeout: std::time::Duration::from_millis(100),
|
||||
..LlmExtractorConfig::default()
|
||||
};
|
||||
let ex = LlmEntityExtractor::new(cfg).unwrap();
|
||||
async fn extract_soft_fallback_on_provider_failure() {
|
||||
// Provider always errors. extract() must NOT return Err — it must
|
||||
// return an empty ExtractedEntities with a warn log after retry
|
||||
// exhaustion.
|
||||
use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider};
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct FailingProvider;
|
||||
#[async_trait]
|
||||
impl ChatProvider for FailingProvider {
|
||||
fn name(&self) -> &str {
|
||||
"test:failing"
|
||||
}
|
||||
async fn chat_for_json(&self, _p: &ChatPrompt) -> anyhow::Result<String> {
|
||||
Err(anyhow::anyhow!("simulated transport failure"))
|
||||
}
|
||||
}
|
||||
|
||||
let ex = LlmEntityExtractor::new(LlmExtractorConfig::default(), Arc::new(FailingProvider));
|
||||
let out = ex.extract("some text").await.unwrap();
|
||||
assert!(out.entities.is_empty());
|
||||
assert!(out.topics.is_empty());
|
||||
assert!(out.llm_importance.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extract_routes_through_chat_provider_and_parses_response() {
|
||||
// Mock provider returns canned NER+importance JSON. Verify the
|
||||
// extractor parses it, recovers spans by string search, and emits the
|
||||
// expected entities + importance signal.
|
||||
use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider};
|
||||
use async_trait::async_trait;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct MockProvider {
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
#[async_trait]
|
||||
impl ChatProvider for MockProvider {
|
||||
fn name(&self) -> &str {
|
||||
"test:mock"
|
||||
}
|
||||
async fn chat_for_json(&self, _p: &ChatPrompt) -> anyhow::Result<String> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(r#"{
|
||||
"entities": [
|
||||
{"kind":"person","text":"Alice"},
|
||||
{"kind":"organization","text":"Anthropic"}
|
||||
],
|
||||
"importance": 0.8,
|
||||
"importance_reason": "factual"
|
||||
}"#
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
let mock = Arc::new(MockProvider {
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let ex = LlmEntityExtractor::new(LlmExtractorConfig::default(), mock.clone());
|
||||
let out = ex.extract("Alice met Anthropic today.").await.unwrap();
|
||||
assert_eq!(mock.calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(out.entities.len(), 2);
|
||||
assert_eq!(out.entities[0].text, "Alice");
|
||||
assert_eq!(out.entities[0].kind, EntityKind::Person);
|
||||
assert_eq!(out.entities[1].text, "Anthropic");
|
||||
assert_eq!(out.llm_importance, Some(0.8));
|
||||
assert_eq!(out.llm_importance_reason.as_deref(), Some("factual"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extract_returns_empty_on_malformed_provider_response() {
|
||||
// Provider returns garbage. Caller must NOT see an Err — the parse
|
||||
// failure path returns empty entities (retrying the same input would
|
||||
// yield the same garbage, so we don't burn retries).
|
||||
use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider};
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct GarbageProvider;
|
||||
#[async_trait]
|
||||
impl ChatProvider for GarbageProvider {
|
||||
fn name(&self) -> &str {
|
||||
"test:garbage"
|
||||
}
|
||||
async fn chat_for_json(&self, _p: &ChatPrompt) -> anyhow::Result<String> {
|
||||
Ok("not json at all".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
let ex = LlmEntityExtractor::new(LlmExtractorConfig::default(), Arc::new(GarbageProvider));
|
||||
let out = ex.extract("text").await.unwrap();
|
||||
assert!(out.entities.is_empty());
|
||||
assert!(out.llm_importance.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_extracted_entities_drops_hallucinations() {
|
||||
let out = LlmExtractionOutput {
|
||||
@@ -295,21 +377,35 @@ fn into_extracted_entities_disallowed_known_kind_falls_back_to_misc() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_request_uses_configured_model() {
|
||||
fn build_prompt_carries_user_text_and_kind_tag() {
|
||||
use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider};
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct NoopProvider;
|
||||
#[async_trait]
|
||||
impl ChatProvider for NoopProvider {
|
||||
fn name(&self) -> &str {
|
||||
"test:noop"
|
||||
}
|
||||
async fn chat_for_json(&self, _p: &ChatPrompt) -> anyhow::Result<String> {
|
||||
Ok("{}".into())
|
||||
}
|
||||
}
|
||||
|
||||
let cfg = LlmExtractorConfig {
|
||||
model: "test-model".into(),
|
||||
..LlmExtractorConfig::default()
|
||||
};
|
||||
let ex = LlmEntityExtractor::new(cfg).unwrap();
|
||||
let req = ex.build_request("hello");
|
||||
assert_eq!(req.model, "test-model");
|
||||
assert_eq!(req.format, "json");
|
||||
assert!(!req.stream);
|
||||
assert_eq!(req.options.temperature, 0.0);
|
||||
assert_eq!(req.messages.len(), 2);
|
||||
assert_eq!(req.messages[0].role, "system");
|
||||
assert_eq!(req.messages[1].role, "user");
|
||||
assert!(req.messages[1].content.contains("hello"));
|
||||
let ex = LlmEntityExtractor::new(cfg, Arc::new(NoopProvider));
|
||||
let prompt = ex.build_prompt("hello");
|
||||
assert!(prompt.user.contains("hello"));
|
||||
assert!(prompt.user.contains("Return JSON only"));
|
||||
assert_eq!(prompt.temperature, 0.0);
|
||||
assert_eq!(prompt.kind, "memory_tree::extract");
|
||||
// System prompt should describe the JSON schema.
|
||||
assert!(prompt.system.contains("\"entities\""));
|
||||
assert!(prompt.system.contains("\"importance\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,8 +12,8 @@ pub mod types;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::openhuman::config::Config;
|
||||
use crate::openhuman::memory::tree::util::redact::redact_endpoint;
|
||||
use crate::openhuman::config::{Config, LlmBackend, DEFAULT_CLOUD_LLM_MODEL};
|
||||
use crate::openhuman::memory::tree::chat::{build_chat_provider, ChatConsumer};
|
||||
|
||||
pub use extractor::{CompositeExtractor, EntityExtractor, RegexEntityExtractor};
|
||||
pub use llm::{LlmEntityExtractor, LlmExtractorConfig};
|
||||
@@ -23,8 +23,10 @@ pub use types::{EntityKind, ExtractedEntities, ExtractedEntity, ExtractedTopic};
|
||||
///
|
||||
/// Composition:
|
||||
/// - regex extractor — always on, mechanical, near-zero cost
|
||||
/// - LLM extractor with `emit_topics: true` — added when
|
||||
/// `memory_tree.llm_extractor_endpoint` and `..._model` are both set
|
||||
/// - LLM extractor with `emit_topics: true` — added when the LLM backend
|
||||
/// is reachable. For `llm_backend = "cloud"` (default) that's always. For
|
||||
/// `llm_backend = "local"` we still require `llm_extractor_endpoint` +
|
||||
/// `_model` to be set (otherwise the legacy regex-only path stays).
|
||||
///
|
||||
/// Differs from [`super::ScoringConfig::from_config`] (the chunk-admission
|
||||
/// builder) in two ways: returns *just* an extractor (no thresholds /
|
||||
@@ -32,61 +34,79 @@ pub use types::{EntityKind, ExtractedEntities, ExtractedEntity, ExtractedTopic};
|
||||
/// `emit_topics` on so summaries surface thematic labels alongside
|
||||
/// entities. Leaf-side scoring is unchanged.
|
||||
pub fn build_summary_extractor(config: &Config) -> Arc<dyn EntityExtractor> {
|
||||
let endpoint = config
|
||||
.memory_tree
|
||||
.llm_extractor_endpoint
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let model = config
|
||||
.memory_tree
|
||||
.llm_extractor_model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let (Some(endpoint), Some(model)) = (endpoint, model) else {
|
||||
let model = resolve_extractor_model(config);
|
||||
let Some(model) = model else {
|
||||
log::debug!(
|
||||
"[memory_tree::extract] summary extractor: LLM not configured — using regex-only"
|
||||
"[memory_tree::extract] summary extractor: LLM model not resolvable for \
|
||||
llm_backend={} — using regex-only",
|
||||
config.memory_tree.llm_backend.as_str()
|
||||
);
|
||||
return Arc::new(CompositeExtractor::regex_only());
|
||||
};
|
||||
|
||||
let timeout_ms = config
|
||||
.memory_tree
|
||||
.llm_extractor_timeout_ms
|
||||
.unwrap_or(15_000);
|
||||
|
||||
let cfg = LlmExtractorConfig {
|
||||
endpoint: endpoint.to_string(),
|
||||
model: model.to_string(),
|
||||
timeout: std::time::Duration::from_millis(timeout_ms),
|
||||
model: model.clone(),
|
||||
emit_topics: true,
|
||||
..LlmExtractorConfig::default()
|
||||
};
|
||||
|
||||
match LlmEntityExtractor::new(cfg) {
|
||||
Ok(llm) => {
|
||||
// Drop to debug (diagnostic, not always-on) and redact the endpoint
|
||||
// so embedded credentials (e.g. api keys in URL) don't leak.
|
||||
log::debug!(
|
||||
"[memory_tree::extract] summary extractor: regex + LLM endpoint={} model={} \
|
||||
timeout_ms={} emit_topics=true",
|
||||
redact_endpoint(endpoint),
|
||||
model,
|
||||
timeout_ms
|
||||
);
|
||||
Arc::new(CompositeExtractor::new(vec![
|
||||
Box::new(RegexEntityExtractor),
|
||||
Box::new(llm),
|
||||
]))
|
||||
}
|
||||
let provider = match build_chat_provider(config, ChatConsumer::Extract) {
|
||||
Ok(p) => p,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"[memory_tree::extract] summary extractor: LlmEntityExtractor construction \
|
||||
failed: {err:#} — falling back to regex-only"
|
||||
"[memory_tree::extract] summary extractor: build_chat_provider failed: \
|
||||
{err:#} — falling back to regex-only"
|
||||
);
|
||||
Arc::new(CompositeExtractor::regex_only())
|
||||
return Arc::new(CompositeExtractor::regex_only());
|
||||
}
|
||||
};
|
||||
|
||||
log::debug!(
|
||||
"[memory_tree::extract] summary extractor: regex + LLM provider={} model={} \
|
||||
emit_topics=true",
|
||||
provider.name(),
|
||||
model
|
||||
);
|
||||
Arc::new(CompositeExtractor::new(vec![
|
||||
Box::new(RegexEntityExtractor),
|
||||
Box::new(LlmEntityExtractor::new(cfg, provider)),
|
||||
]))
|
||||
}
|
||||
|
||||
/// Resolve the model identifier the extractor's [`ChatProvider`] should
|
||||
/// target, returning `None` when the configured backend can't be served:
|
||||
///
|
||||
/// - `Cloud`: always returns the configured `cloud_llm_model` or its
|
||||
/// `summarization-v1` default.
|
||||
/// - `Local`: returns `Some(model)` only when both
|
||||
/// `llm_extractor_endpoint` AND `llm_extractor_model` are set —
|
||||
/// otherwise the legacy regex-only path engages.
|
||||
pub(super) fn resolve_extractor_model(config: &Config) -> Option<String> {
|
||||
match config.memory_tree.llm_backend {
|
||||
LlmBackend::Cloud => Some(
|
||||
config
|
||||
.memory_tree
|
||||
.cloud_llm_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_CLOUD_LLM_MODEL.to_string()),
|
||||
),
|
||||
LlmBackend::Local => {
|
||||
let endpoint = config
|
||||
.memory_tree
|
||||
.llm_extractor_endpoint
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let model = config
|
||||
.memory_tree
|
||||
.llm_extractor_model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
match (endpoint, model) {
|
||||
(Some(_), Some(m)) => Some(m.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,57 +105,51 @@ impl ScoringConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`ScoringConfig`] from the workspace [`Config`]. When
|
||||
/// `memory_tree.llm_extractor_endpoint` and `llm_extractor_model`
|
||||
/// are both set, wires [`extract::LlmEntityExtractor`] as the
|
||||
/// second-pass extractor. Otherwise falls back to
|
||||
/// [`Self::default_regex_only`]. Construction errors in the LLM
|
||||
/// extractor (rare — only client-builder failures) also fall back
|
||||
/// to regex-only with a warn log; scoring never blocks on LLM
|
||||
/// availability.
|
||||
/// Build a [`ScoringConfig`] from the workspace [`Config`]. The
|
||||
/// resolution rules match `build_summary_extractor`:
|
||||
///
|
||||
/// - `llm_backend = "cloud"` (default): always wires the LLM extractor
|
||||
/// against the cloud provider, using the configured
|
||||
/// `cloud_llm_model` (defaulting to `summarization-v1`).
|
||||
/// - `llm_backend = "local"`: wires the LLM extractor only when both
|
||||
/// `llm_extractor_endpoint` and `llm_extractor_model` are set;
|
||||
/// otherwise falls back to [`Self::default_regex_only`].
|
||||
///
|
||||
/// Construction errors in the chat provider (rare — only client-builder
|
||||
/// failures) fall back to regex-only with a warn log; scoring never
|
||||
/// blocks on LLM availability.
|
||||
pub fn from_config(config: &crate::openhuman::config::Config) -> Self {
|
||||
let endpoint = config
|
||||
.memory_tree
|
||||
.llm_extractor_endpoint
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let model = config
|
||||
.memory_tree
|
||||
.llm_extractor_model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
use crate::openhuman::memory::tree::chat::{build_chat_provider, ChatConsumer};
|
||||
|
||||
let (Some(endpoint), Some(model)) = (endpoint, model) else {
|
||||
log::debug!("[memory_tree::score] llm_extractor not configured — using regex-only");
|
||||
return Self::default_regex_only();
|
||||
let model = match extract::resolve_extractor_model(config) {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
log::debug!(
|
||||
"[memory_tree::score] llm_extractor not resolvable for llm_backend={} \
|
||||
— using regex-only",
|
||||
config.memory_tree.llm_backend.as_str()
|
||||
);
|
||||
return Self::default_regex_only();
|
||||
}
|
||||
};
|
||||
|
||||
let timeout_ms = config
|
||||
.memory_tree
|
||||
.llm_extractor_timeout_ms
|
||||
.unwrap_or(15_000);
|
||||
|
||||
let cfg = extract::LlmExtractorConfig {
|
||||
endpoint: endpoint.to_string(),
|
||||
model: model.to_string(),
|
||||
timeout: std::time::Duration::from_millis(timeout_ms),
|
||||
model: model.clone(),
|
||||
..extract::LlmExtractorConfig::default()
|
||||
};
|
||||
match extract::LlmEntityExtractor::new(cfg) {
|
||||
Ok(llm) => {
|
||||
|
||||
match build_chat_provider(config, ChatConsumer::Extract) {
|
||||
Ok(provider) => {
|
||||
log::info!(
|
||||
"[memory_tree::score] using LlmEntityExtractor endpoint={} model={} timeout_ms={}",
|
||||
endpoint,
|
||||
model,
|
||||
timeout_ms
|
||||
"[memory_tree::score] using LlmEntityExtractor provider={} model={}",
|
||||
provider.name(),
|
||||
model
|
||||
);
|
||||
Self::with_llm_extractor(Arc::new(llm))
|
||||
Self::with_llm_extractor(Arc::new(extract::LlmEntityExtractor::new(cfg, provider)))
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"[memory_tree::score] LlmEntityExtractor construction failed: {err:#} — \
|
||||
"[memory_tree::score] build_chat_provider failed: {err:#} — \
|
||||
falling back to regex-only"
|
||||
);
|
||||
Self::default_regex_only()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! LLM-backed summariser — Ollama `/api/chat` peer of
|
||||
//! LLM-backed summariser — peer of
|
||||
//! [`crate::openhuman::memory::tree::score::extract::llm::LlmEntityExtractor`].
|
||||
//!
|
||||
//! ## Responsibility
|
||||
@@ -25,19 +25,25 @@
|
||||
//! ## Prompt shape
|
||||
//!
|
||||
//! The system prompt commits the model to returning JSON with the shape
|
||||
//! `{ summary }`. We use Ollama's `format: "json"` +
|
||||
//! `temperature: 0.0` to maximise determinism — same knobs the entity
|
||||
//! extractor already uses with success.
|
||||
|
||||
use std::time::Duration;
|
||||
//! `{ summary }`. We pass `temperature: 0.0` for maximum determinism —
|
||||
//! same knob the entity extractor already uses with success.
|
||||
//!
|
||||
//! ## Backend transparency
|
||||
//!
|
||||
//! Originally this summariser owned its own `reqwest::Client` and talked
|
||||
//! directly to Ollama. After the cloud-default refactor, it accepts an
|
||||
//! `Arc<dyn ChatProvider>` instead — letting a single workspace pick
|
||||
//! cloud (default) or local (opt-in) at runtime without changing this
|
||||
//! file's prompt or parse logic.
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::inert::InertSummariser;
|
||||
use super::{Summariser, SummaryContext, SummaryInput, SummaryOutput};
|
||||
use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider};
|
||||
use crate::openhuman::memory::tree::types::approx_token_count;
|
||||
|
||||
/// Hard cap on summariser output length (in approximate tokens).
|
||||
@@ -59,10 +65,13 @@ use crate::openhuman::memory::tree::types::approx_token_count;
|
||||
/// this regardless of what the model produces.
|
||||
const MAX_SUMMARY_OUTPUT_TOKENS: u32 = 3_500;
|
||||
|
||||
/// Context window we ask Ollama for. Must match the value below in
|
||||
/// [`OllamaOptions::num_ctx`] so the per-input clamp computed in
|
||||
/// [`LlmSummariser::summarise`] sizes inputs against the same window
|
||||
/// the model actually sees.
|
||||
/// Context window assumed for the model. Used as the divisor in the
|
||||
/// per-input clamp so the joined prompt body stays under this even at
|
||||
/// upper-level seals where SUMMARY_FANOUT children each near
|
||||
/// MAX_SUMMARY_OUTPUT_TOKENS would otherwise overflow. Conservative —
|
||||
/// real cloud models have larger contexts; smaller local models may
|
||||
/// truncate, but the post-generation `clamp_to_budget` ensures output
|
||||
/// fits the embedder regardless.
|
||||
const NUM_CTX_TOKENS: u32 = 16_384;
|
||||
|
||||
/// Tokens reserved for the system prompt, JSON wrapper, and tokenizer
|
||||
@@ -71,34 +80,20 @@ const NUM_CTX_TOKENS: u32 = 16_384;
|
||||
/// prompt body + output budget never exceeds `num_ctx`.
|
||||
const OVERHEAD_RESERVE_TOKENS: u32 = 512;
|
||||
|
||||
/// Configuration for [`LlmSummariser`]. Endpoint + model defaults match
|
||||
/// [`crate::openhuman::memory::tree::score::extract::llm::LlmExtractorConfig`]
|
||||
/// so a workspace configured for one LLM path also satisfies the other
|
||||
/// by default.
|
||||
/// Configuration for [`LlmSummariser`]. Threaded down to the chat
|
||||
/// provider for diagnostic logging — model selection at the wire level
|
||||
/// happens inside the [`ChatProvider`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LlmSummariserConfig {
|
||||
/// Base URL of the Ollama-compatible endpoint (e.g.
|
||||
/// `http://localhost:11434`). Do NOT include `/api/chat` — the
|
||||
/// summariser appends it.
|
||||
pub endpoint: String,
|
||||
/// Model identifier (e.g. `qwen2.5:0.5b` or `llama3.1:8b`).
|
||||
/// Model identifier (e.g. `summarization-v1` for cloud, `qwen2.5:0.5b`
|
||||
/// or `llama3.1:8b` for local Ollama). Diagnostic / log only.
|
||||
pub model: String,
|
||||
/// Per-request timeout. Generous default because first-call weight
|
||||
/// loading can be slow.
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for LlmSummariserConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
endpoint: "http://localhost:11434".to_string(),
|
||||
model: "qwen2.5:0.5b".to_string(),
|
||||
// 120s — generous enough for small/medium models (1B-8B
|
||||
// params) summarising the seal cascade's full token
|
||||
// budget on first invocation, when Ollama may also be
|
||||
// loading model weights into VRAM. Large models on CPU
|
||||
// can still time out; bump via config for those.
|
||||
timeout: Duration::from_secs(120),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,59 +102,28 @@ impl Default for LlmSummariserConfig {
|
||||
/// failure so seal cascades never fail.
|
||||
pub struct LlmSummariser {
|
||||
cfg: LlmSummariserConfig,
|
||||
http: Client,
|
||||
provider: Arc<dyn ChatProvider>,
|
||||
fallback: InertSummariser,
|
||||
}
|
||||
|
||||
impl LlmSummariser {
|
||||
/// Build a summariser around `cfg`. Returns an error only if the HTTP
|
||||
/// client fails to construct (timeout / TLS init).
|
||||
pub fn new(cfg: LlmSummariserConfig) -> Result<Self> {
|
||||
// No body-read timeout. Ollama is local — slow responses mean
|
||||
// the model is genuinely processing, not that the network
|
||||
// broke. A body-read timeout here would cancel mid-stream and
|
||||
// force retries against the same slow model. `cfg.timeout` is
|
||||
// repurposed as the TCP connect timeout (fast-fail when
|
||||
// Ollama is actually down).
|
||||
let http = Client::builder()
|
||||
.connect_timeout(cfg.timeout)
|
||||
.build()
|
||||
.map_err(anyhow::Error::from)?;
|
||||
Ok(Self {
|
||||
/// Build a summariser with the supplied chat provider. Infallible —
|
||||
/// the caller is responsible for provider construction.
|
||||
pub fn new(cfg: LlmSummariserConfig, provider: Arc<dyn ChatProvider>) -> Self {
|
||||
Self {
|
||||
cfg,
|
||||
http,
|
||||
provider,
|
||||
fallback: InertSummariser::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_request(&self, prompt_body: &str, budget: u32) -> OllamaChatRequest {
|
||||
OllamaChatRequest {
|
||||
model: self.cfg.model.clone(),
|
||||
messages: vec![
|
||||
OllamaMessage {
|
||||
role: "system".to_string(),
|
||||
content: system_prompt(budget),
|
||||
},
|
||||
OllamaMessage {
|
||||
role: "user".to_string(),
|
||||
content: prompt_body.to_string(),
|
||||
},
|
||||
],
|
||||
format: "json".to_string(),
|
||||
stream: false,
|
||||
options: OllamaOptions {
|
||||
temperature: 0.0,
|
||||
// 16k context window. Sized so that the per-input
|
||||
// clamp in `summarise` (NUM_CTX - output_budget -
|
||||
// overhead, divided by `inputs.len()`) keeps the
|
||||
// joined prompt body inside this window even at
|
||||
// upper-level seals where SUMMARY_FANOUT children
|
||||
// each near MAX_SUMMARY_OUTPUT_TOKENS would otherwise
|
||||
// overflow. Keeping `num_ctx` modest also keeps the
|
||||
// kv-cache small enough to fit on consumer GPUs
|
||||
// alongside the model weights.
|
||||
num_ctx: NUM_CTX_TOKENS,
|
||||
},
|
||||
/// Build the chat prompt sent to the provider for a given seal.
|
||||
fn build_prompt(&self, prompt_body: &str, budget: u32) -> ChatPrompt {
|
||||
ChatPrompt {
|
||||
system: system_prompt(budget),
|
||||
user: prompt_body.to_string(),
|
||||
temperature: 0.0,
|
||||
kind: "memory_tree::summarise",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,12 +176,12 @@ impl Summariser for LlmSummariser {
|
||||
});
|
||||
}
|
||||
|
||||
let url = format!("{}/api/chat", self.cfg.endpoint.trim_end_matches('/'));
|
||||
let req = self.build_request(&body, effective_budget);
|
||||
let prompt = self.build_prompt(&body, effective_budget);
|
||||
|
||||
log::debug!(
|
||||
"[tree_source::summariser::llm] POST {url} model={} tree_id={} level={} \
|
||||
"[tree_source::summariser::llm] chat provider={} model={} tree_id={} level={} \
|
||||
inputs={} budget={}",
|
||||
self.provider.name(),
|
||||
self.cfg.model,
|
||||
ctx.tree_id,
|
||||
ctx.target_level,
|
||||
@@ -225,38 +189,13 @@ impl Summariser for LlmSummariser {
|
||||
ctx.token_budget
|
||||
);
|
||||
|
||||
let resp = match self.http.post(&url).json(&req).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[tree_source::summariser::llm] transport failure to {url}: {e} — \
|
||||
falling back to inert summariser for tree_id={} level={}",
|
||||
ctx.tree_id,
|
||||
ctx.target_level
|
||||
);
|
||||
return self.fallback.summarise(inputs, ctx).await;
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
log::warn!(
|
||||
"[tree_source::summariser::llm] ollama non-success status {status} \
|
||||
tree_id={} level={}: {} — falling back to inert",
|
||||
ctx.tree_id,
|
||||
ctx.target_level,
|
||||
truncate_for_log(&body, 200)
|
||||
);
|
||||
return self.fallback.summarise(inputs, ctx).await;
|
||||
}
|
||||
|
||||
let envelope: OllamaChatResponse = match resp.json().await {
|
||||
let raw = match self.provider.chat_for_json(&prompt).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[tree_source::summariser::llm] response not Ollama-shaped JSON: {e} — \
|
||||
falling back to inert for tree_id={} level={}",
|
||||
"[tree_source::summariser::llm] chat provider={} failed: {e:#} — \
|
||||
falling back to inert summariser for tree_id={} level={}",
|
||||
self.provider.name(),
|
||||
ctx.tree_id,
|
||||
ctx.target_level
|
||||
);
|
||||
@@ -264,13 +203,13 @@ impl Summariser for LlmSummariser {
|
||||
}
|
||||
};
|
||||
|
||||
let parsed: LlmSummaryOutput = match serde_json::from_str(&envelope.message.content) {
|
||||
let parsed: LlmSummaryOutput = match serde_json::from_str(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[tree_source::summariser::llm] model returned non-JSON or wrong-shape \
|
||||
body: {e}; content was: {} — falling back to inert",
|
||||
truncate_for_log(&envelope.message.content, 400)
|
||||
truncate_for_log(&raw, 400)
|
||||
);
|
||||
return self.fallback.summarise(inputs, ctx).await;
|
||||
}
|
||||
@@ -356,44 +295,6 @@ fn truncate_for_log(s: &str, max_chars: usize) -> String {
|
||||
format!("{truncated}…")
|
||||
}
|
||||
|
||||
// ── Wire types (Ollama API) ──────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaChatRequest {
|
||||
model: String,
|
||||
messages: Vec<OllamaMessage>,
|
||||
format: String,
|
||||
stream: bool,
|
||||
options: OllamaOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaOptions {
|
||||
temperature: f32,
|
||||
/// Override Ollama's default 32k context window — large local
|
||||
/// models inflate kv-cache to >15 GiB at 32k which won't fit on
|
||||
/// consumer GPUs. 16k is plenty for one bucket summary (input is
|
||||
/// bounded by the source-tree's ~10k-token bucket budget) while
|
||||
/// keeping kv-cache reasonable.
|
||||
num_ctx: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaChatResponse {
|
||||
message: OllamaResponseMessage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaResponseMessage {
|
||||
content: String,
|
||||
}
|
||||
|
||||
// ── LLM JSON output ──────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -524,59 +425,115 @@ mod tests {
|
||||
assert!(out.ends_with('…'));
|
||||
}
|
||||
|
||||
/// Mock chat provider that lets us assert prompt shape and stub responses
|
||||
/// in summariser unit tests without hitting the network.
|
||||
struct StubProvider {
|
||||
response: anyhow::Result<String>,
|
||||
calls: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl StubProvider {
|
||||
fn ok(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
response: Ok(text.into()),
|
||||
calls: std::sync::atomic::AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
fn err(msg: &'static str) -> Self {
|
||||
Self {
|
||||
response: Err(anyhow::anyhow!(msg)),
|
||||
calls: std::sync::atomic::AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatProvider for StubProvider {
|
||||
fn name(&self) -> &str {
|
||||
"test:stub"
|
||||
}
|
||||
async fn chat_for_json(&self, _p: &ChatPrompt) -> anyhow::Result<String> {
|
||||
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
self.response
|
||||
.as_ref()
|
||||
.map(|s| s.clone())
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_inputs_yield_empty_summary_without_network_call() {
|
||||
async fn empty_inputs_yield_empty_summary_without_provider_call() {
|
||||
// All inputs are blank → prompt body is empty → the summariser
|
||||
// short-circuits and returns an empty output. Importantly, this
|
||||
// path must work even if Ollama is unreachable.
|
||||
let cfg = LlmSummariserConfig {
|
||||
endpoint: "http://127.0.0.1:1".to_string(),
|
||||
timeout: Duration::from_millis(50),
|
||||
..LlmSummariserConfig::default()
|
||||
};
|
||||
let s = LlmSummariser::new(cfg).unwrap();
|
||||
// short-circuits and returns an empty output without invoking the
|
||||
// chat provider.
|
||||
let provider = std::sync::Arc::new(StubProvider::ok("never returned"));
|
||||
let s = LlmSummariser::new(LlmSummariserConfig::default(), provider.clone());
|
||||
let inputs = vec![sample_input("a", " "), sample_input("b", "")];
|
||||
let out = s.summarise(&inputs, &test_ctx()).await.unwrap();
|
||||
assert!(out.content.is_empty());
|
||||
assert_eq!(out.token_count, 0);
|
||||
assert_eq!(
|
||||
provider.calls.load(std::sync::atomic::Ordering::SeqCst),
|
||||
0,
|
||||
"blank inputs must not call the chat provider"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_failure_falls_back_to_inert() {
|
||||
// Unreachable endpoint → transport error → must NOT return Err;
|
||||
// must fall through to InertSummariser's concatenate+truncate
|
||||
// behaviour (content present, entities empty).
|
||||
let cfg = LlmSummariserConfig {
|
||||
endpoint: "http://127.0.0.1:1".to_string(),
|
||||
timeout: Duration::from_millis(100),
|
||||
..LlmSummariserConfig::default()
|
||||
};
|
||||
let s = LlmSummariser::new(cfg).unwrap();
|
||||
async fn provider_failure_falls_back_to_inert() {
|
||||
// Provider errors → must NOT return Err; must fall through to
|
||||
// InertSummariser's concatenate+truncate behaviour (content
|
||||
// present, entities empty).
|
||||
let provider = std::sync::Arc::new(StubProvider::err("simulated"));
|
||||
let s = LlmSummariser::new(LlmSummariserConfig::default(), provider);
|
||||
let inputs = vec![sample_input("a", "alice decided to ship friday")];
|
||||
let out = s.summarise(&inputs, &test_ctx()).await.unwrap();
|
||||
assert!(out.content.contains("alice decided to ship"));
|
||||
// Inert branch emits empty entities/topics — this is how callers
|
||||
// can distinguish fallback from a real LLM success with no entities.
|
||||
assert!(out.entities.is_empty());
|
||||
assert!(out.topics.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_response_falls_back_to_inert() {
|
||||
// Provider returns garbage → parse fails → fallback to inert.
|
||||
let provider = std::sync::Arc::new(StubProvider::ok("not json"));
|
||||
let s = LlmSummariser::new(LlmSummariserConfig::default(), provider);
|
||||
let inputs = vec![sample_input("a", "alice ships friday")];
|
||||
let out = s.summarise(&inputs, &test_ctx()).await.unwrap();
|
||||
// Inert fallback content includes the original input.
|
||||
assert!(out.content.contains("alice"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_summary_response_is_used_and_clamped() {
|
||||
// Provider returns valid JSON; summariser parses it and clamps to
|
||||
// the budget.
|
||||
let provider = std::sync::Arc::new(StubProvider::ok(
|
||||
r#"{"summary":"alice decided to ship friday"}"#,
|
||||
));
|
||||
let s = LlmSummariser::new(LlmSummariserConfig::default(), provider.clone());
|
||||
let inputs = vec![sample_input("a", "alice ships friday")];
|
||||
let out = s.summarise(&inputs, &test_ctx()).await.unwrap();
|
||||
assert!(out.content.contains("alice decided to ship"));
|
||||
assert!(out.token_count > 0);
|
||||
assert_eq!(provider.calls.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_request_uses_configured_model_and_json_format() {
|
||||
let cfg = LlmSummariserConfig {
|
||||
model: "llama3.1:8b".into(),
|
||||
..LlmSummariserConfig::default()
|
||||
};
|
||||
let s = LlmSummariser::new(cfg).unwrap();
|
||||
let req = s.build_request("body", 2048);
|
||||
assert_eq!(req.model, "llama3.1:8b");
|
||||
assert_eq!(req.format, "json");
|
||||
assert!(!req.stream);
|
||||
assert_eq!(req.options.temperature, 0.0);
|
||||
assert_eq!(req.messages[0].role, "system");
|
||||
assert!(req.messages[0].content.contains("\"summary\""));
|
||||
assert_eq!(req.messages[1].role, "user");
|
||||
assert_eq!(req.messages[1].content, "body");
|
||||
fn build_prompt_carries_body_and_kind_tag() {
|
||||
let provider = std::sync::Arc::new(StubProvider::ok("{}"));
|
||||
let s = LlmSummariser::new(
|
||||
LlmSummariserConfig {
|
||||
model: "llama3.1:8b".into(),
|
||||
},
|
||||
provider,
|
||||
);
|
||||
let prompt = s.build_prompt("body", 2048);
|
||||
assert!(prompt.system.contains("\"summary\""));
|
||||
assert!(!prompt.system.contains("\"entities\""));
|
||||
assert_eq!(prompt.user, "body");
|
||||
assert_eq!(prompt.temperature, 0.0);
|
||||
assert_eq!(prompt.kind, "memory_tree::summarise");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -66,64 +66,83 @@ pub trait Summariser: Send + Sync {
|
||||
}
|
||||
|
||||
/// Build the summariser implementation driven by the workspace's
|
||||
/// [`Config`]. When `memory_tree.llm_summariser_endpoint` and
|
||||
/// `llm_summariser_model` are both set, return the Ollama-backed
|
||||
/// [`llm::LlmSummariser`] (which itself soft-falls-back to inert on
|
||||
/// transport failure). Otherwise return [`inert::InertSummariser`].
|
||||
/// [`Config`]. The cloud-default refactor changed the resolution rules:
|
||||
///
|
||||
/// - `llm_backend = "cloud"` (default): always returns the LLM summariser
|
||||
/// routed through the OpenHuman backend's `cloud_llm_model`
|
||||
/// (defaulting to `summarization-v1`).
|
||||
/// - `llm_backend = "local"`: returns the LLM summariser only when both
|
||||
/// `llm_summariser_endpoint` AND `llm_summariser_model` are set;
|
||||
/// otherwise returns the [`inert::InertSummariser`] fallback.
|
||||
///
|
||||
/// In all cases the LLM summariser itself soft-falls-back to inert per
|
||||
/// seal on transport failure, so seal cascades never abort.
|
||||
///
|
||||
/// Returned as `Arc<dyn Summariser>` so the ingest pipeline can pass it
|
||||
/// by reference to `append_leaf` and `route_leaf_to_topic_trees`
|
||||
/// without threading a generic type parameter through every caller.
|
||||
pub fn build_summariser(config: &Config) -> Arc<dyn Summariser> {
|
||||
let endpoint = config
|
||||
.memory_tree
|
||||
.llm_summariser_endpoint
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let model = config
|
||||
.memory_tree
|
||||
.llm_summariser_model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
use crate::openhuman::config::{LlmBackend, DEFAULT_CLOUD_LLM_MODEL};
|
||||
use crate::openhuman::memory::tree::chat::{build_chat_provider, ChatConsumer};
|
||||
|
||||
let (Some(endpoint), Some(model)) = (endpoint, model) else {
|
||||
// Resolve the model identifier to log alongside the provider name.
|
||||
// Returns None (→ inert fallback) only when llm_backend=local and the legacy
|
||||
// llm_summariser_endpoint/_model fields are not both set.
|
||||
let model: Option<String> = match config.memory_tree.llm_backend {
|
||||
LlmBackend::Cloud => Some(
|
||||
config
|
||||
.memory_tree
|
||||
.cloud_llm_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_CLOUD_LLM_MODEL.to_string()),
|
||||
),
|
||||
LlmBackend::Local => {
|
||||
let endpoint = config
|
||||
.memory_tree
|
||||
.llm_summariser_endpoint
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let m = config
|
||||
.memory_tree
|
||||
.llm_summariser_model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
match (endpoint, m) {
|
||||
(Some(_), Some(m)) => Some(m.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let Some(model) = model else {
|
||||
log::debug!(
|
||||
"[tree_source::summariser] llm_summariser not configured — using InertSummariser"
|
||||
"[tree_source::summariser] llm_summariser not configured for llm_backend={} \
|
||||
— using InertSummariser",
|
||||
config.memory_tree.llm_backend.as_str()
|
||||
);
|
||||
return Arc::new(inert::InertSummariser::new());
|
||||
};
|
||||
|
||||
// 120s default — matches `LlmSummariserConfig::default()`. Lets a
|
||||
// small/medium local model finish the seal-budget summary on a
|
||||
// cold-loaded weight cache without spurious timeouts.
|
||||
let timeout_ms = config
|
||||
.memory_tree
|
||||
.llm_summariser_timeout_ms
|
||||
.unwrap_or(120_000);
|
||||
|
||||
let cfg = llm::LlmSummariserConfig {
|
||||
endpoint: endpoint.to_string(),
|
||||
model: model.to_string(),
|
||||
timeout: std::time::Duration::from_millis(timeout_ms),
|
||||
};
|
||||
match llm::LlmSummariser::new(cfg) {
|
||||
Ok(s) => {
|
||||
log::info!(
|
||||
"[tree_source::summariser] using LlmSummariser endpoint={} model={} timeout_ms={}",
|
||||
endpoint,
|
||||
model,
|
||||
timeout_ms
|
||||
);
|
||||
Arc::new(s)
|
||||
}
|
||||
let provider = match build_chat_provider(config, ChatConsumer::Summarise) {
|
||||
Ok(p) => p,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"[tree_source::summariser] LlmSummariser construction failed: {err:#} — \
|
||||
"[tree_source::summariser] build_chat_provider failed: {err:#} — \
|
||||
falling back to InertSummariser"
|
||||
);
|
||||
Arc::new(inert::InertSummariser::new())
|
||||
return Arc::new(inert::InertSummariser::new());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"[tree_source::summariser] using LlmSummariser provider={} model={}",
|
||||
provider.name(),
|
||||
model
|
||||
);
|
||||
Arc::new(llm::LlmSummariser::new(
|
||||
llm::LlmSummariserConfig { model },
|
||||
provider,
|
||||
))
|
||||
}
|
||||
|
||||
+30
-6
@@ -1167,13 +1167,24 @@ async fn json_rpc_memory_tree_end_to_end() {
|
||||
write_min_config(&openhuman_home, &mock_origin);
|
||||
|
||||
let controllers = all_memory_tree_registered_controllers();
|
||||
// Sampled methods this test exercises end-to-end. Don't pin
|
||||
// controllers.len() — the registry has grown organically
|
||||
// (list_sources, search, recall, entity_index_for, top_entities,
|
||||
// chunk_score, delete_chunk, get_llm, set_llm, chunks_for_entity, …)
|
||||
// and adding a new RPC shouldn't break this smoke test. We just
|
||||
// assert the four sampled methods exercised below are registered.
|
||||
let expected_methods = vec![
|
||||
"openhuman.memory_tree_ingest".to_string(),
|
||||
"openhuman.memory_tree_list_chunks".to_string(),
|
||||
"openhuman.memory_tree_get_chunk".to_string(),
|
||||
"openhuman.memory_tree_trigger_digest".to_string(),
|
||||
];
|
||||
assert_eq!(controllers.len(), expected_methods.len());
|
||||
assert!(
|
||||
controllers.len() >= expected_methods.len(),
|
||||
"expected at least {} memory_tree controllers, found {}",
|
||||
expected_methods.len(),
|
||||
controllers.len()
|
||||
);
|
||||
for method in &expected_methods {
|
||||
assert!(
|
||||
controllers
|
||||
@@ -1226,9 +1237,8 @@ async fn json_rpc_memory_tree_end_to_end() {
|
||||
201,
|
||||
&expected_methods[1],
|
||||
json!({
|
||||
"source_kind": "document",
|
||||
"source_id": "notion:launch-plan",
|
||||
"owner": "alice@example.com",
|
||||
"source_kinds": ["document"],
|
||||
"source_ids": ["notion:launch-plan"],
|
||||
"limit": 0
|
||||
}),
|
||||
)
|
||||
@@ -1240,10 +1250,16 @@ async fn json_rpc_memory_tree_end_to_end() {
|
||||
.and_then(Value::as_array)
|
||||
.expect("chunks array");
|
||||
assert_eq!(chunks.len(), 1);
|
||||
// `list_chunks` returns the flat `ChunkRow` projection (id, source_kind,
|
||||
// source_id, source_ref as a flat string, owner, timestamp_ms, …), not
|
||||
// the full `Chunk { metadata: Metadata { source_ref: Option<SourceRef>,
|
||||
// … }, seq_in_source, … }` that `get_chunk` returns. Assert against
|
||||
// the row shape here.
|
||||
let chunk = &chunks[0];
|
||||
assert_eq!(chunk.get("seq_in_source"), Some(&json!(0)));
|
||||
assert_eq!(chunk.get("source_kind"), Some(&json!("document")));
|
||||
assert_eq!(chunk.get("source_id"), Some(&json!("notion:launch-plan")));
|
||||
assert_eq!(
|
||||
chunk.pointer("/metadata/source_ref/value"),
|
||||
chunk.get("source_ref"),
|
||||
Some(&json!("notion://page/launch-plan"))
|
||||
);
|
||||
|
||||
@@ -1259,6 +1275,14 @@ async fn json_rpc_memory_tree_end_to_end() {
|
||||
let get_outer = assert_no_jsonrpc_error(&get_chunk, "memory_tree_get_chunk");
|
||||
let get_result = get_outer.get("result").unwrap_or(get_outer);
|
||||
assert_eq!(get_result.pointer("/chunk/id"), Some(&chunk_ids[0]));
|
||||
// Full-Chunk-shape assertions live here because `get_chunk` returns the
|
||||
// canonical `Chunk` (with nested `metadata` + `seq_in_source`), unlike
|
||||
// `list_chunks`'s `ChunkRow` projection above.
|
||||
assert_eq!(get_result.pointer("/chunk/seq_in_source"), Some(&json!(0)));
|
||||
assert_eq!(
|
||||
get_result.pointer("/chunk/metadata/source_ref/value"),
|
||||
Some(&json!("notion://page/launch-plan"))
|
||||
);
|
||||
|
||||
let invalid_ingest = post_json_rpc(
|
||||
&rpc_base,
|
||||
|
||||
Reference in New Issue
Block a user