Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions app/src/components/intelligence/MemoryGraphMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface GraphNode {
id: string;
label: string;
namespace: string | null;
entityType: string | null;
connectionCount: number;
x: number;
y: number;
Expand Down Expand Up @@ -50,19 +51,28 @@ function buildGraph(relations: GraphRelation[]): { nodes: GraphNode[]; edges: Gr
const cappedRelations = sorted.slice(0, MAX_EDGES);

// Collect unique entity ids
const entitySet = new Map<string, { namespace: string | null; count: number }>();
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 });
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,
});
}

Expand All @@ -75,6 +85,7 @@ function buildGraph(relations: GraphRelation[]): { nodes: GraphNode[]; edges: Gr
id,
label: id,
namespace: info.namespace,
entityType: info.entityType,
connectionCount: info.count,
x: 80 + Math.random() * (WIDTH - 160),
y: 80 + Math.random() * (HEIGHT - 160),
Expand Down Expand Up @@ -334,6 +345,23 @@ export function MemoryGraphMap({ relations, loading }: MemoryGraphMapProps) {
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>
);
})}
Expand Down
16 changes: 16 additions & 0 deletions app/src/components/intelligence/MemoryInsights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ interface InsightItem {
evidenceCount: number;
namespace: string | null;
updatedAt: number;
subjectType: string | null;
objectType: string | null;
}

const PREDICATE_CATEGORIES: Record<string, InsightCategory> = {
Expand Down Expand Up @@ -134,6 +136,15 @@ const CATEGORY_CONFIG: Record<
},
};

/** Small inline badge that displays an entity type (e.g. "person", "project"). */
function EntityTypeBadge({ type }: { type: string }) {
return (
<span className="inline-block ml-1 px-1 py-px rounded text-[9px] leading-tight font-medium bg-white/8 text-stone-400 border border-white/6 uppercase tracking-wide">
{type}
</span>
);
}

export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
const [expandedCategory, setExpandedCategory] = useState<InsightCategory | null>(null);

Expand All @@ -143,13 +154,16 @@ export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
for (const rel of relations) {
const category = categorize(rel.predicate);
const items = buckets.get(category) ?? [];
const entityTypes = (rel.attrs?.entity_types ?? {}) as Record<string, string>;
items.push({
subject: rel.subject,
predicate: rel.predicate,
object: rel.object,
evidenceCount: rel.evidenceCount,
namespace: rel.namespace,
updatedAt: rel.updatedAt,
subjectType: entityTypes.subject ?? null,
objectType: entityTypes.object ?? null,
});
buckets.set(category, items);
}
Expand Down Expand Up @@ -264,10 +278,12 @@ export function MemoryInsights({ relations, loading }: MemoryInsightsProps) {
className="text-white/80 font-medium shrink-0 max-w-[30%] truncate"
title={item.subject}>
{item.subject}
{item.subjectType && <EntityTypeBadge type={item.subjectType} />}
</span>
<span className="text-stone-500 shrink-0 italic">{item.predicate}</span>
<span className="text-white/60 truncate" title={item.object}>
{item.object}
{item.objectType && <EntityTypeBadge type={item.objectType} />}
</span>
{item.evidenceCount > 1 && (
<span className="ml-auto text-[9px] text-stone-600 shrink-0 tabular-nums">
Expand Down
129 changes: 129 additions & 0 deletions app/src/components/intelligence/MemoryTextWithEntities.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* Renders memory query/recall text with highlighted entity type annotations,
* plus an optional structured entity list when the backend returns entities
* in the `context.entities[]` field.
*
* The backend surfaces entity types in text like:
* "Alice (PERSON) -[OWNS]-> Atlas (PROJECT)"
*
* This component parses those `(TYPE)` annotations and renders them as
* small styled badges inline, keeping the rest as plain text. When a
* structured `entities` array is provided, it also renders a compact
* entity chip bar above the text.
*/
import type { MemoryRetrievalEntity } from '../../utils/tauriCommands';

interface MemoryTextWithEntitiesProps {
text: string;
/** Structured entities from `context.entities[]` — shown as chips when present. */
entities?: MemoryRetrievalEntity[];
className?: string;
}

/** Matches parenthesized entity type annotations like (PERSON), (PROJECT), (ORG). */
const ENTITY_TYPE_RE = /\(([A-Z][A-Z0-9_]{1,30})\)/g;

/** Deterministic colour palette for entity type badges (hue-shifted). */
const TYPE_COLORS: Record<string, { bg: string; text: string; border: string }> = {
PERSON: { bg: 'bg-sky-500/15', text: 'text-sky-300', border: 'border-sky-500/20' },
PROJECT: { bg: 'bg-emerald-500/15', text: 'text-emerald-300', border: 'border-emerald-500/20' },
ORG: { bg: 'bg-amber-500/15', text: 'text-amber-300', border: 'border-amber-500/20' },
ORGANIZATION: { bg: 'bg-amber-500/15', text: 'text-amber-300', border: 'border-amber-500/20' },
TECHNOLOGY: { bg: 'bg-violet-500/15', text: 'text-violet-300', border: 'border-violet-500/20' },
TOOL: { bg: 'bg-violet-500/15', text: 'text-violet-300', border: 'border-violet-500/20' },
LOCATION: { bg: 'bg-rose-500/15', text: 'text-rose-300', border: 'border-rose-500/20' },
EVENT: { bg: 'bg-pink-500/15', text: 'text-pink-300', border: 'border-pink-500/20' },
CONCEPT: { bg: 'bg-teal-500/15', text: 'text-teal-300', border: 'border-teal-500/20' },
};

const DEFAULT_TYPE_COLOR = {
bg: 'bg-primary-500/15',
text: 'text-primary-300',
border: 'border-primary-500/20',
};

function colorForType(entityType: string): { bg: string; text: string; border: string } {
return TYPE_COLORS[entityType.toUpperCase()] ?? DEFAULT_TYPE_COLOR;
}

interface TextSegment {
kind: 'text' | 'entity-type';
value: string;
}

function parseEntityAnnotations(text: string): TextSegment[] {
const segments: TextSegment[] = [];
let lastIndex = 0;

for (const match of text.matchAll(ENTITY_TYPE_RE)) {
const matchStart = match.index;
if (matchStart > lastIndex) {
segments.push({ kind: 'text', value: text.slice(lastIndex, matchStart) });
}
segments.push({ kind: 'entity-type', value: match[1] });
lastIndex = matchStart + match[0].length;
}

if (lastIndex < text.length) {
segments.push({ kind: 'text', value: text.slice(lastIndex) });
}

return segments;
}

/** Compact chip for a structured entity, showing name + optional type badge. */
function EntityChip({ entity }: { entity: MemoryRetrievalEntity }) {
const color = entity.entity_type ? colorForType(entity.entity_type) : DEFAULT_TYPE_COLOR;
return (
<span
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded ${color.bg} border ${color.border}`}
title={entity.entity_type ? `${entity.name} (${entity.entity_type})` : entity.name}>
<span className={`text-[10px] leading-tight font-medium ${color.text}`}>{entity.name}</span>
{entity.entity_type && (
<span className="text-[8px] leading-tight font-semibold uppercase tracking-wide opacity-70">
{entity.entity_type}
</span>
)}
</span>
);
}

export function MemoryTextWithEntities({ text, entities, className }: MemoryTextWithEntitiesProps) {
if (!text && (!entities || entities.length === 0)) return null;

const hasStructuredEntities = entities && entities.length > 0;
const hasInlineAnnotations = /\([A-Z][A-Z0-9_]{1,30}\)/.test(text);

return (
<div className={className}>
{/* Structured entity chips */}
{hasStructuredEntities && (
<div className="flex flex-wrap gap-1 mb-2 pb-2 border-b border-white/5">
{entities.map((entity, i) => (
<EntityChip key={entity.id ?? `${entity.name}-${i}`} entity={entity} />
))}
</div>
)}

{/* Text content with inline entity type annotations */}
{text && (
<pre className="whitespace-pre-wrap m-0 p-0 font-inherit text-inherit leading-inherit">
{hasInlineAnnotations
? parseEntityAnnotations(text).map((seg, i) =>
seg.kind === 'entity-type' ? (
<span
key={i}
className={`inline-block mx-0.5 px-1 py-px rounded text-[9px] leading-tight font-semibold ${colorForType(seg.value).bg} ${colorForType(seg.value).text} border ${colorForType(seg.value).border} uppercase tracking-wide align-baseline`}
title={`Entity type: ${seg.value}`}>
{seg.value}
</span>
) : (
<span key={i}>{seg.value}</span>
)
)
: text}
</pre>
)}
</div>
);
}
28 changes: 17 additions & 11 deletions app/src/components/intelligence/MemoryWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ import {
memoryListDocuments,
memoryListNamespaces,
memoryQueryNamespace,
type MemoryQueryResult,
memoryRecallNamespace,
} from '../../utils/tauriCommands';
import { MemoryGraphMap } from './MemoryGraphMap';
import { MemoryHeatmap } from './MemoryHeatmap';
import { MemoryInsights } from './MemoryInsights';
import { MemoryStatsBar } from './MemoryStatsBar';
import { MemoryTextWithEntities } from './MemoryTextWithEntities';

type MemoryDoc = { documentId: string; namespace: string; title?: string; raw: unknown };

Expand Down Expand Up @@ -139,9 +141,9 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
const [selectedFileError, setSelectedFileError] = useState<string | null>(null);

const [queryInput, setQueryInput] = useState('important user preferences and active goals');
const [queryResult, setQueryResult] = useState('');
const [queryResult, setQueryResult] = useState<MemoryQueryResult | null>(null);
const [queryLoading, setQueryLoading] = useState(false);
const [recallResult, setRecallResult] = useState('');
const [recallResult, setRecallResult] = useState<MemoryQueryResult | null>(null);
const [recallLoading, setRecallLoading] = useState(false);
const [memoryActionError, setMemoryActionError] = useState<string | null>(null);

Expand Down Expand Up @@ -254,7 +256,7 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
setQueryResult(response);
} catch (error) {
setMemoryActionError(error instanceof Error ? error.message : 'Query failed');
setQueryResult('');
setQueryResult(null);
} finally {
setQueryLoading(false);
}
Expand All @@ -266,10 +268,10 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
setMemoryActionError(null);
try {
const response = await memoryRecallNamespace(selectedNamespace, 10);
setRecallResult(response ?? '');
setRecallResult(response);
} catch (error) {
setMemoryActionError(error instanceof Error ? error.message : 'Recall failed');
setRecallResult('');
setRecallResult(null);
} finally {
setRecallLoading(false);
}
Expand Down Expand Up @@ -515,15 +517,19 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3 mb-3">
<div>
<div className="text-[11px] text-stone-500 mb-1">Query response</div>
<pre className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-28 overflow-auto text-[11px] leading-5 text-stone-200 whitespace-pre-wrap">
{queryResult || 'No query result yet.'}
</pre>
<MemoryTextWithEntities
text={queryResult?.text || 'No query result yet.'}
entities={queryResult?.entities}
className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-28 overflow-auto text-[11px] leading-5 text-stone-200 whitespace-pre-wrap"
/>
</div>
<div>
<div className="text-[11px] text-stone-500 mb-1">Recall response</div>
<pre className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-28 overflow-auto text-[11px] leading-5 text-stone-200 whitespace-pre-wrap">
{recallResult || 'No recall result yet.'}
</pre>
<MemoryTextWithEntities
text={recallResult?.text || 'No recall result yet.'}
entities={recallResult?.entities}
className="rounded-lg border border-white/10 bg-stone-950/50 p-2 h-28 overflow-auto text-[11px] leading-5 text-stone-200 whitespace-pre-wrap"
/>
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ vi.mock('../../../utils/tauriCommands', () => ({
aiReadMemoryFile: vi.fn().mockResolvedValue('# Memory\nSome content'),
aiWriteMemoryFile: vi.fn().mockResolvedValue(undefined),
memoryDeleteDocument: vi.fn().mockResolvedValue(undefined),
memoryQueryNamespace: vi.fn().mockResolvedValue('query result'),
memoryRecallNamespace: vi.fn().mockResolvedValue('recall result'),
memoryQueryNamespace: vi.fn().mockResolvedValue({ text: 'query result', entities: [] }),
memoryRecallNamespace: vi.fn().mockResolvedValue({ text: 'recall result', entities: [] }),
memoryGraphQuery: vi.fn().mockResolvedValue([
{
namespace: 'research',
Expand Down
24 changes: 15 additions & 9 deletions app/src/components/settings/panels/MemoryDebugPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {
memoryListDocuments,
memoryListNamespaces,
memoryQueryNamespace,
type MemoryQueryResult,
memoryRecallNamespace,
} from '../../../utils/tauriCommands';
import { MemoryTextWithEntities } from '../../intelligence/MemoryTextWithEntities';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import { PrimaryButton } from './components/ActionPanel';
Expand All @@ -30,8 +32,8 @@ const MemoryDebugPanel = () => {
const [namespaceInput, setNamespaceInput] = useState('');
const [queryInput, setQueryInput] = useState('');
const [maxChunksInput, setMaxChunksInput] = useState('10');
const [queryResult, setQueryResult] = useState<string | null>(null);
const [recallResult, setRecallResult] = useState<string | null>(null);
const [queryResult, setQueryResult] = useState<MemoryQueryResult | null>(null);
const [recallResult, setRecallResult] = useState<MemoryQueryResult | null>(null);
const [queryError, setQueryError] = useState<string | null>(null);
const [recallError, setRecallError] = useState<string | null>(null);
const [queryLoading, setQueryLoading] = useState(false);
Expand Down Expand Up @@ -134,7 +136,7 @@ const MemoryDebugPanel = () => {
setRecallResult(null);
try {
const result = await memoryRecallNamespace(namespaceInput.trim(), maxChunks);
setRecallResult(result ?? '');
setRecallResult(result);
} catch (error) {
setRecallError(error instanceof Error ? error.message : String(error));
} finally {
Expand Down Expand Up @@ -411,13 +413,17 @@ const MemoryDebugPanel = () => {

<div className="space-y-2">
<div className="text-xs text-stone-400">Query response</div>
<pre className="rounded border border-stone-700 bg-black/20 p-2 overflow-auto text-[11px] leading-5 min-h-16">
{queryResult ?? ''}
</pre>
<MemoryTextWithEntities
text={queryResult?.text ?? ''}
entities={queryResult?.entities}
className="rounded border border-stone-700 bg-black/20 p-2 overflow-auto text-[11px] leading-5 min-h-16 whitespace-pre-wrap"
/>
<div className="text-xs text-stone-400">Recall response</div>
<pre className="rounded border border-stone-700 bg-black/20 p-2 overflow-auto text-[11px] leading-5 min-h-16">
{recallResult ?? ''}
</pre>
<MemoryTextWithEntities
text={recallResult?.text ?? ''}
entities={recallResult?.entities}
className="rounded border border-stone-700 bg-black/20 p-2 overflow-auto text-[11px] leading-5 min-h-16 whitespace-pre-wrap"
/>
</div>
</div>
</SectionCard>
Expand Down
Loading
Loading