-
({ mountPixiGraph: vi.fn() }));
+
+vi.mock('./pixiGraphRenderer', () => ({
+ mountPixiGraph: (...args: unknown[]) => mocks.mountPixiGraph(...args),
+}));
+
+const NODES: GraphNode[] = [
+ { kind: 'summary', id: 'root', label: 'R', level: 0, parent_id: null },
+ { kind: 'chunk', id: 'leaf', label: 'L', parent_id: 'root' },
+];
+
+describe('
', () => {
+ beforeEach(() => mocks.mountPixiGraph.mockReset());
+ afterEach(() => vi.restoreAllMocks());
+
+ it('mounts the renderer with built graph data', async () => {
+ const handle = { resetView: vi.fn(), setTheme: vi.fn(), destroy: vi.fn() };
+ mocks.mountPixiGraph.mockResolvedValue(handle);
+ const { getByTestId } = render(
+
+ );
+ expect(getByTestId('memory-graph-canvas')).toBeInTheDocument();
+ await waitFor(() => expect(mocks.mountPixiGraph).toHaveBeenCalledTimes(1));
+ const [, opts] = mocks.mountPixiGraph.mock.calls[0] as [
+ HTMLElement,
+ { simNodes: unknown[]; links: unknown[] },
+ ];
+ expect(opts.simNodes).toHaveLength(2);
+ expect(opts.links).toHaveLength(1); // leaf -> root
+ });
+
+ it('destroys the renderer on unmount', async () => {
+ const handle = { resetView: vi.fn(), setTheme: vi.fn(), destroy: vi.fn() };
+ mocks.mountPixiGraph.mockResolvedValue(handle);
+ const { unmount } = render(
+
+ );
+ await waitFor(() => expect(mocks.mountPixiGraph).toHaveBeenCalled());
+ unmount();
+ await waitFor(() => expect(handle.destroy).toHaveBeenCalled());
+ });
+});
diff --git a/app/src/components/intelligence/PixiGraph.tsx b/app/src/components/intelligence/PixiGraph.tsx
new file mode 100644
index 000000000..958a79032
--- /dev/null
+++ b/app/src/components/intelligence/PixiGraph.tsx
@@ -0,0 +1,101 @@
+/**
+ * Thin React host for the imperative Pixi + d3-force renderer.
+ *
+ * Mounts the WebGL graph into a div and forwards hover/open back to the
+ * parent `MemoryGraph` chrome (footer + preview). Pixi owns all canvas
+ * interaction; React only manages the lifecycle. Callbacks are held in
+ * refs so changing them never tears down and re-creates the GPU context.
+ */
+import { useEffect, useRef } from 'react';
+
+import { type GraphEdge, type GraphMode, type GraphNode } from '../../utils/tauriCommands';
+import { buildGraph } from './memoryGraphLayout';
+import { mountPixiGraph, type PixiGraphHandle } from './pixiGraphRenderer';
+
+interface PixiGraphProps {
+ nodes: GraphNode[];
+ edges: GraphEdge[];
+ mode: GraphMode;
+ dark: boolean;
+ /** Bump to recentre the view (Reset view button). */
+ resetSignal: number;
+ onHover: (node: GraphNode | null) => void;
+ onOpen: (node: GraphNode) => void;
+ /** Called if Pixi fails to initialise at runtime so the parent can
+ * fall back to the SVG renderer. */
+ onError?: () => void;
+}
+
+export function PixiGraph({
+ nodes,
+ edges,
+ mode,
+ dark,
+ resetSignal,
+ onHover,
+ onOpen,
+ onError,
+}: PixiGraphProps) {
+ const hostRef = useRef
(null);
+ const handleRef = useRef(null);
+ const onHoverRef = useRef(onHover);
+ const onOpenRef = useRef(onOpen);
+ const onErrorRef = useRef(onError);
+ const darkRef = useRef(dark);
+ onHoverRef.current = onHover;
+ onOpenRef.current = onOpen;
+ onErrorRef.current = onError;
+ darkRef.current = dark;
+
+ // (Re)mount the renderer whenever the graph data or mode changes.
+ useEffect(() => {
+ const host = hostRef.current;
+ if (!host) return;
+ let cancelled = false;
+ const { simNodes, links } = buildGraph(nodes, edges, mode);
+ const pending = mountPixiGraph(host, {
+ simNodes,
+ links,
+ dark: darkRef.current,
+ onHover: n => onHoverRef.current(n),
+ onOpen: n => onOpenRef.current(n),
+ })
+ .then(handle => {
+ if (cancelled) {
+ handle.destroy();
+ return null;
+ }
+ handleRef.current = handle;
+ return handle;
+ })
+ .catch(err => {
+ // Runtime WebGL failure (driver / lost context) even though
+ // supportsWebGL() was true — let the parent fall back to SVG.
+ console.error('[memory-graph] Pixi init failed; falling back to SVG', err);
+ if (!cancelled) onErrorRef.current?.();
+ return null;
+ });
+ return () => {
+ cancelled = true;
+ handleRef.current = null;
+ void pending.then(handle => handle?.destroy());
+ };
+ }, [nodes, edges, mode]);
+
+ useEffect(() => {
+ handleRef.current?.setTheme(dark);
+ }, [dark]);
+
+ useEffect(() => {
+ if (resetSignal > 0) handleRef.current?.resetView();
+ }, [resetSignal]);
+
+ return (
+
+ );
+}
diff --git a/app/src/components/intelligence/VaultPanel.test.tsx b/app/src/components/intelligence/VaultPanel.test.tsx
deleted file mode 100644
index 96e63c2b2..000000000
--- a/app/src/components/intelligence/VaultPanel.test.tsx
+++ /dev/null
@@ -1,500 +0,0 @@
-/**
- * Vitest for ``. Covers: load/empty/error states, the add-
- * vault form happy + error paths, per-row sync (success + failed-files
- * branch), and remove with both purge=true and purge=false flows.
- */
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { VaultPanel } from './VaultPanel';
-
-const mockList = vi.fn();
-const mockCreate = vi.fn();
-const mockSync = vi.fn();
-const mockSyncStatus = vi.fn();
-const mockRemove = vi.fn();
-
-const mockOpenUrl = vi.fn();
-const mockRevealPath = vi.fn();
-
-vi.mock('../../utils/tauriCommands/vault', () => ({
- openhumanVaultList: (...args: unknown[]) => mockList(...args),
- openhumanVaultCreate: (...args: unknown[]) => mockCreate(...args),
- openhumanVaultSync: (...args: unknown[]) => mockSync(...args),
- openhumanVaultSyncStatus: (...args: unknown[]) => mockSyncStatus(...args),
- openhumanVaultRemove: (...args: unknown[]) => mockRemove(...args),
-}));
-
-vi.mock('../../utils/openUrl', () => ({
- openUrl: (...args: unknown[]) => mockOpenUrl(...args),
- revealPath: (...args: unknown[]) => mockRevealPath(...args),
-}));
-
-function vault(overrides: Record = {}) {
- return {
- id: 'v-1',
- name: 'Notes',
- root_path: '/Users/me/notes',
- namespace: 'vault:v-1',
- include_globs: [],
- exclude_globs: [],
- created_at: '2026-05-17T10:00:00Z',
- last_synced_at: null,
- file_count: 0,
- write_state: 'writable',
- write_state_reason: 'writable',
- ...overrides,
- };
-}
-
-/** Build a completed `CoreVaultSyncState` payload for mockSyncStatus. */
-function syncState(overrides: Record = {}) {
- return {
- result: {
- vault_id: 'v-1',
- status: 'completed',
- scanned: 4,
- ingested: 3,
- unchanged: 1,
- removed: 0,
- failed: 0,
- skipped_unsupported: 0,
- total: 4,
- started_at_ms: 1_000,
- finished_at_ms: 2_200,
- duration_ms: 1_200,
- errors: [],
- ...overrides,
- },
- logs: [],
- };
-}
-
-describe('', () => {
- beforeEach(() => {
- mockList.mockReset();
- mockCreate.mockReset();
- mockSync.mockReset();
- mockSyncStatus.mockReset();
- mockRemove.mockReset();
- mockOpenUrl.mockReset();
- mockRevealPath.mockReset();
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- it('shows loading then empty state when list returns no vaults', async () => {
- mockList.mockResolvedValueOnce({ result: [], logs: [] });
- render();
- expect(screen.getByText(/Loading vaults/)).toBeTruthy();
- await waitFor(() => screen.getByText(/No vaults yet/));
- expect(mockList).toHaveBeenCalledTimes(1);
- });
-
- it('renders an error banner when list fails', async () => {
- mockList.mockRejectedValueOnce(new Error('rpc down'));
- render();
- await waitFor(() => screen.getByText(/Failed to load vaults/));
- expect(screen.getByText(/rpc down/)).toBeTruthy();
- });
-
- it('lists vaults with file count + relative last-synced label', async () => {
- vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-05-17T10:05:00Z').getTime());
- mockList.mockResolvedValueOnce({
- result: [
- vault({ id: 'v-A', name: 'A', file_count: 42, last_synced_at: '2026-05-17T10:04:30Z' }),
- ],
- logs: [],
- });
- render();
- await waitFor(() => screen.getByTestId('vault-list'));
- expect(screen.getByText('A')).toBeTruthy();
- expect(screen.getByText(/42 file/)).toBeTruthy();
- expect(screen.getByText(/synced 30s ago/)).toBeTruthy();
- expect(screen.getByTestId('vault-write-state-v-A')).toHaveTextContent('Writable');
- expect(screen.getByText(/Approved markdown/)).toBeTruthy();
- });
-
- it('shows read-only and unavailable vault write states with reasons', async () => {
- mockList.mockResolvedValueOnce({
- result: [
- vault({
- id: 'v-readonly',
- name: 'Read only',
- write_state: 'read_only',
- write_state_reason: 'read_only',
- }),
- vault({
- id: 'v-missing',
- name: 'Missing',
- write_state: 'unavailable',
- write_state_reason: 'unavailable',
- }),
- ],
- logs: [],
- });
- render();
- await waitFor(() => screen.getByTestId('vault-list'));
-
- expect(screen.getByTestId('vault-write-state-v-readonly')).toHaveTextContent('Read-only');
- expect(screen.getByText('Vault folder is read-only on this device.')).toBeTruthy();
- expect(screen.getByTestId('vault-write-state-v-missing')).toHaveTextContent('Unavailable');
- expect(screen.getByText('Vault folder is not available on this device.')).toBeTruthy();
- });
-
- it('toggles the add form and creates a vault on submit', async () => {
- mockList
- .mockResolvedValueOnce({ result: [], logs: [] })
- .mockResolvedValueOnce({ result: [vault()], logs: [] });
- mockCreate.mockResolvedValueOnce({ result: vault(), logs: [] });
- const onToast = vi.fn();
- render();
- await waitFor(() => screen.getByText(/No vaults yet/));
-
- fireEvent.click(screen.getByTestId('vault-add-toggle'));
- const form = screen.getByTestId('vault-add-form');
- const inputs = form.querySelectorAll('input');
- fireEvent.change(inputs[0], { target: { value: 'My notes' } });
- fireEvent.change(inputs[1], { target: { value: '/Users/me/notes' } });
- fireEvent.change(inputs[2], { target: { value: 'drafts, .secret' } });
- fireEvent.submit(form);
-
- await waitFor(() =>
- expect(mockCreate).toHaveBeenCalledWith({
- name: 'My notes',
- rootPath: '/Users/me/notes',
- excludeGlobs: ['drafts', '.secret'],
- })
- );
- expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({ type: 'success', title: 'Vault added' })
- );
- // Reload happens after create — list called twice (initial + post-create).
- expect(mockList).toHaveBeenCalledTimes(2);
- });
-
- it('emits an error toast when create throws', async () => {
- mockList.mockResolvedValueOnce({ result: [], logs: [] });
- mockCreate.mockRejectedValueOnce(new Error('disk full'));
- const onToast = vi.fn();
- render();
- await waitFor(() => screen.getByText(/No vaults yet/));
-
- fireEvent.click(screen.getByTestId('vault-add-toggle'));
- const form = screen.getByTestId('vault-add-form');
- const inputs = form.querySelectorAll('input');
- fireEvent.change(inputs[0], { target: { value: 'n' } });
- fireEvent.change(inputs[1], { target: { value: '/x' } });
- fireEvent.submit(form);
-
- await waitFor(() =>
- expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({ type: 'error', title: 'Could not add vault' })
- )
- );
- });
-
- it('syncs a vault and reports counts via toast', async () => {
- mockList
- .mockResolvedValueOnce({ result: [vault()], logs: [] })
- .mockResolvedValueOnce({ result: [vault()], logs: [] });
- // vault_sync returns immediately with "started"
- mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] });
- // vault_sync_status returns "completed" on first poll
- mockSyncStatus.mockResolvedValueOnce(syncState());
- const onToast = vi.fn();
- render();
- await waitFor(() => screen.getByTestId('vault-list'));
-
- fireEvent.click(screen.getByText('Sync'));
- await waitFor(() => expect(mockSync).toHaveBeenCalledWith('v-1'));
- await waitFor(() =>
- expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({
- type: 'success',
- title: expect.stringContaining('Synced'),
- message: expect.stringContaining('Ingested 3'),
- })
- )
- );
- });
-
- it('uses info toast when sync reports failed files', async () => {
- mockList
- .mockResolvedValueOnce({ result: [vault()], logs: [] })
- .mockResolvedValueOnce({ result: [vault()], logs: [] });
- mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] });
- mockSyncStatus.mockResolvedValueOnce(
- syncState({
- ingested: 1,
- unchanged: 0,
- failed: 1,
- duration_ms: 50,
- errors: ['x.md: read failed'],
- })
- );
- const onToast = vi.fn();
- render();
- await waitFor(() => screen.getByTestId('vault-list'));
-
- fireEvent.click(screen.getByText('Sync'));
- await waitFor(() =>
- expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({ type: 'info', message: expect.stringContaining('failed 1') })
- )
- );
- });
-
- it('emits error toast when sync RPC fails', async () => {
- mockList.mockResolvedValueOnce({ result: [vault()], logs: [] });
- mockSync.mockRejectedValueOnce(new Error('boom'));
- const onToast = vi.fn();
- render();
- await waitFor(() => screen.getByTestId('vault-list'));
-
- fireEvent.click(screen.getByText('Sync'));
- await waitFor(() =>
- expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({ type: 'error', title: 'Sync failed' })
- )
- );
- });
-
- it('emits error toast when sync status returns failed', async () => {
- mockList
- .mockResolvedValueOnce({ result: [vault()], logs: [] })
- .mockResolvedValueOnce({ result: [vault()], logs: [] });
- mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] });
- mockSyncStatus.mockResolvedValueOnce(
- syncState({ status: 'failed', failed: 0, errors: ['disk full'] })
- );
- const onToast = vi.fn();
- render();
- await waitFor(() => screen.getByTestId('vault-list'));
-
- fireEvent.click(screen.getByText('Sync'));
- await waitFor(() =>
- expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({
- type: 'error',
- title: expect.stringContaining('Sync failed'),
- message: expect.stringContaining('disk full'),
- })
- )
- );
- });
-
- it('emits error toast when status poll RPC throws', async () => {
- mockList.mockResolvedValueOnce({ result: [vault()], logs: [] });
- mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] });
- mockSyncStatus.mockRejectedValueOnce(new Error('poll error'));
- const onToast = vi.fn();
- render();
- await waitFor(() => screen.getByTestId('vault-list'));
-
- fireEvent.click(screen.getByText('Sync'));
- await waitFor(() =>
- expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({ type: 'error', title: 'Sync failed', message: 'poll error' })
- )
- );
- });
-
- it('uses fallback failed-file count message when errors array is empty', async () => {
- mockList
- .mockResolvedValueOnce({ result: [vault()], logs: [] })
- .mockResolvedValueOnce({ result: [vault()], logs: [] });
- mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] });
- mockSyncStatus.mockResolvedValueOnce(syncState({ status: 'failed', failed: 3, errors: [] }));
- const onToast = vi.fn();
- render();
- await waitFor(() => screen.getByTestId('vault-list'));
-
- fireEvent.click(screen.getByText('Sync'));
- await waitFor(() =>
- expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({
- type: 'error',
- message: expect.stringContaining('Failed 3 file(s)'),
- })
- )
- );
- });
-
- it('includes skipped_unsupported count in completed toast message', async () => {
- mockList
- .mockResolvedValueOnce({ result: [vault()], logs: [] })
- .mockResolvedValueOnce({ result: [vault()], logs: [] });
- mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] });
- mockSyncStatus.mockResolvedValueOnce(
- syncState({ ingested: 2, skipped_unsupported: 5, duration_ms: 0 })
- );
- const onToast = vi.fn();
- render();
- await waitFor(() => screen.getByTestId('vault-list'));
-
- fireEvent.click(screen.getByText('Sync'));
- await waitFor(() =>
- expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({ message: expect.stringContaining('skipped 5') })
- )
- );
- });
-
- it('cancels pending poll timer on unmount', async () => {
- mockList.mockResolvedValueOnce({ result: [vault()], logs: [] });
- mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] });
- // Always running — the 1 500 ms re-poll timer stays live after poll #1.
- mockSyncStatus.mockResolvedValue(syncState({ status: 'running', ingested: 1, total: 4 }));
-
- const { unmount } = render();
- await waitFor(() => screen.getByTestId('vault-list'));
-
- fireEvent.click(screen.getByText('Sync'));
-
- // Wait until the first poll fires (0 ms timer) so the 1 500 ms next-poll
- // timer is scheduled in pollTimers.current.
- await waitFor(() => expect(mockSyncStatus).toHaveBeenCalledTimes(1));
-
- const clearSpy = vi.spyOn(globalThis, 'clearTimeout');
- unmount();
-
- // useEffect cleanup must have called clearTimeout for the pending timer.
- expect(clearSpy).toHaveBeenCalled();
- clearSpy.mockRestore();
- });
-
- it('removes a vault with purge=true when both confirms accepted', async () => {
- mockList
- .mockResolvedValueOnce({ result: [vault()], logs: [] })
- .mockResolvedValueOnce({ result: [], logs: [] });
- mockRemove.mockResolvedValueOnce({
- result: { vault_id: 'v-1', removed: true, purged: true },
- logs: [],
- });
- const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
- const onToast = vi.fn();
- render();
- await waitFor(() => screen.getByTestId('vault-list'));
-
- fireEvent.click(screen.getByText('Remove'));
- await waitFor(() => expect(mockRemove).toHaveBeenCalledWith('v-1', true));
- expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({ type: 'success', message: expect.stringContaining('purged') })
- );
- confirmSpy.mockRestore();
- });
-
- it('removes a vault with purge=false when first confirm denied', async () => {
- mockList
- .mockResolvedValueOnce({ result: [vault()], logs: [] })
- .mockResolvedValueOnce({ result: [], logs: [] });
- mockRemove.mockResolvedValueOnce({
- result: { vault_id: 'v-1', removed: true, purged: false },
- logs: [],
- });
- // First confirm (purge?) → no; second confirm (really remove?) → yes.
- const confirmSpy = vi
- .spyOn(window, 'confirm')
- .mockReturnValueOnce(false)
- .mockReturnValueOnce(true);
- const onToast = vi.fn();
- render();
- await waitFor(() => screen.getByTestId('vault-list'));
-
- fireEvent.click(screen.getByText('Remove'));
- await waitFor(() => expect(mockRemove).toHaveBeenCalledWith('v-1', false));
- expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({ message: expect.stringContaining('Documents kept') })
- );
- confirmSpy.mockRestore();
- });
-
- it('aborts remove when second confirm is denied', async () => {
- mockList.mockResolvedValueOnce({ result: [vault()], logs: [] });
- const confirmSpy = vi
- .spyOn(window, 'confirm')
- .mockReturnValueOnce(true)
- .mockReturnValueOnce(false);
- render();
- await waitFor(() => screen.getByTestId('vault-list'));
-
- fireEvent.click(screen.getByText('Remove'));
- // Allow microtasks to settle so any (incorrect) RPC dispatch would land.
- await Promise.resolve();
- expect(mockRemove).not.toHaveBeenCalled();
- confirmSpy.mockRestore();
- });
-
- it('emits error toast when remove RPC fails', async () => {
- mockList.mockResolvedValueOnce({ result: [vault()], logs: [] });
- mockRemove.mockRejectedValueOnce(new Error('locked'));
- const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
- const onToast = vi.fn();
- render();
- await waitFor(() => screen.getByTestId('vault-list'));
-
- fireEvent.click(screen.getByText('Remove'));
- await waitFor(() =>
- expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({ type: 'error', title: 'Could not remove vault' })
- )
- );
- confirmSpy.mockRestore();
- });
-
- it('open vault fires obsidian deep link and shows success toast', async () => {
- mockList.mockResolvedValueOnce({ result: [vault()], logs: [] });
- mockOpenUrl.mockResolvedValueOnce(undefined);
- const onToast = vi.fn();
- render();
- await waitFor(() => screen.getByTestId('vault-list'));
-
- fireEvent.click(screen.getByTestId('vault-open'));
- await waitFor(() =>
- expect(mockOpenUrl).toHaveBeenCalledWith(
- 'obsidian://open?path=' + encodeURIComponent('/Users/me/notes')
- )
- );
- expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({ type: 'info', title: 'Opened in Obsidian' })
- );
- });
-
- it('open vault falls back to revealPath when obsidian deep link fails', async () => {
- mockList.mockResolvedValueOnce({ result: [vault()], logs: [] });
- mockOpenUrl.mockRejectedValueOnce(new Error('scheme not handled'));
- mockRevealPath.mockResolvedValueOnce(undefined);
- const onToast = vi.fn();
- render();
- await waitFor(() => screen.getByTestId('vault-list'));
-
- fireEvent.click(screen.getByTestId('vault-open'));
- await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled());
- await waitFor(() => expect(mockRevealPath).toHaveBeenCalledWith('/Users/me/notes'));
- expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({
- type: 'info',
- title: 'Obsidian not found — opened in file manager',
- })
- );
- });
-
- it('open vault shows error toast when both obsidian and reveal fail', async () => {
- mockList.mockResolvedValueOnce({ result: [vault()], logs: [] });
- mockOpenUrl.mockRejectedValueOnce(new Error('scheme not handled'));
- mockRevealPath.mockRejectedValueOnce(new Error('permission denied'));
- const onToast = vi.fn();
- render();
- await waitFor(() => screen.getByTestId('vault-list'));
-
- fireEvent.click(screen.getByTestId('vault-open'));
- await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled());
- await waitFor(() => expect(mockRevealPath).toHaveBeenCalled());
- expect(onToast).toHaveBeenCalledWith(
- expect.objectContaining({ type: 'error', title: "Couldn't open vault" })
- );
- });
-});
diff --git a/app/src/components/intelligence/VaultPanel.tsx b/app/src/components/intelligence/VaultPanel.tsx
deleted file mode 100644
index e85b8d064..000000000
--- a/app/src/components/intelligence/VaultPanel.tsx
+++ /dev/null
@@ -1,490 +0,0 @@
-/**
- * Knowledge vaults — point the assistant at a local folder and have its
- * files mirrored into memory under namespace `vault:`. Sits inside
- * the Intelligence ▸ Memory tab.
- */
-import { useCallback, useEffect, useRef, useState } from 'react';
-
-import { useT } from '../../lib/i18n/I18nContext';
-import type { ToastNotification } from '../../types/intelligence';
-import { openUrl, revealPath } from '../../utils/openUrl';
-import {
- type CoreVault,
- type CoreVaultSyncState,
- openhumanVaultCreate,
- openhumanVaultList,
- openhumanVaultRemove,
- openhumanVaultSync,
- openhumanVaultSyncStatus,
-} from '../../utils/tauriCommands/vault';
-
-/** How often the UI re-polls for sync progress while a sync is running (ms). */
-const SYNC_POLL_INTERVAL_MS = 1_500;
-
-interface VaultPanelProps {
- onToast?: (toast: Omit) => void;
-}
-
-export function VaultPanel({ onToast }: VaultPanelProps) {
- const { t } = useT();
- const [vaults, setVaults] = useState([]);
- const [loading, setLoading] = useState(true);
- const [loadError, setLoadError] = useState(null);
- const [busy, setBusy] = useState>({});
- const [syncProgress, setSyncProgress] = useState<
- Record
- >({});
- const [creating, setCreating] = useState(false);
- const [showForm, setShowForm] = useState(false);
- const [newName, setNewName] = useState('');
- const [newPath, setNewPath] = useState('');
- const [newExcludes, setNewExcludes] = useState('');
-
- // Track active polling timers so we can cancel them on unmount.
- const pollTimers = useRef>>({});
-
- // Cancel all active poll timers on unmount.
- useEffect(() => {
- const timers = pollTimers.current;
- return () => {
- for (const t of Object.values(timers)) {
- clearTimeout(t);
- }
- };
- }, []);
-
- const reload = useCallback(async () => {
- setLoading(true);
- setLoadError(null);
- try {
- const resp = await openhumanVaultList();
- setVaults(resp.result);
- } catch (err) {
- console.error('[ui-flow][vault-panel] list failed', err);
- setLoadError(err instanceof Error ? err.message : String(err));
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- void reload();
- }, [reload]);
-
- const handleCreate = useCallback(
- async (event: React.FormEvent) => {
- event.preventDefault();
- const name = newName.trim();
- const rootPath = newPath.trim();
- if (!name || !rootPath) return;
- const excludeGlobs = newExcludes
- .split(',')
- .map(s => s.trim())
- .filter(Boolean);
- setCreating(true);
- try {
- const resp = await openhumanVaultCreate({ name, rootPath, excludeGlobs });
- onToast?.({
- type: 'success',
- title: t('vault.added'),
- message: t('vault.createdMessage')
- .replace('{name}', resp.result.name)
- .replace('{sync}', t('sync.sync')),
- });
- setNewName('');
- setNewPath('');
- setNewExcludes('');
- setShowForm(false);
- await reload();
- } catch (err) {
- console.error('[ui-flow][vault-panel] create failed', err);
- onToast?.({
- type: 'error',
- title: t('vault.couldNotAdd'),
- message: err instanceof Error ? err.message : String(err),
- });
- } finally {
- setCreating(false);
- }
- },
- [newExcludes, newName, newPath, onToast, reload, t]
- );
-
- const handleSync = useCallback(
- async (vault: CoreVault) => {
- setBusy(b => ({ ...b, [vault.id]: 'sync' }));
- setSyncProgress(p => ({ ...p, [vault.id]: undefined }));
-
- // Start the background sync.
- try {
- await openhumanVaultSync(vault.id);
- } catch (err) {
- console.error('[ui-flow][vault-panel] sync start failed', err);
- onToast?.({
- type: 'error',
- title: t('vault.syncFailed'),
- message: err instanceof Error ? err.message : String(err),
- });
- setBusy(b => ({ ...b, [vault.id]: undefined }));
- return;
- }
-
- console.debug('[ui-flow][vault-panel] sync started, polling for status', {
- vaultId: vault.id,
- });
-
- // Poll until the background task finishes.
- const vaultId = vault.id;
- const vaultName = vault.name;
-
- const poll = async () => {
- let st: CoreVaultSyncState;
- try {
- const resp = await openhumanVaultSyncStatus(vaultId);
- st = resp.result;
- } catch (err) {
- console.error('[ui-flow][vault-panel] sync status poll failed', err);
- setBusy(b => ({ ...b, [vaultId]: undefined }));
- setSyncProgress(p => ({ ...p, [vaultId]: undefined }));
- onToast?.({
- type: 'error',
- title: t('vault.syncFailed'),
- message: err instanceof Error ? err.message : String(err),
- });
- return;
- }
-
- // Update progress indicator while running.
- if (st.total > 0) {
- setSyncProgress(p => ({ ...p, [vaultId]: { ingested: st.ingested, total: st.total } }));
- }
-
- console.debug('[ui-flow][vault-panel] sync poll', {
- vaultId,
- status: st.status,
- ingested: st.ingested,
- total: st.total,
- });
-
- if (st.status === 'completed' || st.status === 'failed') {
- // Clear polling state and show final toast.
- delete pollTimers.current[vaultId];
- setBusy(b => ({ ...b, [vaultId]: undefined }));
- setSyncProgress(p => ({ ...p, [vaultId]: undefined }));
-
- if (st.status === 'failed') {
- onToast?.({
- type: 'error',
- title: t('vault.syncFailedFor').replace('{name}', vaultName),
- message:
- st.errors.length > 0
- ? st.errors.slice(0, 3).join('; ')
- : t('vault.syncFailedFiles').replace('{count}', String(st.failed)),
- });
- } else {
- onToast?.({
- type: st.failed > 0 ? 'info' : 'success',
- title: t('vault.syncedTitle').replace('{name}', vaultName),
- message: formatSyncSummary(st, t),
- });
- }
- await reload();
- return;
- }
-
- // Still running — schedule the next poll.
- pollTimers.current[vaultId] = setTimeout(() => {
- void poll();
- }, SYNC_POLL_INTERVAL_MS);
- };
-
- // First poll fires immediately (0 ms delay) so tests don't need fake timers.
- pollTimers.current[vaultId] = setTimeout(() => {
- void poll();
- }, 0);
- },
- [onToast, reload, t]
- );
-
- const handleRemove = useCallback(
- async (vault: CoreVault) => {
- const purge = window.confirm(
- t('vault.confirmRemovePurge')
- .replace('{name}', vault.name)
- .replace('{count}', String(vault.file_count))
- );
- // Confirm step #2: ensure the user actually meant to remove the vault row.
- const ok = window.confirm(t('vault.confirmRemove').replace('{name}', vault.name));
- if (!ok) return;
- setBusy(b => ({ ...b, [vault.id]: 'remove' }));
- try {
- await openhumanVaultRemove(vault.id, purge);
- onToast?.({
- type: 'success',
- title: t('vault.removed'),
- message: purge
- ? t('vault.removedPurgedMessage').replace('{name}', vault.name)
- : t('vault.removedKeptMessage').replace('{name}', vault.name),
- });
- await reload();
- } catch (err) {
- console.error('[ui-flow][vault-panel] remove failed', err);
- onToast?.({
- type: 'error',
- title: t('vault.couldNotRemove'),
- message: err instanceof Error ? err.message : String(err),
- });
- } finally {
- setBusy(b => ({ ...b, [vault.id]: undefined }));
- }
- },
- [onToast, reload, t]
- );
-
- const handleOpenVault = useCallback(
- async (rootPath: string) => {
- try {
- await openUrl(`obsidian://open?path=${encodeURIComponent(rootPath)}`);
- onToast?.({ type: 'info', title: t('vault.openSuccess'), message: rootPath });
- return;
- } catch (err) {
- console.error('[ui-flow][vault-panel] obsidian deep link failed', err);
- }
- try {
- await revealPath(rootPath);
- onToast?.({ type: 'info', title: t('vault.openFallback'), message: rootPath });
- } catch (err) {
- console.error('[ui-flow][vault-panel] reveal vault failed', err);
- onToast?.({ type: 'error', title: t('vault.openError'), message: String(err) });
- }
- },
- [onToast, t]
- );
-
- return (
-
-
-
-
- {t('vault.title')}
-
-
{t('vault.description')}
-
-
-
-
- {showForm ? (
-
- ) : null}
-
- {loading ? (
-
- {t('vault.loading')}
-
- ) : loadError ? (
-
- {t('vault.failedToLoad').replace('{error}', loadError)}
-
- ) : vaults.length === 0 ? (
-
- {t('vault.empty')}
-
- ) : (
-
- {vaults.map(v => {
- const state = busy[v.id];
- const writeStateReason = v.write_state_reason
- ? t(
- `vault.writeState.reasons.${v.write_state_reason}`,
- t('vault.writeState.unknownReason')
- )
- : t('vault.writeState.unknownReason');
- return (
- -
-
-
- {v.name}
-
-
- {v.root_path}
-
-
- {t('vault.fileCount').replace('{count}', v.file_count.toLocaleString())} ·{' '}
- {v.last_synced_at
- ? t('vault.syncedRelative').replace(
- '{time}',
- formatRelative(v.last_synced_at, t)
- )
- : t('vault.neverSynced')}
-
-
-
- {t(`vault.writeState.${v.write_state}`)}
-
-
- {writeStateReason}
-
-
-
-
-
-
-
-
-
- );
- })}
-
- )}
-
- );
-}
-
-function writeStateBadgeClass(state: CoreVault['write_state']): string {
- const base = 'inline-flex h-5 items-center rounded px-1.5 text-[10px] font-semibold';
- switch (state) {
- case 'writable':
- return `${base} bg-sage-50 text-sage-700 dark:bg-sage-500/10 dark:text-sage-300`;
- case 'read_only':
- return `${base} bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-300`;
- case 'unavailable':
- default:
- return `${base} bg-coral-50 text-coral-700 dark:bg-coral-500/10 dark:text-coral-300`;
- }
-}
-
-function formatRelative(iso: string, translate: (key: string) => string): string {
- const timestamp = new Date(iso).getTime();
- if (Number.isNaN(timestamp)) return iso;
- const diff = Math.max(0, Date.now() - timestamp);
- const sec = Math.floor(diff / 1000);
- if (sec < 60) return translate('vault.relative.sec').replace('{count}', String(sec));
- const min = Math.floor(sec / 60);
- if (min < 60) return translate('vault.relative.min').replace('{count}', String(min));
- const hr = Math.floor(min / 60);
- if (hr < 24) return translate('vault.relative.hr').replace('{count}', String(hr));
- const day = Math.floor(hr / 24);
- return translate('vault.relative.day').replace('{count}', String(day));
-}
-
-function formatSyncSummary(state: CoreVaultSyncState, t: (key: string) => string): string {
- let summary = t('vault.syncSummary')
- .replace('{ingested}', String(state.ingested))
- .replace('{unchanged}', String(state.unchanged))
- .replace('{removed}', String(state.removed));
- if (state.failed > 0) {
- summary += t('vault.syncSummaryFailed').replace('{count}', String(state.failed));
- }
- if (state.skipped_unsupported > 0) {
- summary += t('vault.syncSummarySkipped').replace('{count}', String(state.skipped_unsupported));
- }
- if (state.duration_ms > 0) {
- summary += t('vault.syncSummaryDuration').replace(
- '{seconds}',
- (state.duration_ms / 1000).toFixed(1)
- );
- }
- return summary;
-}
diff --git a/app/src/components/intelligence/memoryGraphLayout.test.ts b/app/src/components/intelligence/memoryGraphLayout.test.ts
new file mode 100644
index 000000000..4b8adf7de
--- /dev/null
+++ b/app/src/components/intelligence/memoryGraphLayout.test.ts
@@ -0,0 +1,109 @@
+import { describe, expect, it } from 'vitest';
+
+import type { GraphEdge, GraphNode } from '../../utils/tauriCommands';
+import {
+ buildGraph,
+ CONTACT_COLOR,
+ createSimulation,
+ LEAF_COLOR,
+ LEVEL_COLOR,
+ levelColor,
+ nodeColor,
+ nodeGlows,
+ nodeRadius,
+ pickNode,
+ type SimNode,
+ supportsWebGL,
+} from './memoryGraphLayout';
+
+function summary(overrides: Partial = {}): GraphNode {
+ return { kind: 'summary', id: 's', label: 'S', level: 0, parent_id: null, ...overrides };
+}
+function chunk(overrides: Partial = {}): GraphNode {
+ return { kind: 'chunk', id: 'c', label: 'C', ...overrides };
+}
+function contact(overrides: Partial = {}): GraphNode {
+ return { kind: 'contact', id: 'p', label: 'P', entity_kind: 'person', ...overrides };
+}
+
+describe('memoryGraphLayout', () => {
+ it('colours summaries by level, wrapping the palette', () => {
+ expect(levelColor(0)).toBe(LEVEL_COLOR[0]);
+ expect(levelColor(2)).toBe(LEVEL_COLOR[2]);
+ expect(levelColor(LEVEL_COLOR.length)).toBe(LEVEL_COLOR[0]); // wraps
+ expect(levelColor(null)).toBe(LEAF_COLOR);
+ expect(levelColor(-5)).toBe(LEVEL_COLOR[0]); // clamped to 0
+ });
+
+ it('nodeColor branches on kind', () => {
+ expect(nodeColor(summary({ level: 1 }))).toBe(LEVEL_COLOR[1]);
+ expect(nodeColor(chunk())).toBe(LEAF_COLOR);
+ expect(nodeColor(contact())).toBe(CONTACT_COLOR);
+ });
+
+ it('nodeRadius shrinks with level and is fixed for chunk/contact', () => {
+ expect(nodeRadius(summary({ level: 0 }))).toBe(10);
+ expect(nodeRadius(summary({ level: 3 }))).toBeCloseTo(7.6);
+ expect(nodeRadius(summary({ level: 99 }))).toBe(4); // floored
+ expect(nodeRadius(contact())).toBe(9);
+ expect(nodeRadius(chunk())).toBe(4);
+ });
+
+ it('only summary/contact nodes glow', () => {
+ expect(nodeGlows(summary())).toBe(true);
+ expect(nodeGlows(contact())).toBe(true);
+ expect(nodeGlows(chunk())).toBe(false);
+ });
+
+ it('buildGraph derives parent_id edges in tree mode and drops danglers', () => {
+ const nodes = [
+ summary({ id: 'root', parent_id: null }),
+ summary({ id: 'child', level: 1, parent_id: 'root' }),
+ chunk({ id: 'leaf', parent_id: 'child' }),
+ chunk({ id: 'orphan', parent_id: 'missing' }), // dangling → dropped
+ ];
+ const { simNodes, links } = buildGraph(nodes, [], 'tree');
+ expect(simNodes).toHaveLength(4);
+ expect(simNodes.every(n => typeof n.x === 'number' && typeof n.y === 'number')).toBe(true);
+ const pairs = links.map(l => `${String(l.source)}->${String(l.target)}`);
+ expect(pairs).toContain('child->root');
+ expect(pairs).toContain('leaf->child');
+ expect(pairs).not.toContain('orphan->missing');
+ });
+
+ it('buildGraph uses explicit edges in contacts mode and drops danglers', () => {
+ const nodes = [chunk({ id: 'c1' }), contact({ id: 'p1' })];
+ const edges: GraphEdge[] = [
+ { from: 'c1', to: 'p1' },
+ { from: 'c1', to: 'ghost' }, // dangling endpoint → dropped
+ ];
+ const { links } = buildGraph(nodes, edges, 'contacts');
+ expect(links).toHaveLength(1);
+ expect(String(links[0].source)).toBe('c1');
+ expect(String(links[0].target)).toBe('p1');
+ });
+
+ it('createSimulation resolves link ids to node objects and converges', () => {
+ const nodes = [summary({ id: 'root' }), summary({ id: 'child', parent_id: 'root' })];
+ const { simNodes, links } = buildGraph(nodes, [], 'tree');
+ const sim = createSimulation(simNodes, links);
+ for (let i = 0; i < 50; i++) sim.tick();
+ // forceLink replaces string ids with the actual node objects.
+ expect((links[0].source as SimNode).id).toBe('child');
+ expect((links[0].target as SimNode).id).toBe('root');
+ expect(Number.isFinite(simNodes[0].x)).toBe(true);
+ sim.stop();
+ });
+
+ it('pickNode returns the nearest node within its disc, else null', () => {
+ const a: SimNode = { ...summary({ id: 'a', level: 0 }), x: 0, y: 0 };
+ const b: SimNode = { ...summary({ id: 'b', level: 0 }), x: 100, y: 0 };
+ expect(pickNode([a, b], 1, 1)?.id).toBe('a');
+ expect(pickNode([a, b], 99, 0)?.id).toBe('b');
+ expect(pickNode([a, b], 50, 50)).toBeNull(); // outside both discs
+ });
+
+ it('supportsWebGL is false under jsdom (no GL context)', () => {
+ expect(supportsWebGL()).toBe(false);
+ });
+});
diff --git a/app/src/components/intelligence/memoryGraphLayout.ts b/app/src/components/intelligence/memoryGraphLayout.ts
new file mode 100644
index 000000000..a9c5e2fd2
--- /dev/null
+++ b/app/src/components/intelligence/memoryGraphLayout.ts
@@ -0,0 +1,166 @@
+/**
+ * Shared, render-agnostic layout + palette helpers for the memory graph.
+ *
+ * Physics is d3-force (Barnes–Hut quadtree charge, O(n log n)) so the
+ * 1000-node cap settles smoothly — the same model Obsidian's graph is
+ * built on. Both the WebGL (Pixi) renderer and the SVG fallback consume
+ * these helpers so colours, radii, edge derivation and hit-testing stay
+ * identical across paths.
+ */
+import {
+ forceCenter,
+ forceCollide,
+ forceLink,
+ forceManyBody,
+ forceSimulation,
+ type Simulation,
+ type SimulationLinkDatum,
+ type SimulationNodeDatum,
+} from 'd3-force';
+
+import { type GraphEdge, type GraphMode, type GraphNode } from '../../utils/tauriCommands';
+
+/**
+ * Per-level palette — each tree level "lights up" in its own hue
+ * (mirrors the Obsidian `path:L{n}` colour groups).
+ */
+export const LEVEL_COLOR = [
+ '#7C3AED', // L0
+ '#4A83DD', // L1
+ '#1FB6C7', // L2
+ '#34C77B', // L3
+ '#E8A653', // L4
+ '#E0654A', // L5
+ '#C026D3', // L6+
+];
+export const LEAF_COLOR = '#94A3B8'; // raw chunks / leaves (no level)
+export const CONTACT_COLOR = '#A78BFA'; // person entities (contacts mode)
+
+/** Layout is computed in this fixed coordinate space; the renderer pans/zooms it. */
+export const VIEWPORT_W = 1100;
+export const VIEWPORT_H = 640;
+export const ZOOM_MIN = 0.3;
+export const ZOOM_MAX = 4;
+
+export function levelColor(level: number | null | undefined): string {
+ if (level == null) return LEAF_COLOR;
+ return LEVEL_COLOR[Math.max(0, level) % LEVEL_COLOR.length];
+}
+
+export function nodeColor(node: GraphNode): string {
+ if (node.kind === 'summary') return levelColor(node.level);
+ if (node.kind === 'contact') return CONTACT_COLOR;
+ return LEAF_COLOR; // chunk
+}
+
+export function nodeRadius(node: GraphNode): number {
+ if (node.kind === 'summary') return Math.max(4, 10 - (node.level ?? 0) * 0.8);
+ if (node.kind === 'contact') return 9;
+ return 4; // chunk
+}
+
+/** Summary / contact nodes glow; leaves stay flat so the structure pops. */
+export function nodeGlows(node: GraphNode): boolean {
+ return node.kind !== 'chunk';
+}
+
+/** A graph node carrying mutable physics state (x/y/vx/vy populated by d3-force). */
+export interface SimNode extends GraphNode, SimulationNodeDatum {
+ x: number;
+ y: number;
+}
+
+export type SimLink = SimulationLinkDatum;
+
+/**
+ * Seed node positions on a ring centred on the origin and derive links.
+ * Tree mode draws an edge from each node to its `parent_id`; contacts mode
+ * uses the explicit `edges`. Dangling endpoints are dropped.
+ */
+export function buildGraph(
+ nodes: GraphNode[],
+ edges: GraphEdge[],
+ mode: GraphMode
+): { simNodes: SimNode[]; links: SimLink[] } {
+ const ids = new Set(nodes.map(n => n.id));
+ const simNodes: SimNode[] = nodes.map((n, i) => {
+ const angle = (i / Math.max(1, nodes.length)) * Math.PI * 2;
+ const r = 180 + (i % 7) * 14;
+ return { ...n, x: Math.cos(angle) * r, y: Math.sin(angle) * r };
+ });
+ const links: SimLink[] = [];
+ if (mode === 'tree') {
+ for (const n of nodes) {
+ if (!n.parent_id || !ids.has(n.parent_id) || !ids.has(n.id)) continue;
+ links.push({ source: n.id, target: n.parent_id });
+ }
+ } else {
+ for (const e of edges) {
+ if (!ids.has(e.from) || !ids.has(e.to)) continue;
+ links.push({ source: e.from, target: e.to });
+ }
+ }
+ return { simNodes, links };
+}
+
+/**
+ * A cooled d3-force simulation (call `.tick()` from the render loop). Charge
+ * = Coulomb repulsion (Barnes–Hut), link = Hooke spring, plus centring and
+ * a soft collide so nodes don't stack.
+ */
+export function createSimulation(
+ simNodes: SimNode[],
+ links: SimLink[]
+): Simulation {
+ return forceSimulation(simNodes)
+ .force('charge', forceManyBody().strength(-140).distanceMax(420))
+ .force(
+ 'link',
+ forceLink(links)
+ .id(d => d.id)
+ .distance(58)
+ .strength(0.35)
+ )
+ .force('center', forceCenter(0, 0).strength(0.04))
+ .force(
+ 'collide',
+ forceCollide().radius(n => nodeRadius(n) + 2)
+ )
+ .stop();
+}
+
+/**
+ * Nearest node whose disc (radius + slop) contains the point, or null.
+ * Linear scan — trivial at the 1000-node cap and only runs on pointer
+ * events, never per frame.
+ */
+export function pickNode(simNodes: SimNode[], x: number, y: number, slop = 4): SimNode | null {
+ let best: SimNode | null = null;
+ let bestD = Infinity;
+ for (const n of simNodes) {
+ const r = nodeRadius(n) + slop;
+ const dx = n.x - x;
+ const dy = n.y - y;
+ const d = dx * dx + dy * dy;
+ if (d <= r * r && d < bestD) {
+ bestD = d;
+ best = n;
+ }
+ }
+ return best;
+}
+
+/** Does the renderer have a usable WebGL context? Drives Pixi-vs-SVG. */
+export function supportsWebGL(): boolean {
+ if (typeof document === 'undefined') return false;
+ try {
+ const canvas = document.createElement('canvas');
+ return !!(
+ canvas.getContext('webgl2') ||
+ canvas.getContext('webgl') ||
+ canvas.getContext('experimental-webgl')
+ );
+ } catch {
+ return false;
+ }
+}
diff --git a/app/src/components/intelligence/pixiGraphRenderer.test.ts b/app/src/components/intelligence/pixiGraphRenderer.test.ts
new file mode 100644
index 000000000..2790e2434
--- /dev/null
+++ b/app/src/components/intelligence/pixiGraphRenderer.test.ts
@@ -0,0 +1,202 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { SimLink, SimNode } from './memoryGraphLayout';
+import { mountPixiGraph } from './pixiGraphRenderer';
+
+// Minimal Pixi stubs that record the handlers the renderer wires up so we
+// can drive them from the test. Shared with the mock via vi.hoisted.
+const h = vi.hoisted(() => {
+ const state: {
+ stageHandlers: Record void>;
+ canvasListeners: Record void>;
+ rendererHandlers: Record void>;
+ tickerCb: (() => void) | null;
+ destroyed: boolean;
+ } = {
+ stageHandlers: {},
+ canvasListeners: {},
+ rendererHandlers: {},
+ tickerCb: null,
+ destroyed: false,
+ };
+ class Graphics {
+ clear() {
+ return this;
+ }
+ circle() {
+ return this;
+ }
+ fill() {
+ return this;
+ }
+ moveTo() {
+ return this;
+ }
+ lineTo() {
+ return this;
+ }
+ stroke() {
+ return this;
+ }
+ }
+ class Container {
+ position = {
+ x: 0,
+ y: 0,
+ set(x: number, y: number) {
+ this.x = x;
+ this.y = y;
+ },
+ };
+ scale = {
+ x: 1,
+ y: 1,
+ set(s: number) {
+ this.x = s;
+ this.y = s;
+ },
+ };
+ children: unknown[] = [];
+ eventMode = '';
+ hitArea: unknown = null;
+ addChild(c: unknown) {
+ this.children.push(c);
+ }
+ toLocal(p: { x: number; y: number }) {
+ return {
+ x: (p.x - this.position.x) / this.scale.x,
+ y: (p.y - this.position.y) / this.scale.y,
+ };
+ }
+ on(ev: string, cb: (e: unknown) => void) {
+ state.stageHandlers[ev] = cb;
+ }
+ }
+ class Application {
+ canvas = {
+ style: {} as Record,
+ addEventListener: (ev: string, cb: (e: unknown) => void) => {
+ state.canvasListeners[ev] = cb;
+ },
+ removeEventListener: vi.fn(),
+ };
+ stage = new Container();
+ screen = { width: 800, height: 600 };
+ ticker = {
+ add: (cb: () => void) => {
+ state.tickerCb = cb;
+ },
+ };
+ renderer = {
+ on: (ev: string, cb: () => void) => {
+ state.rendererHandlers[ev] = cb;
+ },
+ };
+ init = vi.fn().mockResolvedValue(undefined);
+ destroy = vi.fn(() => {
+ state.destroyed = true;
+ });
+ }
+ return { state, Graphics, Container, Application };
+});
+
+vi.mock('pixi.js', () => ({
+ Application: h.Application,
+ Container: h.Container,
+ Graphics: h.Graphics,
+}));
+
+function makeNodes(): SimNode[] {
+ return [
+ { kind: 'summary', id: 'root', label: 'R', level: 0, parent_id: null, x: 0, y: 0 },
+ { kind: 'chunk', id: 'leaf', label: 'L', x: 200, y: 0 },
+ ];
+}
+
+describe('mountPixiGraph', () => {
+ beforeEach(() => {
+ h.state.stageHandlers = {};
+ h.state.canvasListeners = {};
+ h.state.rendererHandlers = {};
+ h.state.tickerCb = null;
+ h.state.destroyed = false;
+ });
+
+ async function mount() {
+ const simNodes = makeNodes();
+ const links: SimLink[] = [{ source: simNodes[1], target: simNodes[0] }];
+ const onHover = vi.fn();
+ const onOpen = vi.fn();
+ const host = { appendChild: vi.fn() } as unknown as HTMLElement;
+ const handle = await mountPixiGraph(host, { simNodes, links, dark: false, onHover, onOpen });
+ return { handle, simNodes, onHover, onOpen, host };
+ }
+
+ it('wires interaction handlers and a render loop', async () => {
+ const { host } = await mount();
+ expect(host.appendChild).toHaveBeenCalled();
+ expect(h.state.tickerCb).toBeTypeOf('function');
+ expect(h.state.stageHandlers.pointerdown).toBeTypeOf('function');
+ // Render loop draws while the simulation is warm.
+ expect(() => h.state.tickerCb?.()).not.toThrow();
+ });
+
+ it('opens a node on a click without movement', async () => {
+ const { onOpen } = await mount();
+ // World is centred at (400,300); a click there maps to graph (0,0) = "root".
+ h.state.stageHandlers.pointerdown({ global: { x: 400, y: 300 } });
+ h.state.stageHandlers.pointerup({});
+ expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ id: 'root' }));
+ });
+
+ it('drags a node instead of opening it once the pointer moves', async () => {
+ const { onOpen, simNodes } = await mount();
+ h.state.stageHandlers.pointerdown({ global: { x: 400, y: 300 } });
+ h.state.stageHandlers.pointermove({ global: { x: 450, y: 320 } });
+ h.state.stageHandlers.pointerup({});
+ expect(onOpen).not.toHaveBeenCalled();
+ // Pin released after the drag so physics can resume.
+ expect(simNodes[0].fx ?? null).toBeNull();
+ });
+
+ it('emits hover when the pointer is over a node and clears it off-node', async () => {
+ const { onHover } = await mount();
+ h.state.stageHandlers.pointermove({ global: { x: 400, y: 300 } }); // over root
+ expect(onHover).toHaveBeenLastCalledWith(expect.objectContaining({ id: 'root' }));
+ h.state.stageHandlers.pointermove({ global: { x: 10, y: 10 } }); // empty space
+ expect(onHover).toHaveBeenLastCalledWith(null);
+ });
+
+ it('pans on background drag', async () => {
+ await mount();
+ // Pointer down on empty space starts a pan, not a node drag.
+ expect(() => {
+ h.state.stageHandlers.pointerdown({ global: { x: 10, y: 10 } });
+ h.state.stageHandlers.pointermove({ global: { x: 60, y: 40 } });
+ h.state.stageHandlers.pointerup({});
+ }).not.toThrow();
+ });
+
+ it('zooms on wheel without throwing and prevents default scroll', async () => {
+ await mount();
+ const preventDefault = vi.fn();
+ h.state.canvasListeners.wheel({ offsetX: 400, offsetY: 300, deltaY: -120, preventDefault });
+ expect(preventDefault).toHaveBeenCalled();
+ });
+
+ it('resetView, setTheme and resize redraw without error', async () => {
+ const { handle } = await mount();
+ expect(() => {
+ handle.setTheme(true);
+ handle.resetView();
+ h.state.rendererHandlers.resize?.();
+ h.state.tickerCb?.();
+ }).not.toThrow();
+ });
+
+ it('destroy tears down the Pixi application', async () => {
+ const { handle } = await mount();
+ handle.destroy();
+ expect(h.state.destroyed).toBe(true);
+ });
+});
diff --git a/app/src/components/intelligence/pixiGraphRenderer.ts b/app/src/components/intelligence/pixiGraphRenderer.ts
new file mode 100644
index 000000000..2141e8631
--- /dev/null
+++ b/app/src/components/intelligence/pixiGraphRenderer.ts
@@ -0,0 +1,276 @@
+/**
+ * WebGL memory-graph renderer — Pixi.js draw loop driven by a d3-force
+ * simulation. This is the same stack Obsidian's graph view uses (Pixi for
+ * GPU rendering, force-directed physics) so it stays smooth well past the
+ * 1000-node cap.
+ *
+ * The renderer is fully imperative: a React wrapper mounts it into a host
+ * element and feeds hover/open back through callbacks. All interaction
+ * (drag a node, drag the background to pan, wheel to zoom) is hit-tested
+ * against the simulation positions in `memoryGraphLayout`, so there are no
+ * per-node DOM objects — the whole graph is a single canvas.
+ *
+ * Drawing is dirty-flagged: while the simulation is warm (or the user is
+ * interacting) we redraw each frame; once it cools the loop idles.
+ */
+import { Application, Container, type FederatedPointerEvent, Graphics } from 'pixi.js';
+
+import {
+ createSimulation,
+ nodeColor,
+ nodeGlows,
+ nodeRadius,
+ pickNode,
+ type SimLink,
+ type SimNode,
+ ZOOM_MAX,
+ ZOOM_MIN,
+} from './memoryGraphLayout';
+
+export interface PixiGraphOptions {
+ simNodes: SimNode[];
+ links: SimLink[];
+ dark: boolean;
+ onHover: (node: SimNode | null) => void;
+ onOpen: (node: SimNode) => void;
+}
+
+export interface PixiGraphHandle {
+ resetView(): void;
+ setTheme(dark: boolean): void;
+ destroy(): void;
+}
+
+function colorNum(hex: string): number {
+ return parseInt(hex.replace('#', ''), 16);
+}
+
+/**
+ * Mount a Pixi graph into `host`. Resolves once the WebGL context is live;
+ * rejects/throws if Pixi can't initialise (caller falls back to SVG).
+ */
+export async function mountPixiGraph(
+ host: HTMLElement,
+ opts: PixiGraphOptions
+): Promise {
+ const app = new Application();
+ await app.init({
+ resizeTo: host,
+ backgroundAlpha: 0,
+ antialias: true,
+ autoDensity: true,
+ resolution: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
+ // Match Obsidian — force the WebGL backend rather than letting Pixi
+ // probe WebGPU, which is uneven across the CEF runtime.
+ preference: 'webgl',
+ });
+ host.appendChild(app.canvas);
+ app.canvas.style.width = '100%';
+ app.canvas.style.height = '100%';
+ app.canvas.style.display = 'block';
+
+ const world = new Container();
+ const edgeG = new Graphics();
+ const nodeG = new Graphics();
+ world.addChild(edgeG);
+ world.addChild(nodeG);
+ app.stage.addChild(world);
+
+ const recenter = () => world.position.set(app.screen.width / 2, app.screen.height / 2);
+ recenter();
+
+ const sim = createSimulation(opts.simNodes, opts.links);
+ sim.alpha(1);
+
+ let dark = opts.dark;
+ let dirty = true;
+ let hoveredId: string | null = null;
+ // Auto-fit the whole graph into view until the user pans/zooms/drags,
+ // so the initial frame is zoomed out to show as much as possible.
+ let userInteracted = false;
+
+ /** Scale + centre the world so every node's disc fits the viewport. */
+ const fitToView = () => {
+ if (opts.simNodes.length === 0) return;
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+ for (const n of opts.simNodes) {
+ const r = nodeRadius(n) + 6;
+ if (n.x - r < minX) minX = n.x - r;
+ if (n.y - r < minY) minY = n.y - r;
+ if (n.x + r > maxX) maxX = n.x + r;
+ if (n.y + r > maxY) maxY = n.y + r;
+ }
+ if (!Number.isFinite(minX)) return;
+ const pad = 48;
+ const w = Math.max(1, maxX - minX);
+ const h = Math.max(1, maxY - minY);
+ const scale = Math.min(
+ ZOOM_MAX,
+ Math.max(ZOOM_MIN, Math.min((app.screen.width - pad) / w, (app.screen.height - pad) / h))
+ );
+ world.scale.set(scale);
+ const cx = (minX + maxX) / 2;
+ const cy = (minY + maxY) / 2;
+ world.position.set(app.screen.width / 2 - cx * scale, app.screen.height / 2 - cy * scale);
+ };
+
+ const draw = () => {
+ edgeG.clear();
+ for (const l of opts.links) {
+ const s = l.source as SimNode;
+ const t = l.target as SimNode;
+ if (!s || !t || typeof s.x !== 'number' || typeof t.x !== 'number') continue;
+ edgeG.moveTo(s.x, s.y);
+ edgeG.lineTo(t.x, t.y);
+ }
+ edgeG.stroke({ width: 0.8, color: dark ? 0x475569 : 0xcbd5e1, alpha: 0.7 });
+
+ nodeG.clear();
+ // Halos first so the structural levels "light up" beneath the discs.
+ for (const n of opts.simNodes) {
+ if (!nodeGlows(n)) continue;
+ nodeG
+ .circle(n.x, n.y, nodeRadius(n) + 5)
+ .fill({ color: colorNum(nodeColor(n)), alpha: 0.18 });
+ }
+ for (const n of opts.simNodes) {
+ const hover = n.id === hoveredId;
+ const r = nodeRadius(n) + (hover ? 2 : 0);
+ nodeG.circle(n.x, n.y, r).fill({ color: colorNum(nodeColor(n)), alpha: 1 });
+ if (hover) nodeG.circle(n.x, n.y, r).stroke({ width: 1.4, color: 0x0f172a, alpha: 0.9 });
+ }
+ };
+
+ app.ticker.add(() => {
+ let changed = dirty;
+ if (sim.alpha() > sim.alphaMin()) {
+ sim.tick();
+ changed = true;
+ }
+ if (changed) {
+ // Keep the whole graph framed while it settles, until the user
+ // takes over the camera.
+ if (!userInteracted) fitToView();
+ draw();
+ dirty = false;
+ }
+ });
+
+ // ── interaction ────────────────────────────────────────────────────
+ app.stage.eventMode = 'static';
+ app.stage.hitArea = app.screen;
+ let drag:
+ | { node: SimNode; moved: boolean }
+ | { panX: number; panY: number; px: number; py: number; moved: boolean }
+ | null = null;
+
+ const setCursor = (c: string) => {
+ app.canvas.style.cursor = c;
+ };
+
+ app.stage.on('pointerdown', (e: FederatedPointerEvent) => {
+ userInteracted = true; // hand the camera to the user
+ const p = world.toLocal(e.global);
+ const node = pickNode(opts.simNodes, p.x, p.y);
+ if (node) {
+ sim.alpha(0.3);
+ node.fx = node.x;
+ node.fy = node.y;
+ drag = { node, moved: false };
+ setCursor('grabbing');
+ } else {
+ drag = {
+ panX: world.position.x,
+ panY: world.position.y,
+ px: e.global.x,
+ py: e.global.y,
+ moved: false,
+ };
+ setCursor('grabbing');
+ }
+ });
+
+ app.stage.on('pointermove', (e: FederatedPointerEvent) => {
+ if (drag) {
+ if ('node' in drag) {
+ const p = world.toLocal(e.global);
+ drag.node.fx = p.x;
+ drag.node.fy = p.y;
+ drag.moved = true;
+ if (sim.alpha() < 0.1) sim.alpha(0.1);
+ } else {
+ world.position.set(drag.panX + (e.global.x - drag.px), drag.panY + (e.global.y - drag.py));
+ drag.moved = true;
+ }
+ dirty = true;
+ return;
+ }
+ const p = world.toLocal(e.global);
+ const node = pickNode(opts.simNodes, p.x, p.y);
+ const id = node ? node.id : null;
+ setCursor(node ? 'pointer' : 'grab');
+ if (id !== hoveredId) {
+ hoveredId = id;
+ dirty = true;
+ opts.onHover(node ?? null);
+ }
+ });
+
+ const endDrag = (open: boolean) => {
+ const d = drag;
+ if (d && 'node' in d) {
+ // Release the pin so physics resumes for that node.
+ d.node.fx = null;
+ d.node.fy = null;
+ if (open && !d.moved) opts.onOpen(d.node);
+ }
+ drag = null;
+ setCursor('grab');
+ };
+ app.stage.on('pointerup', () => endDrag(true));
+ app.stage.on('pointerupoutside', () => endDrag(false));
+
+ const onWheel = (e: WheelEvent) => {
+ e.preventDefault();
+ userInteracted = true;
+ const gx = e.offsetX;
+ const gy = e.offsetY;
+ // Graph point under the cursor, kept fixed across the zoom.
+ const lx = (gx - world.position.x) / world.scale.x;
+ const ly = (gy - world.position.y) / world.scale.y;
+ const next = Math.min(
+ ZOOM_MAX,
+ Math.max(ZOOM_MIN, world.scale.x * Math.exp(-e.deltaY * 0.0015))
+ );
+ world.scale.set(next);
+ world.position.set(gx - lx * next, gy - ly * next);
+ dirty = true;
+ };
+ app.canvas.addEventListener('wheel', onWheel, { passive: false });
+
+ app.renderer.on('resize', () => {
+ dirty = true;
+ });
+
+ return {
+ resetView() {
+ // Re-enable auto-fit and reheat so the graph re-frames itself.
+ userInteracted = false;
+ sim.alpha(0.3);
+ dirty = true;
+ },
+ setTheme(next: boolean) {
+ dark = next;
+ dirty = true;
+ },
+ destroy() {
+ sim.stop();
+ app.canvas.removeEventListener('wheel', onWheel);
+ // destroy(true) tears down the canvas + GPU resources.
+ app.destroy(true, { children: true });
+ },
+ };
+}
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts
index be57d0c4c..c89c01610 100644
--- a/app/src/lib/i18n/ar.ts
+++ b/app/src/lib/i18n/ar.ts
@@ -264,6 +264,7 @@ const messages: TranslationMap = {
'memory.noResults': 'لم يتم العثور على ذكريات',
'memory.empty': 'لا توجد ذكريات بعد. تُنشأ الذكريات تلقائيًا أثناء تفاعلك.',
'memory.tab.memory': 'الذاكرة',
+ 'memory.tab.memoryTree': 'شجرة الذاكرة',
'memory.tab.tasks': 'مهام الوكيل',
'memory.tab.tasksDescription':
'أنشئ المهام وتتبعها — مهامك الخاصة بالإضافة إلى اللوحات التي يبنيها وكلاؤك عبر المحادثات.',
@@ -1865,6 +1866,7 @@ const messages: TranslationMap = {
'graph.children': 'أبناء',
'graph.clickToOpenObsidian': 'انقر للفتح في Obsidian',
'graph.person': 'شخص',
+ 'graph.resetView': 'إعادة ضبط العرض',
'modal.dontShowAgain': 'لا تظهر اقتراحات مماثلة',
'reflections.loading': 'جارٍ تحميل التأملات...',
'reflections.empty': 'لا توجد تأملات بعد',
@@ -4026,61 +4028,6 @@ const messages: TranslationMap = {
'تكوين إعدادات فرز الذكاء الاصطناعي لمشغلات التكامل Composio',
'memory.sourceFilterAria': 'التصفية حسب المصدر',
'calls.comingSoonDescription': 'المكالمات بمساعدة الذكاء الاصطناعي قادمة قريباً. ابقَ على اطلاع.',
- 'vault.title': 'خزائن المعرفة',
- 'vault.description': 'أشر إلى مجلد محلي؛ يتم تقسيم الملفات وعكسها في الذاكرة.',
- 'vault.add': 'إضافة مخزن',
- 'vault.added': 'تمت إضافة المخزن',
- 'vault.createdMessage': 'تم إنشاء "{name}". انقر فوق {sync} لاستيعابها.',
- 'vault.couldNotAdd': 'تعذر إضافة مخزن',
- 'vault.syncFailed': 'فشلت المزامنة',
- 'vault.syncFailedFor': 'فشلت المزامنة لـ "{name}"',
- 'vault.syncFailedFiles': 'فشل ملف (ملفات) {count}',
- 'vault.syncedTitle': 'تمت مزامنة "{name}"',
- 'vault.syncSummary': 'تم استيعابها {ingested}، دون تغيير {unchanged}، تمت إزالة {removed}',
- 'vault.syncSummaryFailed': ', فشل {count}',
- 'vault.syncSummarySkipped': '، تم تخطي {count}',
- 'vault.syncSummaryDuration': ' · {seconds}s',
- 'vault.confirmRemovePurge':
- 'إزالة الخزنة "{name}"؟\n\nانقر «موافق» لحذف ذاكرتها أيضاً (حذف جميع {count} مستند/مستندات مستوعبة).\nانقر «إلغاء» للاحتفاظ بالمستندات في الذاكرة.',
- 'vault.confirmRemove': 'هل تريد حقًا إزالة المخزن "{name}"؟',
- 'vault.removed': 'تمت إزالة المخزن',
- 'vault.removedPurgedMessage': 'تمت إزالة "{name}" وتطهير ذاكرته.',
- 'vault.removedKeptMessage': 'تمت إزالة "{name}". الوثائق المحفوظة في الذاكرة.',
- 'vault.couldNotRemove': 'تعذرت إزالة المخزن',
- 'vault.name': 'الاسم',
- 'vault.namePlaceholder': 'ملاحظاتي البحثية',
- 'vault.folderPath': 'مسار المجلد (مطلق)',
- 'vault.folderPathPlaceholder': '/Users/you/Documents/notes',
- 'vault.excludes': 'باستثناء (سلاسل فرعية مفصولة بفواصل، اختيارية)',
- 'vault.excludesPlaceholder': 'المسودات/، .secret',
- 'vault.creating': 'إنشاء...',
- 'vault.create': 'إنشاء مخزن',
- 'vault.loading': 'تحميل الخزائن...',
- 'vault.failedToLoad': 'فشل تحميل الخزائن: {error}',
- 'vault.empty': 'لا توجد خزائن حتى الآن. أضف واحدًا أعلاه لبدء استيعاب مجلد.',
- 'vault.fileCount': '{count} ملف (ملفات)',
- 'vault.syncedRelative': 'تمت مزامنته {time}',
- 'vault.neverSynced': 'لم تتم مزامنته أبدًا',
- 'vault.writeState.writable': 'قابل للكتابة',
- 'vault.writeState.read_only': 'للقراءة فقط',
- 'vault.writeState.unavailable': 'غير متاح',
- 'vault.writeState.unknownReason': 'حالة الكتابة غير معروفة.',
- 'vault.writeState.reasons.writable':
- 'يمكن حفظ عمليات كتابة Markdown/wiki الموافق عليها في هذا المخزن.',
- 'vault.writeState.reasons.read_only': 'مجلد المخزن للقراءة فقط على هذا الجهاز.',
- 'vault.writeState.reasons.unavailable': 'مجلد المخزن غير متاح على هذا الجهاز.',
- 'vault.writeState.reasons.not_directory': 'مسار المخزن ليس دليلاً.',
- 'vault.writeState.reasons.empty_path': 'مسار مجلد المخزن فارغ.',
- 'vault.syncingProgress': 'جارٍ المزامنة… {ingested}/{total}',
- 'vault.removing': 'جارٍ الإزالة...',
- 'vault.relative.sec': 'قبل {count}s',
- 'vault.relative.min': 'قبل {count}m',
- 'vault.relative.hr': 'قبل {count} قبل',
- 'vault.relative.day': 'قبل {count}d',
- 'vault.openButton': 'فتح',
- 'vault.openSuccess': 'تم الفتح في Obsidian',
- 'vault.openFallback': 'لم يتم العثور على Obsidian — تم الفتح في مدير الملفات',
- 'vault.openError': 'تعذر فتح المخزن',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 دقائق',
'subconscious.interval.tenMinutes': '10 دقائق',
diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts
index 9ec2d1481..694a2b66c 100644
--- a/app/src/lib/i18n/bn.ts
+++ b/app/src/lib/i18n/bn.ts
@@ -267,6 +267,7 @@ const messages: TranslationMap = {
'memory.empty':
'এখনো কোনো মেমোরি নেই। আপনি যত ইন্টারঅ্যাক্ট করবেন, মেমোরি স্বয়ংক্রিয়ভাবে তৈরি হবে।',
'memory.tab.memory': 'মেমোরি',
+ 'memory.tab.memoryTree': 'মেমোরি ট্রি',
'memory.tab.tasks': 'এজেন্ট টাস্ক',
'memory.tab.tasksDescription':
'টাস্ক তৈরি করুন এবং ট্র্যাক করুন — আপনার নিজের কাজের তালিকা এবং এজেন্টরা কথোপকথন জুড়ে যে বোর্ডগুলি তৈরি করে।',
@@ -1902,6 +1903,7 @@ const messages: TranslationMap = {
'graph.children': 'চাইল্ড',
'graph.clickToOpenObsidian': 'Obsidian-এ খুলতে ক্লিক করুন',
'graph.person': 'ব্যক্তি',
+ 'graph.resetView': 'ভিউ রিসেট করুন',
'modal.dontShowAgain': 'একই ধরনের পরামর্শ আর দেখাবেন না',
'reflections.loading': 'প্রতিফলন লোড হচ্ছে...',
'reflections.empty': 'এখনো কোনো প্রতিফলন নেই',
@@ -4092,61 +4094,6 @@ const messages: TranslationMap = {
'Composio ইন্টিগ্রেশন ট্রিগারের জন্য AI ট্রাইজ সেটিংস কনফিগার করুন',
'memory.sourceFilterAria': 'উত্স দ্বারা ফিল্টার',
'calls.comingSoonDescription': 'AI-সহায়তা কলগুলি শীঘ্রই আসছে৷ সাথে থাকুন।',
- 'vault.title': 'নলেজ ভল্ট',
- 'vault.description':
- 'একটি স্থানীয় ফোল্ডার নির্দেশ করুন; ফাইলগুলি চাঙ্ক করা হয় এবং মেমোরিতে মিরর করা হয়।',
- 'vault.add': 'ভল্ট যোগ করুন',
- 'vault.added': 'ভল্ট যোগ করা হয়েছে',
- 'vault.createdMessage': 'তৈরি করা হয়েছে "{name}"। ইনজেস্ট করতে {sync} এ ক্লিক করুন।',
- 'vault.couldNotAdd': 'ভল্ট যোগ করা যায়নি',
- 'vault.syncFailed': 'সিঙ্ক ব্যর্থ হয়েছে',
- 'vault.syncFailedFor': '"{name}"-এর জন্য সিঙ্ক ব্যর্থ হয়েছে',
- 'vault.syncFailedFiles': 'ফাইল __000 এর জন্য সিঙ্ক ব্যর্থ হয়েছে',
- 'vault.syncedTitle': 'সিঙ্ক হয়েছে "{name}"',
- 'vault.syncSummary': 'ইনজেস্ট {ingested}, অপরিবর্তিত {unchanged}, সরানো {removed}',
- 'vault.syncSummaryFailed': ', ব্যর্থ হয়েছে {count}',
- 'vault.syncSummarySkipped': '__PH এড়িয়ে গেছে',
- 'vault.syncSummaryDuration': ' · {seconds}s',
- 'vault.confirmRemovePurge':
- 'ভল্ট "{name}" সরিয়ে দেবেন?\n\nOK ক্লিক করুন এর মেমোরিও মুছে ফেলতে (সমস্ত {count}টি ইনজেস্ট করা ডকুমেন্ট মুছুন)।\nডকুমেন্টগুলি মেমোরিতে রাখতে Cancel ক্লিক করুন।',
- 'vault.confirmRemove': 'সত্যিই ভল্ট "{name}" সরান?',
- 'vault.removed': 'ভল্ট সরানো হয়েছে',
- 'vault.removedPurgedMessage': '"{name}" সরানো হয়েছে এবং এর মেমরি পরিষ্কার করেছে৷',
- 'vault.removedKeptMessage': '"{name}" সরানো হয়েছে। নথি মেমরি রাখা.',
- 'vault.couldNotRemove': 'ভল্ট সরানো যায়নি',
- 'vault.name': 'নাম',
- 'vault.namePlaceholder': 'আমার গবেষণা নোট',
- 'vault.folderPath': 'ফোল্ডার পাথ (পরম)',
- 'vault.folderPathPlaceholder': '/Users/you/Documents/notes',
- 'vault.excludes': 'বাদ দেয় (কমা দ্বারা পৃথক করা সাবস্ট্রিং, ঐচ্ছিক)',
- 'vault.excludesPlaceholder': 'drafts/, .secret',
- 'vault.creating': 'তৈরি করা হচ্ছে...',
- 'vault.create': 'ভল্ট তৈরি করুন',
- 'vault.loading': 'ভল্ট লোড হচ্ছে...',
- 'vault.failedToLoad': 'ভল্ট লোড করতে ব্যর্থ হয়েছে: {error}',
- 'vault.empty': 'এখনো কোনো ভল্ট নেই। একটি ফোল্ডার ইনজেস্ট করা শুরু করতে উপরে একটি যোগ করুন।',
- 'vault.fileCount': '{count} ফাইল(গুলি)',
- 'vault.syncedRelative': 'সিঙ্ক করা হয়েছে {time}',
- 'vault.neverSynced': 'কখনই সিঙ্ক করা হয়নি',
- 'vault.writeState.writable': 'লেখার যোগ্য',
- 'vault.writeState.read_only': 'শুধু-পঠন',
- 'vault.writeState.unavailable': 'অনুপলব্ধ',
- 'vault.writeState.unknownReason': 'লেখার অবস্থা অজানা।',
- 'vault.writeState.reasons.writable': 'অনুমোদিত Markdown/wiki লেখা এই ভল্টে সংরক্ষণ করা যাবে।',
- 'vault.writeState.reasons.read_only': 'এই ডিভাইসে ভল্ট ফোল্ডারটি শুধু-পঠন।',
- 'vault.writeState.reasons.unavailable': 'এই ডিভাইসে ভল্ট ফোল্ডারটি উপলব্ধ নয়।',
- 'vault.writeState.reasons.not_directory': 'ভল্ট পাথটি কোনো ফোল্ডার নয়।',
- 'vault.writeState.reasons.empty_path': 'ভল্ট ফোল্ডার পাথ খালি।',
- 'vault.syncingProgress': 'সিঙ্ক হচ্ছে... {ingested}/{total}',
- 'vault.removing': 'সরানো হচ্ছে...',
- 'vault.relative.sec': '{count}সেকেন্ড আগে',
- 'vault.relative.min': '{count}মি আগে',
- 'vault.relative.hr': '{count}সেকেন্ড আগে',
- 'vault.relative.day': '{count}দিন আগে',
- 'vault.openButton': 'Open',
- 'vault.openSuccess': 'Opened in Obsidian',
- 'vault.openFallback': 'Obsidian not found — opened in file manager',
- 'vault.openError': "Couldn't open vault",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 মিনিট',
'subconscious.interval.tenMinutes': '10 মিনিট',
diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts
index b3ae7764b..5b2907a8e 100644
--- a/app/src/lib/i18n/de.ts
+++ b/app/src/lib/i18n/de.ts
@@ -276,6 +276,7 @@ const messages: TranslationMap = {
'memory.empty':
'Noch keine Erinnerungen. Erinnerungen werden automatisch erstellt, während du interagierst.',
'memory.tab.memory': 'Erinnerung',
+ 'memory.tab.memoryTree': 'Erinnerungsbaum',
'memory.tab.tasks': 'Agent-Aufgaben',
'memory.tab.tasksDescription':
'Aufgaben erstellen und verfolgen – eigene To-dos sowie die Boards, die Agenten in Gesprächen anlegen.',
@@ -1950,6 +1951,7 @@ const messages: TranslationMap = {
'graph.children': 'Kinder',
'graph.clickToOpenObsidian': 'Klicke hier, um in Obsidian zu öffnen',
'graph.person': 'Person',
+ 'graph.resetView': 'Ansicht zurücksetzen',
'modal.dontShowAgain': 'Ähnliche Vorschläge nicht anzeigen',
'reflections.loading': 'Reflexionen werden geladen...',
'reflections.empty': 'Noch keine Überlegungen',
@@ -4205,62 +4207,6 @@ const messages: TranslationMap = {
'Konfiguriere KI-Triage-Einstellungen für Composio-Integrationsauslöser',
'memory.sourceFilterAria': 'Nach Quelle filtern',
'calls.comingSoonDescription': 'KI-unterstützte Anrufe folgen in Kürze. Bleiben Sie dran.',
- 'vault.title': 'Wissensdepots',
- 'vault.description':
- 'Lokalen Ordner angeben; Dateien werden in Fragmente aufgeteilt und in den Speicher gespiegelt.',
- 'vault.add': 'Tresor hinzufügen',
- 'vault.added': 'Tresor hinzugefügt',
- 'vault.createdMessage': 'Erstellt „{name}“. Klicken Sie zum Aufnehmen auf {sync}.',
- 'vault.couldNotAdd': 'Tresor konnte nicht hinzugefügt werden',
- 'vault.syncFailed': 'Synchronisierung fehlgeschlagen',
- 'vault.syncFailedFor': 'Synchronisierung für „{name}“ fehlgeschlagen',
- 'vault.syncFailedFiles': '{count}-Datei(en) fehlgeschlagen',
- 'vault.syncedTitle': 'Synchronisierung „{name}“ fehlgeschlagen',
- 'vault.syncSummary': 'Aufgenommen {ingested}, unverändert {unchanged}, entfernt {removed}',
- 'vault.syncSummaryFailed': ', fehlgeschlagen {count}',
- 'vault.syncSummarySkipped': ', übersprungen {count}',
- 'vault.syncSummaryDuration': '· {seconds}s',
- 'vault.confirmRemovePurge':
- 'Vault "{name}" entfernen?\n\nOK klicken, um auch den Speicher zu löschen (alle {count} aufgenommenen Dokument(e) entfernen).\nAbbrechen klicken, um die Dokumente im Speicher zu behalten.',
- 'vault.confirmRemove': 'Tresor „{name}“ wirklich entfernen?',
- 'vault.removed': 'Tresor entfernt',
- 'vault.removedPurgedMessage': '„{name}“ entfernt und seinen Speicher geleert.',
- 'vault.removedKeptMessage': '„{name}“ entfernt. Dokumente im Gedächtnis behalten.',
- 'vault.couldNotRemove': 'Tresor konnte nicht entfernt werden.',
- 'vault.name': 'Name',
- 'vault.namePlaceholder': 'Meine Forschungsnotizen',
- 'vault.folderPath': 'Ordnerpfad (absolut)',
- 'vault.folderPathPlaceholder': '/Benutzer/Sie/Dokumente/Notizen',
- 'vault.excludes': 'Schließt aus (durch Kommas getrennte Teilzeichenfolgen, optional)',
- 'vault.excludesPlaceholder': 'drafts/, .secret',
- 'vault.creating': 'Wird erstellt…',
- 'vault.create': 'Tresor erstellen',
- 'vault.loading': 'Wird geladen Tresore…',
- 'vault.failedToLoad': 'Tresore konnten nicht geladen werden: {error}',
- 'vault.empty': 'Noch keine Vaults. Oben einen hinzufügen, um einen Ordner aufzunehmen.',
- 'vault.fileCount': '{count} Datei(en)',
- 'vault.syncedRelative': 'synchronisiert {time}',
- 'vault.neverSynced': 'nie synchronisiert',
- 'vault.writeState.writable': 'Beschreibbar',
- 'vault.writeState.read_only': 'Schreibgeschützt',
- 'vault.writeState.unavailable': 'Nicht verfügbar',
- 'vault.writeState.unknownReason': 'Schreibstatus ist unbekannt.',
- 'vault.writeState.reasons.writable':
- 'Genehmigte Markdown-/Wiki-Schreibvorgänge können in diesem Tresor gespeichert werden.',
- 'vault.writeState.reasons.read_only': 'Der Tresorordner ist auf diesem Gerät schreibgeschützt.',
- 'vault.writeState.reasons.unavailable': 'Der Tresorordner ist auf diesem Gerät nicht verfügbar.',
- 'vault.writeState.reasons.not_directory': 'Der Tresorpfad ist kein Ordner.',
- 'vault.writeState.reasons.empty_path': 'Der Tresorordnerpfad ist leer.',
- 'vault.syncingProgress': 'Synchronisierung… {ingested}/{total}',
- 'vault.removing': 'Entfernen…',
- 'vault.relative.sec': 'vor {count}s',
- 'vault.relative.min': 'vor {count}m',
- 'vault.relative.hr': 'vor {count}h',
- 'vault.relative.day': 'vor {count}d',
- 'vault.openButton': 'Open',
- 'vault.openSuccess': 'Opened in Obsidian',
- 'vault.openFallback': 'Obsidian not found — opened in file manager',
- 'vault.openError': "Couldn't open vault",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 Min.',
'subconscious.interval.tenMinutes': '10 Min.',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index 2a61c636f..a5e3ad69f 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -292,6 +292,7 @@ const en: TranslationMap = {
'memory.noResults': 'No memories found',
'memory.empty': 'No memories yet. Memories are created automatically as you interact.',
'memory.tab.memory': 'Memory',
+ 'memory.tab.memoryTree': 'Memory Tree',
'memory.tab.tasks': 'Agent Tasks',
'memory.tab.tasksDescription':
'Create and track tasks — your own to-dos plus the boards your agents build across conversations.',
@@ -2099,6 +2100,7 @@ const en: TranslationMap = {
'graph.children': 'children',
'graph.clickToOpenObsidian': 'Click to open in Obsidian',
'graph.person': 'Person',
+ 'graph.resetView': 'Reset view',
// Modal
'modal.dontShowAgain': "Don't show similar suggestions",
@@ -4362,60 +4364,6 @@ const en: TranslationMap = {
'Configure AI triage settings for Composio integration triggers',
'memory.sourceFilterAria': 'Filter by source',
'calls.comingSoonDescription': 'AI-assisted calls are coming soon. Stay tuned.',
- 'vault.title': 'Knowledge vaults (Experimental)',
- 'vault.description': 'Point at a local folder; files are chunked and mirrored into memory.',
- 'vault.add': 'Add vault',
- 'vault.added': 'Vault added',
- 'vault.createdMessage': 'Created "{name}". Click {sync} to ingest.',
- 'vault.couldNotAdd': 'Could not add vault',
- 'vault.syncFailed': 'Sync failed',
- 'vault.syncFailedFor': 'Sync failed for "{name}"',
- 'vault.syncFailedFiles': 'Failed {count} file(s)',
- 'vault.syncedTitle': 'Synced "{name}"',
- 'vault.syncSummary': 'Ingested {ingested}, unchanged {unchanged}, removed {removed}',
- 'vault.syncSummaryFailed': ', failed {count}',
- 'vault.syncSummarySkipped': ', skipped {count}',
- 'vault.syncSummaryDuration': ' · {seconds}s',
- 'vault.confirmRemovePurge':
- 'Remove vault "{name}"?\n\nClick OK to also purge its memory (delete all {count} ingested document(s)).\nClick Cancel to keep the documents in memory.',
- 'vault.confirmRemove': 'Really remove vault "{name}"?',
- 'vault.removed': 'Vault removed',
- 'vault.removedPurgedMessage': 'Removed "{name}" and purged its memory.',
- 'vault.removedKeptMessage': 'Removed "{name}". Documents kept in memory.',
- 'vault.couldNotRemove': 'Could not remove vault',
- 'vault.name': 'Name',
- 'vault.namePlaceholder': 'My research notes',
- 'vault.folderPath': 'Folder path (absolute)',
- 'vault.folderPathPlaceholder': '/Users/you/Documents/notes',
- 'vault.excludes': 'Excludes (comma-separated substrings, optional)',
- 'vault.excludesPlaceholder': 'drafts/, .secret',
- 'vault.creating': 'Creating…',
- 'vault.create': 'Create vault',
- 'vault.loading': 'Loading vaults…',
- 'vault.failedToLoad': 'Failed to load vaults: {error}',
- 'vault.empty': 'No vaults yet. Add one above to start ingesting a folder.',
- 'vault.fileCount': '{count} file(s)',
- 'vault.syncedRelative': 'synced {time}',
- 'vault.neverSynced': 'never synced',
- 'vault.writeState.writable': 'Writable',
- 'vault.writeState.read_only': 'Read-only',
- 'vault.writeState.unavailable': 'Unavailable',
- 'vault.writeState.unknownReason': 'Write state is unknown.',
- 'vault.writeState.reasons.writable': 'Approved markdown/wiki writes can be saved in this vault.',
- 'vault.writeState.reasons.read_only': 'Vault folder is read-only on this device.',
- 'vault.writeState.reasons.unavailable': 'Vault folder is not available on this device.',
- 'vault.writeState.reasons.not_directory': 'Vault path is not a directory.',
- 'vault.writeState.reasons.empty_path': 'Vault folder path is empty.',
- 'vault.syncingProgress': 'Syncing… {ingested}/{total}',
- 'vault.removing': 'Removing…',
- 'vault.relative.sec': '{count}s ago',
- 'vault.relative.min': '{count}m ago',
- 'vault.relative.hr': '{count}h ago',
- 'vault.relative.day': '{count}d ago',
- 'vault.openButton': 'Open',
- 'vault.openSuccess': 'Opened in Obsidian',
- 'vault.openFallback': 'Obsidian not found — opened in file manager',
- 'vault.openError': "Couldn't open vault",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 min',
'subconscious.interval.tenMinutes': '10 min',
diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts
index 42d9faf05..67113cb24 100644
--- a/app/src/lib/i18n/es.ts
+++ b/app/src/lib/i18n/es.ts
@@ -276,6 +276,7 @@ const messages: TranslationMap = {
'memory.noResults': 'No se encontraron recuerdos',
'memory.empty': 'Sin recuerdos aún. Los recuerdos se crean automáticamente mientras interactúas.',
'memory.tab.memory': 'Memoria',
+ 'memory.tab.memoryTree': 'Árbol de memoria',
'memory.tab.tasks': 'Tareas del agente',
'memory.tab.tasksDescription':
'Crea y realiza un seguimiento de tareas: tus propios pendientes más los tableros que tus agentes crean a lo largo de las conversaciones.',
@@ -1942,6 +1943,7 @@ const messages: TranslationMap = {
'graph.children': 'hijos',
'graph.clickToOpenObsidian': 'Clic para abrir en Obsidian',
'graph.person': 'Persona',
+ 'graph.resetView': 'Restablecer vista',
'modal.dontShowAgain': 'No mostrar sugerencias similares',
'reflections.loading': 'Cargando reflexiones...',
'reflections.empty': 'Sin reflexiones aún',
@@ -4171,64 +4173,6 @@ const messages: TranslationMap = {
'Configurar los ajustes de clasificación de IA para los activadores de integración Composio',
'memory.sourceFilterAria': 'Filtrar por fuente',
'calls.comingSoonDescription': 'Las llamadas asistidas por IA llegarán pronto. Mantente atento.',
- 'vault.title': 'Bóvedas de conocimiento',
- 'vault.description':
- 'Apunta a una carpeta local; los archivos se fragmentan y se reflejan en la memoria.',
- 'vault.add': 'Agregar bóveda',
- 'vault.added': 'Bóveda agregada',
- 'vault.createdMessage': 'Creado "{name}". Haga clic en {sync} para ingerir.',
- 'vault.couldNotAdd': 'No se pudo agregar la bóveda',
- 'vault.syncFailed': 'Error de sincronización',
- 'vault.syncFailedFor': 'Error de sincronización para "{name}"',
- 'vault.syncFailedFiles': 'Archivo(s) {count} fallidos',
- 'vault.syncedTitle': 'Sincronizado "{name}"',
- 'vault.syncSummary': 'Ingerido {ingested}, sin cambios {unchanged}, eliminado {removed}',
- 'vault.syncSummaryFailed': ', falló {count}',
- 'vault.syncSummarySkipped': ', omitido {count}',
- 'vault.syncSummaryDuration': ' · {seconds}s',
- 'vault.confirmRemovePurge':
- '¿Eliminar el almacén «{name}»?\n\nHaz clic en Aceptar para purgar también su memoria (eliminar todos los {count} documento(s) procesados).\nHaz clic en Cancelar para conservar los documentos en memoria.',
- 'vault.confirmRemove': '¿Realmente eliminar la bóveda "{name}"?',
- 'vault.removed': 'Bóveda eliminada',
- 'vault.removedPurgedMessage': 'Se eliminó "{name}" y se borró la memoria.',
- 'vault.removedKeptMessage': 'Se eliminó "{name}". Documentos guardados en la memoria.',
- 'vault.couldNotRemove': 'No se pudo eliminar la bóveda',
- 'vault.name': 'Nombre',
- 'vault.namePlaceholder': 'mis notas de investigacion',
- 'vault.folderPath': 'Ruta de la carpeta (absoluta)',
- 'vault.folderPathPlaceholder': '/Usuarios/usted/Documentos/notas',
- 'vault.excludes': 'Excluye (subcadenas separadas por comas, opcional)',
- 'vault.excludesPlaceholder': 'borradores/, .secreto',
- 'vault.creating': 'Creando…',
- 'vault.create': 'Crear bóveda',
- 'vault.loading': 'Cargando bóvedas…',
- 'vault.failedToLoad': 'No se pudieron cargar las bóvedas: {error}',
- 'vault.empty': 'Aún no hay bóvedas. Agregue uno arriba para comenzar a ingerir una carpeta.',
- 'vault.fileCount': '{count} archivo(s)',
- 'vault.syncedRelative': 'sincronizado {time}',
- 'vault.neverSynced': 'nunca sincronizado',
- 'vault.writeState.writable': 'Editable',
- 'vault.writeState.read_only': 'Solo lectura',
- 'vault.writeState.unavailable': 'No disponible',
- 'vault.writeState.unknownReason': 'Se desconoce el estado de escritura.',
- 'vault.writeState.reasons.writable':
- 'Las escrituras Markdown/wiki aprobadas se pueden guardar en esta bóveda.',
- 'vault.writeState.reasons.read_only':
- 'La carpeta de la bóveda es de solo lectura en este dispositivo.',
- 'vault.writeState.reasons.unavailable':
- 'La carpeta de la bóveda no está disponible en este dispositivo.',
- 'vault.writeState.reasons.not_directory': 'La ruta de la bóveda no es una carpeta.',
- 'vault.writeState.reasons.empty_path': 'La ruta de la carpeta de la bóveda está vacía.',
- 'vault.syncingProgress': 'Sincronizando... {ingested}/{total}',
- 'vault.removing': 'Eliminando…',
- 'vault.relative.sec': 'Hace {count}s',
- 'vault.relative.min': 'Hace {count}m',
- 'vault.relative.hr': 'Hace {count}h',
- 'vault.relative.day': 'Hace {count}d',
- 'vault.openButton': 'Open',
- 'vault.openSuccess': 'Opened in Obsidian',
- 'vault.openFallback': 'Obsidian not found — opened in file manager',
- 'vault.openError': "Couldn't open vault",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 minutos',
'subconscious.interval.tenMinutes': '10 minutos',
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index d76e393c0..4012a3f3f 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -275,6 +275,7 @@ const messages: TranslationMap = {
'memory.empty':
"Aucun souvenir pour l'instant. Les souvenirs sont créés automatiquement au fil de tes interactions.",
'memory.tab.memory': 'Mémoire',
+ 'memory.tab.memoryTree': 'Arbre de mémoire',
'memory.tab.tasks': "Tâches de l'agent",
'memory.tab.tasksDescription':
'Créez et suivez des tâches — vos propres listes de choses à faire ainsi que les tableaux que vos agents construisent au fil des conversations.',
@@ -1948,6 +1949,7 @@ const messages: TranslationMap = {
'graph.children': 'enfants',
'graph.clickToOpenObsidian': 'Clique pour ouvrir dans Obsidian',
'graph.person': 'Personne',
+ 'graph.resetView': 'Réinitialiser la vue',
'modal.dontShowAgain': 'Ne plus afficher de suggestions similaires',
'reflections.loading': 'Chargement des réflexions…',
'reflections.empty': 'Pas encore de réflexions',
@@ -4186,65 +4188,6 @@ const messages: TranslationMap = {
"Configurez les paramètres de triage IA pour les déclencheurs d'intégration Composio",
'memory.sourceFilterAria': 'Filtrer par source',
'calls.comingSoonDescription': "Les appels assistés par IA arrivent bientôt. Restez à l'écoute.",
- 'vault.title': 'Coffres de connaissances',
- 'vault.description':
- 'Pointez vers un dossier local ; les fichiers sont découpés et mis en miroir dans la mémoire.',
- 'vault.add': 'Ajouter un coffre-fort',
- 'vault.added': 'Coffre-fort ajouté',
- 'vault.createdMessage': 'Création de "{name}". Cliquez sur {sync} pour ingérer.',
- 'vault.couldNotAdd': "Impossible d'ajouter le coffre",
- 'vault.syncFailed': 'Échec de la synchronisation',
- 'vault.syncFailedFor': 'Échec de la synchronisation pour « {name} »',
- 'vault.syncFailedFiles': 'Échec de {count} fichier(s)',
- 'vault.syncedTitle': '"{name}" synchronisé',
- 'vault.syncSummary': '{ingested} ingéré, {unchanged} inchangé, supprimé {removed}',
- 'vault.syncSummaryFailed': ', échec {count}',
- 'vault.syncSummarySkipped': ', ignoré {count}',
- 'vault.syncSummaryDuration': '· {seconds}s',
- 'vault.confirmRemovePurge':
- 'Supprimer le coffre « {name} » ?\n\nCliquez sur OK pour purger également sa mémoire (supprimer les {count} document(s) ingéré(s)).\nCliquez sur Annuler pour conserver les documents en mémoire.',
- 'vault.confirmRemove': 'Voulez-vous vraiment supprimer le coffre-fort « {name} » ?',
- 'vault.removed': 'Vault supprimé',
- 'vault.removedPurgedMessage': 'Supprimé "{name}" et purgé sa mémoire.',
- 'vault.removedKeptMessage': 'Suppression de "{name}". Documents conservés en mémoire.',
- 'vault.couldNotRemove': 'Impossible de supprimer le coffre-fort',
- 'vault.name': 'Nom',
- 'vault.namePlaceholder': 'Mes notes de recherche',
- 'vault.folderPath': 'Chemin du dossier (absolu)',
- 'vault.folderPathPlaceholder': '/Utilisateurs/vous/Documents/notes',
- 'vault.excludes': 'Exclut (sous-chaînes séparées par des virgules, facultatif)',
- 'vault.excludesPlaceholder': 'drafts/, .secret',
- 'vault.creating': 'Création…',
- 'vault.create': 'Créer un coffre-fort',
- 'vault.loading': 'Chargement coffres-forts…',
- 'vault.failedToLoad': 'Échec du chargement des coffres-forts : {error}',
- 'vault.empty':
- "Aucun coffre pour l'instant. Ajoutez-en un ci-dessus pour commencer à ingérer un dossier.",
- 'vault.fileCount': '{count} fichier(s)',
- 'vault.syncedRelative': 'synchronisés {time}',
- 'vault.neverSynced': 'jamais synchronisés',
- 'vault.writeState.writable': 'Modifiable',
- 'vault.writeState.read_only': 'Lecture seule',
- 'vault.writeState.unavailable': 'Indisponible',
- 'vault.writeState.unknownReason': "L'état d'écriture est inconnu.",
- 'vault.writeState.reasons.writable':
- 'Les écritures Markdown/wiki approuvées peuvent être enregistrées dans ce coffre.',
- 'vault.writeState.reasons.read_only':
- 'Le dossier du coffre est en lecture seule sur cet appareil.',
- 'vault.writeState.reasons.unavailable':
- "Le dossier du coffre n'est pas disponible sur cet appareil.",
- 'vault.writeState.reasons.not_directory': "Le chemin du coffre n'est pas un dossier.",
- 'vault.writeState.reasons.empty_path': 'Le chemin du dossier du coffre est vide.',
- 'vault.syncingProgress': 'Synchronisation… {ingested}/{total}',
- 'vault.removing': 'Suppression…',
- 'vault.relative.sec': 'il y a {count}s',
- 'vault.relative.min': 'il y a {count}m',
- 'vault.relative.hr': 'il y a {count}h',
- 'vault.relative.day': '{count}d',
- 'vault.openButton': 'Ouvrir',
- 'vault.openSuccess': 'Ouvert dans Obsidian',
- 'vault.openFallback': 'Obsidian introuvable — ouvert dans le gestionnaire de fichiers',
- 'vault.openError': 'Impossible d\u0027ouvrir le coffre',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 min',
'subconscious.interval.tenMinutes': '10 min',
diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts
index 5c149a403..36d8e1c85 100644
--- a/app/src/lib/i18n/hi.ts
+++ b/app/src/lib/i18n/hi.ts
@@ -266,6 +266,7 @@ const messages: TranslationMap = {
'memory.noResults': 'कोई मेमोरी नहीं मिली',
'memory.empty': 'अभी कोई मेमोरी नहीं है। बातचीत के दौरान मेमोरी अपने आप बनती है।',
'memory.tab.memory': 'मेमोरी',
+ 'memory.tab.memoryTree': 'मेमोरी ट्री',
'memory.tab.tasks': 'एजेंट कार्य',
'memory.tab.tasksDescription':
'कार्य बनाएं और ट्रैक करें — आपके अपने टू-डू और साथ ही वे बोर्ड जो आपके एजेंट वार्तालापों में बनाते हैं।',
@@ -1902,6 +1903,7 @@ const messages: TranslationMap = {
'graph.children': 'चाइल्ड',
'graph.clickToOpenObsidian': 'Obsidian में खोलने के लिए क्लिक करें',
'graph.person': 'व्यक्ति',
+ 'graph.resetView': 'व्यू रीसेट करें',
'modal.dontShowAgain': 'ऐसे सुझाव फिर न दिखाएं',
'reflections.loading': 'रिफ्लेक्शन लोड हो रहे हैं...',
'reflections.empty': 'अभी कोई रिफ्लेक्शन नहीं',
@@ -4101,61 +4103,6 @@ const messages: TranslationMap = {
'Composio एकीकरण ट्रिगर के लिए AI ट्राइएज सेटिंग्स कॉन्फ़िगर करें',
'memory.sourceFilterAria': 'स्रोत के अनुसार फ़िल्टर करें',
'calls.comingSoonDescription': 'एआई-सहायक कॉल जल्द ही आ रही हैं। बने रहें।',
- 'vault.title': 'ज्ञान भंडार',
- 'vault.description':
- 'कोई स्थानीय फ़ोल्डर चुनें; फ़ाइलें खंडों में विभाजित होकर मेमोरी में मिरर हो जाती हैं।',
- 'vault.add': 'तिजोरी जोड़ें',
- 'vault.added': 'तिजोरी जोड़ी गई',
- 'vault.createdMessage': '"{name}" बनाया गया। निगलने के लिए {sync} पर क्लिक करें।',
- 'vault.couldNotAdd': 'वॉल्ट नहीं जोड़ा जा सका',
- 'vault.syncFailed': 'समन्वयन विफल',
- 'vault.syncFailedFor': '"{name}" के लिए समन्वयन विफल',
- 'vault.syncFailedFiles': 'विफल {count} फ़ाइल(फ़ाइलें)',
- 'vault.syncedTitle': 'समन्वयित "{name}"',
- 'vault.syncSummary': 'अंतर्ग्रहण {ingested}, अपरिवर्तित {unchanged}, हटाया गया {removed}',
- 'vault.syncSummaryFailed': ', विफल {count}',
- 'vault.syncSummarySkipped': ', छोड़ दिया गया {count}',
- 'vault.syncSummaryDuration': ' · {seconds}s',
- 'vault.confirmRemovePurge':
- 'वॉल्ट "{name}" हटाएं?\n\nOK क्लिक करें तो इसकी मेमोरी भी साफ़ हो जाएगी (सभी {count} इंजेस्टेड दस्तावेज़ हटा दिए जाएंगे)।\nCancel क्लिक करें तो दस्तावेज़ मेमोरी में रहेंगे।',
- 'vault.confirmRemove': 'वास्तव में वॉल्ट "{name}" को हटा दें?',
- 'vault.removed': 'तिजोरी हटा दी गई',
- 'vault.removedPurgedMessage': '"{name}" को हटा दिया गया और इसकी मेमोरी को शुद्ध कर दिया गया।',
- 'vault.removedKeptMessage': '"{name}" हटा दिया गया। दस्तावेज़ स्मृति में रखे गए.',
- 'vault.couldNotRemove': 'तिजोरी नहीं निकाली जा सकी',
- 'vault.name': 'नाम',
- 'vault.namePlaceholder': 'मेरे शोध नोट्स',
- 'vault.folderPath': 'फ़ोल्डर पथ (पूर्ण)',
- 'vault.folderPathPlaceholder': '/उपयोगकर्ता/आप/दस्तावेज़/नोट्स',
- 'vault.excludes': 'बहिष्कृत (अल्पविराम से अलग किए गए सबस्ट्रिंग, वैकल्पिक)',
- 'vault.excludesPlaceholder': 'ड्राफ्ट/, .गुप्त',
- 'vault.creating': 'बनाया जा रहा है...',
- 'vault.create': 'तिजोरी बनाएं',
- 'vault.loading': 'तिजोरी लोड हो रही है...',
- 'vault.failedToLoad': 'वॉल्ट लोड करने में विफल: {error}',
- 'vault.empty': 'अभी कोई वॉल्ट नहीं है। कोई फ़ोल्डर इंजेस्ट करना शुरू करने के लिए ऊपर एक जोड़ें।',
- 'vault.fileCount': '{count}फ़ाइलें',
- 'vault.syncedRelative': 'समन्वयित {time}',
- 'vault.neverSynced': 'कभी समन्वयित नहीं किया गया',
- 'vault.writeState.writable': 'लिखने योग्य',
- 'vault.writeState.read_only': 'केवल पढ़ने योग्य',
- 'vault.writeState.unavailable': 'उपलब्ध नहीं',
- 'vault.writeState.unknownReason': 'लिखने की स्थिति अज्ञात है.',
- 'vault.writeState.reasons.writable': 'स्वीकृत Markdown/wiki लेखन इस वॉल्ट में सहेजे जा सकते हैं।',
- 'vault.writeState.reasons.read_only': 'इस डिवाइस पर वॉल्ट फ़ोल्डर केवल पढ़ने योग्य है।',
- 'vault.writeState.reasons.unavailable': 'इस डिवाइस पर वॉल्ट फ़ोल्डर उपलब्ध नहीं है।',
- 'vault.writeState.reasons.not_directory': 'वॉल्ट पथ कोई फ़ोल्डर नहीं है।',
- 'vault.writeState.reasons.empty_path': 'वॉल्ट फ़ोल्डर पथ खाली है।',
- 'vault.syncingProgress': 'सिंक हो रहा है... {ingested}/{total}',
- 'vault.removing': 'हटाया जा रहा है...',
- 'vault.relative.sec': '{count}s पहले',
- 'vault.relative.min': '{count}m पहले',
- 'vault.relative.hr': '{count}h पहले',
- 'vault.relative.day': '{count}d पहले',
- 'vault.openButton': 'Open',
- 'vault.openSuccess': 'Opened in Obsidian',
- 'vault.openFallback': 'Obsidian not found — opened in file manager',
- 'vault.openError': "Couldn't open vault",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 मिनट',
'subconscious.interval.tenMinutes': '10 मिनट',
diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts
index c8542b0c4..46819010a 100644
--- a/app/src/lib/i18n/id.ts
+++ b/app/src/lib/i18n/id.ts
@@ -268,6 +268,7 @@ const messages: TranslationMap = {
'memory.noResults': 'Memori tidak ditemukan',
'memory.empty': 'Belum ada memori. Memori dibuat otomatis saat Anda berinteraksi.',
'memory.tab.memory': 'Memori',
+ 'memory.tab.memoryTree': 'Pohon Memori',
'memory.tab.tasks': 'Tugas Agen',
'memory.tab.tasksDescription':
'Buat dan lacak tugas — to-do Anda sendiri beserta papan yang dibangun agen Anda di berbagai percakapan.',
@@ -1905,6 +1906,7 @@ const messages: TranslationMap = {
'graph.children': 'anak',
'graph.clickToOpenObsidian': 'Klik untuk membuka di Obsidian',
'graph.person': 'Orang',
+ 'graph.resetView': 'Atur ulang tampilan',
'modal.dontShowAgain': 'Jangan tampilkan saran serupa',
'reflections.loading': 'Memuat refleksi...',
'reflections.empty': 'Belum ada refleksi',
@@ -4111,61 +4113,6 @@ const messages: TranslationMap = {
'Konfigurasikan pengaturan triase AI untuk pemicu integrasi Composio',
'memory.sourceFilterAria': 'Filter berdasarkan sumber',
'calls.comingSoonDescription': 'Panggilan dengan bantuan AI akan segera hadir. Pantau terus.',
- 'vault.title': 'Gudang pengetahuan',
- 'vault.description': 'Arahkan ke folder lokal; file dipotong dan dicerminkan ke dalam memori.',
- 'vault.add': 'Tambahkan brankas',
- 'vault.added': 'Vault ditambahkan',
- 'vault.createdMessage': 'Membuat "{name}". Klik {sync} untuk menyerap.',
- 'vault.couldNotAdd': 'Tidak dapat menambahkan vault',
- 'vault.syncFailed': 'Sinkronisasi gagal',
- 'vault.syncFailedFor': 'Sinkronisasi gagal untuk "{name}"',
- 'vault.syncFailedFiles': 'Gagal {count} file',
- 'vault.syncedTitle': 'Disinkronkan "{name}"',
- 'vault.syncSummary': 'Diserap {ingested}, tidak diubah {unchanged}, dihapus {removed}',
- 'vault.syncSummaryFailed': ', gagal {count}',
- 'vault.syncSummarySkipped': ', dilewati {count}',
- 'vault.syncSummaryDuration': '· {seconds}s',
- 'vault.confirmRemovePurge':
- 'Hapus vault "{name}"?\n\nKlik OK untuk juga menghapus memorinya (hapus semua {count} dokumen yang telah dimasukkan).\nKlik Batal untuk menyimpan dokumen di memori.',
- 'vault.confirmRemove': 'Benar-benar menghapus brankas "{name}"?',
- 'vault.removed': 'Vault dihapus',
- 'vault.removedPurgedMessage': 'Menghapus "{name}" dan menghapus memorinya.',
- 'vault.removedKeptMessage': 'Menghapus "{name}". Dokumen disimpan dalam memori.',
- 'vault.couldNotRemove': 'Tidak dapat menghapus vault',
- 'vault.name': 'Nama',
- 'vault.namePlaceholder': 'Catatan penelitian saya',
- 'vault.folderPath': 'Jalur folder (mutlak)',
- 'vault.folderPathPlaceholder': '/Pengguna/Anda/Dokumen/catatan',
- 'vault.excludes': 'Tidak termasuk (substring yang dipisahkan koma, opsional)',
- 'vault.excludesPlaceholder': 'draft/, .secret',
- 'vault.creating': 'Membuat…',
- 'vault.create': 'Membuat vault',
- 'vault.loading': 'Memuat vault…',
- 'vault.failedToLoad': 'Gagal memuat brankas: {error}',
- 'vault.empty': 'Belum ada brankas. Tambahkan satu di atas untuk mulai menyerap folder.',
- 'vault.fileCount': '{count} file',
- 'vault.syncedRelative': 'disinkronkan {time}',
- 'vault.neverSynced': 'tidak pernah disinkronkan',
- 'vault.writeState.writable': 'Dapat ditulis',
- 'vault.writeState.read_only': 'Hanya baca',
- 'vault.writeState.unavailable': 'Tidak tersedia',
- 'vault.writeState.unknownReason': 'Status tulis tidak diketahui.',
- 'vault.writeState.reasons.writable':
- 'Penulisan Markdown/wiki yang disetujui dapat disimpan di vault ini.',
- 'vault.writeState.reasons.read_only': 'Folder vault hanya baca di perangkat ini.',
- 'vault.writeState.reasons.unavailable': 'Folder vault tidak tersedia di perangkat ini.',
- 'vault.writeState.reasons.not_directory': 'Jalur vault bukan folder.',
- 'vault.writeState.reasons.empty_path': 'Jalur folder vault kosong.',
- 'vault.syncingProgress': 'Menyinkronkan… {ingested}/{total}',
- 'vault.removing': 'Menghapus…',
- 'vault.relative.sec': '{count}s yang lalu',
- 'vault.relative.min': '{count}m yang lalu',
- 'vault.relative.hr': '{count}h yang lalu',
- 'vault.relative.day': '{count}d yang lalu',
- 'vault.openButton': 'Buka',
- 'vault.openSuccess': 'Dibuka di Obsidian',
- 'vault.openFallback': 'Obsidian tidak ditemukan — dibuka di pengelola file',
- 'vault.openError': 'Tidak dapat membuka vault',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 menit',
'subconscious.interval.tenMinutes': '10 menit',
diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts
index abe89fd4c..0b2b68ab2 100644
--- a/app/src/lib/i18n/it.ts
+++ b/app/src/lib/i18n/it.ts
@@ -273,6 +273,7 @@ const messages: TranslationMap = {
'memory.empty':
'Nessuna memoria ancora. Le memorie vengono create automaticamente mentre interagisci.',
'memory.tab.memory': 'Memoria',
+ 'memory.tab.memoryTree': 'Albero della memoria',
'memory.tab.tasks': 'Attività agente',
'memory.tab.tasksDescription':
'Crea e monitora le attività — i tuoi to-do personali e le board create dagli agenti nelle conversazioni.',
@@ -1933,6 +1934,7 @@ const messages: TranslationMap = {
'graph.children': 'figli',
'graph.clickToOpenObsidian': 'Clic per aprire in Obsidian',
'graph.person': 'Persona',
+ 'graph.resetView': 'Reimposta vista',
'modal.dontShowAgain': 'Non mostrare suggerimenti simili',
'reflections.loading': 'Caricamento riflessioni...',
'reflections.empty': 'Nessuna riflessione',
@@ -4163,64 +4165,6 @@ const messages: TranslationMap = {
'memory.sourceFilterAria': 'Filtra per origine',
'calls.comingSoonDescription':
"Le chiamate assistite dall'IA sono in arrivo. Resta sintonizzato.",
- 'vault.title': 'Depositi di conoscenza',
- 'vault.description':
- 'Punta a una cartella locale; i file vengono suddivisi in blocchi e copiati nella memoria.',
- 'vault.add': 'Aggiungi deposito',
- 'vault.added': 'Deposito aggiunto',
- 'vault.createdMessage': 'Creato "{name}". Fai clic su {sync} per importare.',
- 'vault.couldNotAdd': 'Impossibile aggiungere il deposito',
- 'vault.syncFailed': 'Sincronizzazione non riuscita',
- 'vault.syncFailedFor': 'Sincronizzazione non riuscita per "{name}"',
- 'vault.syncFailedFiles': 'Impossibile {count} file',
- 'vault.syncedTitle': 'Sincronizzato "{name}"',
- 'vault.syncSummary': 'Importato {ingested}, invariato {unchanged}, rimosso {removed}',
- 'vault.syncSummaryFailed': ', non riuscito {count}',
- 'vault.syncSummarySkipped': ', saltato {count}',
- 'vault.syncSummaryDuration': '· {seconds}s',
- 'vault.confirmRemovePurge':
- 'Rimuovere il vault "{name}"?\n\nFai clic su OK per eliminare anche la sua memoria (elimina tutti i {count} documento/i caricati).\nFai clic su Annulla per mantenere i documenti in memoria.',
- 'vault.confirmRemove': 'Rimuovere davvero il vault "{name}"?',
- 'vault.removed': 'Vault rimosso',
- 'vault.removedPurgedMessage': 'Rimosso "{name}" e cancellata la sua memoria.',
- 'vault.removedKeptMessage': 'Rimosso "{name}". Documenti conservati in memoria.',
- 'vault.couldNotRemove': 'Impossibile rimuovere il deposito',
- 'vault.name': 'Nome',
- 'vault.namePlaceholder': 'Le mie note di ricerca',
- 'vault.folderPath': 'Percorso della cartella (assoluto)',
- 'vault.folderPathPlaceholder': '/Utenti/tu/Documenti/note',
- 'vault.excludes': 'Esclude (sottostringhe separate da virgole, facoltativo)',
- 'vault.excludesPlaceholder': 'bozze/, .secret',
- 'vault.creating': 'Creazione…',
- 'vault.create': 'Crea deposito',
- 'vault.loading': 'Caricamento depositi…',
- 'vault.failedToLoad': 'Impossibile caricare i depositi: {error}',
- 'vault.empty': 'Nessun vault ancora. Aggiungine uno sopra per iniziare a caricare una cartella.',
- 'vault.fileCount': '{count} file',
- 'vault.syncedRelative': 'sincronizzato {time}',
- 'vault.neverSynced': 'mai sincronizzato',
- 'vault.writeState.writable': 'Scrivibile',
- 'vault.writeState.read_only': 'Sola lettura',
- 'vault.writeState.unavailable': 'Non disponibile',
- 'vault.writeState.unknownReason': 'Stato di scrittura sconosciuto.',
- 'vault.writeState.reasons.writable':
- 'Le scritture Markdown/wiki approvate possono essere salvate in questo deposito.',
- 'vault.writeState.reasons.read_only':
- 'La cartella del deposito è in sola lettura su questo dispositivo.',
- 'vault.writeState.reasons.unavailable':
- 'La cartella del deposito non è disponibile su questo dispositivo.',
- 'vault.writeState.reasons.not_directory': 'Il percorso del deposito non è una cartella.',
- 'vault.writeState.reasons.empty_path': 'Il percorso della cartella del deposito è vuoto.',
- 'vault.syncingProgress': 'Sincronizzazione… {ingested}/{total}',
- 'vault.removing': 'Rimozione in corso…',
- 'vault.relative.sec': '{count}s fa',
- 'vault.relative.min': '{count}m fa',
- 'vault.relative.hr': '{count}h fa',
- 'vault.relative.day': '{count}d fa',
- 'vault.openButton': 'Open',
- 'vault.openSuccess': 'Opened in Obsidian',
- 'vault.openFallback': 'Obsidian not found — opened in file manager',
- 'vault.openError': "Couldn't open vault",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 minuti',
'subconscious.interval.tenMinutes': '10 minuti',
diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts
index 855b0f4c4..da303403b 100644
--- a/app/src/lib/i18n/ko.ts
+++ b/app/src/lib/i18n/ko.ts
@@ -266,6 +266,7 @@ const messages: TranslationMap = {
'memory.noResults': '메모리를 찾을 수 없습니다',
'memory.empty': '아직 메모리가 없습니다. 메모리는 상호작용하면서 자동으로 생성됩니다.',
'memory.tab.memory': '메모리',
+ 'memory.tab.memoryTree': '메모리 트리',
'memory.tab.tasks': '에이전트 작업',
'memory.tab.tasksDescription':
'작업을 만들고 추적하세요 — 본인의 할 일 목록과 에이전트가 대화를 통해 구성한 보드가 모두 포함됩니다.',
@@ -1883,6 +1884,7 @@ const messages: TranslationMap = {
'graph.children': '자식',
'graph.clickToOpenObsidian': '클릭하여 Obsidian에서 열기',
'graph.person': '사람',
+ 'graph.resetView': '보기 초기화',
'modal.dontShowAgain': '비슷한 제안을 다시 표시하지 않기',
'reflections.loading': '반영을 불러오는 중...',
'reflections.empty': '아직 반영이 없습니다',
@@ -4062,60 +4064,6 @@ const messages: TranslationMap = {
'devOptions.menuComposioTriggersDesc': 'Composio 통합 트리거에 대한 AI 심사 설정 구성',
'memory.sourceFilterAria': '소스별 필터링',
'calls.comingSoonDescription': 'AI 지원 통화가 곧 제공됩니다. 기대해 주세요.',
- 'vault.title': '지식 보관소',
- 'vault.description': '로컬 폴더를 가리킵니다. 파일은 청크로 분할되어 메모리에 미러링됩니다.',
- 'vault.add': '저장소 추가',
- 'vault.added': '저장소 추가',
- 'vault.createdMessage': '"{name}"을(를) 생성했습니다. 수집하려면 {sync}을 클릭하세요.',
- 'vault.couldNotAdd': '볼트를 추가할 수 없습니다.',
- 'vault.syncFailed': '동기화 실패',
- 'vault.syncFailedFor': '"{name}"에 대한 동기화 실패',
- 'vault.syncFailedFiles': '{count} 파일 실패',
- 'vault.syncedTitle': '동기화됨 "{name}"',
- 'vault.syncSummary': '{ingested} 수집, {unchanged} 변경, {removed}',
- 'vault.syncSummaryFailed': '제거, {count}',
- 'vault.syncSummarySkipped': '실패, {count}',
- 'vault.syncSummaryDuration': '건너뛰었습니다. {seconds}s',
- 'vault.confirmRemovePurge':
- '볼트 "{name}"을(를) 제거하시겠습니까?\n\n확인을 클릭하면 수집된 문서 {count}개도 함께 삭제됩니다.\n취소를 클릭하면 문서가 메모리에 유지됩니다.',
- 'vault.confirmRemove': '"{name}" 볼트를 제거하시겠습니까?',
- 'vault.removed': 'Vault가 제거되었습니다.',
- 'vault.removedPurgedMessage': '"{name}"을(를) 제거하고 해당 메모리를 삭제했습니다.',
- 'vault.removedKeptMessage': '"{name}"을(를) 제거했습니다. 문서는 메모리에 보관됩니다.',
- 'vault.couldNotRemove': '볼트를 제거할 수 없습니다.',
- 'vault.name': '이름',
- 'vault.namePlaceholder': '내 연구 노트',
- 'vault.folderPath': '폴더 경로(절대)',
- 'vault.folderPathPlaceholder': '/사용자/당신/문서/메모',
- 'vault.excludes': '제외(쉼표로 구분된 하위 문자열, 선택 사항)',
- 'vault.excludesPlaceholder': '초안/, .secret',
- 'vault.creating': '생성 중…',
- 'vault.create': '볼트 생성',
- 'vault.loading': '볼트 로드 중…',
- 'vault.failedToLoad': '볼트 로드 실패: {error}',
- 'vault.empty': '아직 Vault가 없습니다. 폴더 수집을 시작하려면 위에 하나를 추가하세요.',
- 'vault.fileCount': '{count} 파일',
- 'vault.syncedRelative': '동기화됨 {time}',
- 'vault.neverSynced': '동기화되지 않음',
- 'vault.writeState.writable': '쓰기 가능',
- 'vault.writeState.read_only': '읽기 전용',
- 'vault.writeState.unavailable': '사용할 수 없음',
- 'vault.writeState.unknownReason': '쓰기 상태를 알 수 없습니다.',
- 'vault.writeState.reasons.writable': '승인된 Markdown/wiki 쓰기를 이 볼트에 저장할 수 있습니다.',
- 'vault.writeState.reasons.read_only': '이 장치에서 볼트 폴더는 읽기 전용입니다.',
- 'vault.writeState.reasons.unavailable': '이 장치에서 볼트 폴더를 사용할 수 없습니다.',
- 'vault.writeState.reasons.not_directory': '볼트 경로가 폴더가 아닙니다.',
- 'vault.writeState.reasons.empty_path': '볼트 폴더 경로가 비어 있습니다.',
- 'vault.syncingProgress': '동기화 중… {ingested}/{total}',
- 'vault.removing': '제거 중…',
- 'vault.relative.sec': '{count}초 전',
- 'vault.relative.min': '{count}분 전',
- 'vault.relative.hr': '{count}시간 전',
- 'vault.relative.day': '{count}일 전',
- 'vault.openButton': '열기',
- 'vault.openSuccess': 'Obsidian에서 열림',
- 'vault.openFallback': 'Obsidian을 찾을 수 없음 — 파일 관리자에서 열림',
- 'vault.openError': '볼트를 열 수 없습니다',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5분',
'subconscious.interval.tenMinutes': '10분',
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts
index 27c9d9435..621d08fdc 100644
--- a/app/src/lib/i18n/pl.ts
+++ b/app/src/lib/i18n/pl.ts
@@ -271,6 +271,7 @@ const messages: TranslationMap = {
'memory.empty':
'Brak wspomnień. Wspomnienia powstają automatycznie podczas korzystania z aplikacji.',
'memory.tab.memory': 'Pamięć',
+ 'memory.tab.memoryTree': 'Drzewo pamięci',
'memory.tab.tasks': 'Zadania agenta',
'memory.tab.tasksDescription':
'Twórz i śledź zadania — własne listy do zrobienia oraz tablice, które Twoi agenci budują w rozmowach.',
@@ -1923,6 +1924,7 @@ const messages: TranslationMap = {
'graph.children': 'dzieci',
'graph.clickToOpenObsidian': 'Kliknij, aby otworzyć w Obsidianie',
'graph.person': 'Osoba',
+ 'graph.resetView': 'Resetuj widok',
'modal.dontShowAgain': 'Nie pokazuj podobnych sugestii',
'reflections.loading': 'Wczytywanie refleksji...',
'reflections.empty': 'Brak refleksji',
@@ -4165,62 +4167,6 @@ const messages: TranslationMap = {
'Konfiguruj ustawienia klasyfikacji AI dla wyzwalaczy integracji Composio',
'memory.sourceFilterAria': 'Filtruj po źródle',
'calls.comingSoonDescription': 'Połączenia wspierane AI pojawią się wkrótce. Bądź na bieżąco.',
- 'vault.title': 'Skarbce wiedzy (Eksperymentalne)',
- 'vault.description':
- 'Wskaż lokalny folder; pliki zostaną podzielone i odzwierciedlone w pamięci.',
- 'vault.add': 'Dodaj skarbiec',
- 'vault.added': 'Skarbiec dodany',
- 'vault.createdMessage': 'Utworzono „{name}”. Kliknij {sync}, aby zaindeksować.',
- 'vault.couldNotAdd': 'Nie udało się dodać skarbca',
- 'vault.syncFailed': 'Synchronizacja nieudana',
- 'vault.syncFailedFor': 'Synchronizacja „{name}” nieudana',
- 'vault.syncFailedFiles': 'Nieudane pliki: {count}',
- 'vault.syncedTitle': 'Zsynchronizowano „{name}”',
- 'vault.syncSummary': 'Zaindeksowano: {ingested}, bez zmian: {unchanged}, usunięto: {removed}',
- 'vault.syncSummaryFailed': ', nieudane: {count}',
- 'vault.syncSummarySkipped': ', pominięte: {count}',
- 'vault.syncSummaryDuration': ' · {seconds} s',
- 'vault.confirmRemovePurge':
- 'Usunąć skarbiec „{name}”?\n\nKliknij OK, aby również wyczyścić jego pamięć (usunąć wszystkie {count} zaindeksowane dokumenty).\nKliknij Anuluj, aby zachować dokumenty w pamięci.',
- 'vault.confirmRemove': 'Na pewno usunąć skarbiec „{name}”?',
- 'vault.removed': 'Skarbiec usunięty',
- 'vault.removedPurgedMessage': 'Usunięto „{name}” i wyczyszczono pamięć.',
- 'vault.removedKeptMessage': 'Usunięto „{name}”. Dokumenty pozostają w pamięci.',
- 'vault.couldNotRemove': 'Nie udało się usunąć skarbca',
- 'vault.name': 'Nazwa',
- 'vault.namePlaceholder': 'Moje notatki badawcze',
- 'vault.folderPath': 'Ścieżka folderu (bezwzględna)',
- 'vault.folderPathPlaceholder': '/Users/ty/Documents/notatki',
- 'vault.excludes': 'Wykluczenia (po przecinku, opcjonalnie)',
- 'vault.excludesPlaceholder': 'wersje robocze/, .sekret',
- 'vault.creating': 'Tworzenie…',
- 'vault.create': 'Utwórz skarbiec',
- 'vault.loading': 'Ładowanie skarbców…',
- 'vault.failedToLoad': 'Nie udało się załadować skarbców: {error}',
- 'vault.empty': 'Brak skarbców. Dodaj jeden powyżej, aby zacząć indeksować folder.',
- 'vault.fileCount': 'Plików: {count}',
- 'vault.syncedRelative': 'zsynchronizowano {time}',
- 'vault.neverSynced': 'nigdy nie synchronizowano',
- 'vault.writeState.writable': 'Zapisywalny',
- 'vault.writeState.read_only': 'Tylko do odczytu',
- 'vault.writeState.unavailable': 'Niedostępny',
- 'vault.writeState.unknownReason': 'Stan zapisu jest nieznany.',
- 'vault.writeState.reasons.writable':
- 'Zatwierdzone zapisy Markdown/wiki mogą być zapisane w tym skarbcu.',
- 'vault.writeState.reasons.read_only': 'Folder skarbca jest tylko do odczytu na tym urządzeniu.',
- 'vault.writeState.reasons.unavailable': 'Folder skarbca jest niedostępny na tym urządzeniu.',
- 'vault.writeState.reasons.not_directory': 'Ścieżka skarbca nie jest folderem.',
- 'vault.writeState.reasons.empty_path': 'Ścieżka folderu skarbca jest pusta.',
- 'vault.syncingProgress': 'Synchronizowanie… {ingested}/{total}',
- 'vault.removing': 'Usuwanie…',
- 'vault.relative.sec': '{count} s temu',
- 'vault.relative.min': '{count} min temu',
- 'vault.relative.hr': '{count} godz. temu',
- 'vault.relative.day': '{count} dni temu',
- 'vault.openButton': 'Open',
- 'vault.openSuccess': 'Opened in Obsidian',
- 'vault.openFallback': 'Obsidian not found — opened in file manager',
- 'vault.openError': "Couldn't open vault",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 min',
'subconscious.interval.tenMinutes': '10 min',
diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts
index e0d058d99..1c32f79ab 100644
--- a/app/src/lib/i18n/pt.ts
+++ b/app/src/lib/i18n/pt.ts
@@ -275,6 +275,7 @@ const messages: TranslationMap = {
'memory.empty':
'Nenhuma memória ainda. As memórias são criadas automaticamente conforme você interage.',
'memory.tab.memory': 'Memória',
+ 'memory.tab.memoryTree': 'Árvore de memória',
'memory.tab.tasks': 'Tarefas do Agente',
'memory.tab.tasksDescription':
'Crie e acompanhe tarefas — seus próprios afazeres e os painéis que seus agentes constroem ao longo das conversas.',
@@ -1940,6 +1941,7 @@ const messages: TranslationMap = {
'graph.children': 'filhos',
'graph.clickToOpenObsidian': 'Clique para abrir no Obsidian',
'graph.person': 'Pessoa',
+ 'graph.resetView': 'Redefinir visualização',
'modal.dontShowAgain': 'Não mostrar sugestões semelhantes',
'reflections.loading': 'Carregando reflexões...',
'reflections.empty': 'Nenhuma reflexão ainda',
@@ -4164,62 +4166,6 @@ const messages: TranslationMap = {
'Definir configurações de triagem de IA para gatilhos de integração Composio',
'memory.sourceFilterAria': 'Filtrar por origem',
'calls.comingSoonDescription': 'Chamadas assistidas por IA chegam em breve. Fique ligado.',
- 'vault.title': 'Cofres de conhecimento',
- 'vault.description':
- 'Aponte para uma pasta local; os arquivos são fragmentados e espelhados na memória.',
- 'vault.add': 'Adicionar cofre',
- 'vault.added': 'Cofre adicionado',
- 'vault.createdMessage': 'Criado "{name}". Clique em {sync} para ingerir.',
- 'vault.couldNotAdd': 'Não foi possível adicionar o cofre',
- 'vault.syncFailed': 'Falha na sincronização',
- 'vault.syncFailedFor': 'Falha na sincronização para "{name}"',
- 'vault.syncFailedFiles': 'Falha em {count} arquivo(s)',
- 'vault.syncedTitle': 'Sincronizado "{name}"',
- 'vault.syncSummary': 'Ingerido {ingested}, inalterado {unchanged}, removido {removed}',
- 'vault.syncSummaryFailed': ', falhou {count}',
- 'vault.syncSummarySkipped': ', ignorado {count}',
- 'vault.syncSummaryDuration': '· {seconds}s',
- 'vault.confirmRemovePurge':
- 'Remover vault "{name}"?\n\nClique em OK para também purgar sua memória (excluir todos os {count} documento(s) ingerido(s)).\nClique em Cancelar para manter os documentos na memória.',
- 'vault.confirmRemove': 'Realmente remover o cofre "{name}"?',
- 'vault.removed': 'Vault removido',
- 'vault.removedPurgedMessage': 'Removido "{name}" e limpou sua memória.',
- 'vault.removedKeptMessage': 'Removido "{name}". Documentos guardados na memória.',
- 'vault.couldNotRemove': 'Não foi possível remover o cofre',
- 'vault.name': 'Nome',
- 'vault.namePlaceholder': 'Minhas notas de pesquisa',
- 'vault.folderPath': 'Caminho da pasta (absoluto)',
- 'vault.folderPathPlaceholder': '/Users/you/Documents/notes',
- 'vault.excludes': 'Exclui (substrings separadas por vírgula, opcional)',
- 'vault.excludesPlaceholder': 'rascunhos/, .secret',
- 'vault.creating': 'Criando…',
- 'vault.create': 'Criar cofre',
- 'vault.loading': 'Carregando cofres…',
- 'vault.failedToLoad': 'Falha ao carregar cofres: {error}',
- 'vault.empty': 'Ainda não há cofres. Adicione um acima para começar a assimilar uma pasta.',
- 'vault.fileCount': '{count} arquivo(s)',
- 'vault.syncedRelative': 'sincronizado {time}',
- 'vault.neverSynced': 'nunca sincronizado',
- 'vault.writeState.writable': 'Gravável',
- 'vault.writeState.read_only': 'Somente leitura',
- 'vault.writeState.unavailable': 'Indisponível',
- 'vault.writeState.unknownReason': 'Estado de gravação desconhecido.',
- 'vault.writeState.reasons.writable':
- 'Gravações Markdown/wiki aprovadas podem ser salvas neste cofre.',
- 'vault.writeState.reasons.read_only': 'A pasta do cofre é somente leitura neste dispositivo.',
- 'vault.writeState.reasons.unavailable': 'A pasta do cofre não está disponível neste dispositivo.',
- 'vault.writeState.reasons.not_directory': 'O caminho do cofre não é uma pasta.',
- 'vault.writeState.reasons.empty_path': 'O caminho da pasta do cofre está vazio.',
- 'vault.syncingProgress': 'Sincronizando… {ingested}/{total}',
- 'vault.removing': 'Removendo…',
- 'vault.relative.sec': '{count}s atrás',
- 'vault.relative.min': '{count}m atrás',
- 'vault.relative.hr': '{count}h atrás',
- 'vault.relative.day': '{count}d atrás',
- 'vault.openButton': 'Open',
- 'vault.openSuccess': 'Opened in Obsidian',
- 'vault.openFallback': 'Obsidian not found — opened in file manager',
- 'vault.openError': "Couldn't open vault",
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 minutos',
'subconscious.interval.tenMinutes': '10 minutos',
diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts
index 4082908b7..c00692899 100644
--- a/app/src/lib/i18n/ru.ts
+++ b/app/src/lib/i18n/ru.ts
@@ -268,6 +268,7 @@ const messages: TranslationMap = {
'memory.noResults': 'Воспоминания не найдены',
'memory.empty': 'Воспоминаний пока нет. Они создаются автоматически в процессе общения.',
'memory.tab.memory': 'Память',
+ 'memory.tab.memoryTree': 'Дерево памяти',
'memory.tab.tasks': 'Задачи агента',
'memory.tab.tasksDescription':
'Создавайте и отслеживайте задачи — ваши личные дела и доски, которые агенты формируют в ходе разговоров.',
@@ -1915,6 +1916,7 @@ const messages: TranslationMap = {
'graph.children': 'потомки',
'graph.clickToOpenObsidian': 'Нажми, чтобы открыть в Obsidian',
'graph.person': 'Человек',
+ 'graph.resetView': 'Сбросить вид',
'modal.dontShowAgain': 'Не показывать похожие предложения',
'reflections.loading': 'Загрузка рефлексий...',
'reflections.empty': 'Рефлексий пока нет',
@@ -4131,63 +4133,6 @@ const messages: TranslationMap = {
'Настройка параметров сортировки AI для триггеров интеграции Composio',
'memory.sourceFilterAria': 'Фильтровать по источнику',
'calls.comingSoonDescription': 'Звонки с поддержкой ИИ скоро появятся. Следите за обновлениями.',
- 'vault.title': 'Хранилища знаний.',
- 'vault.description':
- 'Укажите локальную папку; файлы разбиваются на части и зеркалируются в память.',
- 'vault.add': 'Добавить хранилище.',
- 'vault.added': 'Хранилище добавлено.',
- 'vault.createdMessage': 'Создано «{name}». Нажмите {sync}, чтобы принять.',
- 'vault.couldNotAdd': 'Не удалось добавить хранилище.',
- 'vault.syncFailed': 'Не удалось синхронизировать',
- 'vault.syncFailedFor': 'Не удалось синхронизировать «{name}»',
- 'vault.syncFailedFiles': 'Не удалось {count} файлов',
- 'vault.syncedTitle': 'Синхронизировано "{name}"',
- 'vault.syncSummary': 'Загружен {ingested}, без изменений {unchanged}, удален {removed}',
- 'vault.syncSummaryFailed': ', не удалось {count}',
- 'vault.syncSummarySkipped': ', пропущен {count}',
- 'vault.syncSummaryDuration': ' · {seconds}s',
- 'vault.confirmRemovePurge':
- 'Удалить хранилище «{name}»?\n\nНажмите OK, чтобы также очистить его память (удалить все {count} загруженных документов).\nНажмите Отмена, чтобы сохранить документы в памяти.',
- 'vault.confirmRemove': 'Действительно удалить хранилище «{name}»?',
- 'vault.removed': 'Хранилище удалено.',
- 'vault.removedPurgedMessage': 'Удален «{name}» и очищена его память.',
- 'vault.removedKeptMessage': 'Удален «{name}». Документы хранятся в памяти.',
- 'vault.couldNotRemove': 'Не удалось удалить хранилище.',
- 'vault.name': 'Имя',
- 'vault.namePlaceholder': 'Мои исследовательские заметки',
- 'vault.folderPath': 'Путь к папке (абсолютный)',
- 'vault.folderPathPlaceholder': '/Пользователи/вы/Документы/заметки',
- 'vault.excludes': 'Исключает (подстроки, разделенные запятыми, необязательно)',
- 'vault.excludesPlaceholder': 'черновики/, .secret',
- 'vault.creating': 'Создание…',
- 'vault.create': 'Создать хранилище',
- 'vault.loading': 'Загрузка хранилища…',
- 'vault.failedToLoad': 'Не удалось загрузить хранилища: {error}',
- 'vault.empty': 'Хранилищ пока нет. Добавьте одно выше, чтобы начать загрузку папки.',
- 'vault.fileCount': '{count} файлов',
- 'vault.syncedRelative': 'синхронизировано {time}',
- 'vault.neverSynced': 'никогда не синхронизировано',
- 'vault.writeState.writable': 'Доступно для записи',
- 'vault.writeState.read_only': 'Только чтение',
- 'vault.writeState.unavailable': 'Недоступно',
- 'vault.writeState.unknownReason': 'Статус записи неизвестен.',
- 'vault.writeState.reasons.writable':
- 'Одобренные записи Markdown/wiki можно сохранить в этом хранилище.',
- 'vault.writeState.reasons.read_only':
- 'Папка хранилища доступна только для чтения на этом устройстве.',
- 'vault.writeState.reasons.unavailable': 'Папка хранилища недоступна на этом устройстве.',
- 'vault.writeState.reasons.not_directory': 'Путь хранилища не является папкой.',
- 'vault.writeState.reasons.empty_path': 'Путь к папке хранилища пуст.',
- 'vault.syncingProgress': 'Синхронизация… {ingested}/{total}',
- 'vault.removing': 'Удаление…',
- 'vault.relative.sec': '{count}s назад',
- 'vault.relative.min': '{count}m назад',
- 'vault.relative.hr': '{count}h назад',
- 'vault.relative.day': '{count}d назад',
- 'vault.openButton': 'Открыть',
- 'vault.openSuccess': 'Открыто в Obsidian',
- 'vault.openFallback': 'Obsidian не найден — открыто в файловом менеджере',
- 'vault.openError': 'Не удалось открыть хранилище',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5 минут',
'subconscious.interval.tenMinutes': '10 минут',
diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts
index f1f2a1cfa..510f28c9e 100644
--- a/app/src/lib/i18n/zh-CN.ts
+++ b/app/src/lib/i18n/zh-CN.ts
@@ -256,6 +256,7 @@ const messages: TranslationMap = {
'memory.noResults': '未找到记忆',
'memory.empty': '暂无记忆。记忆将在你交互时自动创建。',
'memory.tab.memory': '记忆',
+ 'memory.tab.memoryTree': '记忆树',
'memory.tab.tasks': '智能体任务',
'memory.tab.tasksDescription':
'创建并跟踪任务——包括您自己的待办事项以及智能体在对话中创建的看板。',
@@ -1807,6 +1808,7 @@ const messages: TranslationMap = {
'graph.children': '个子节点',
'graph.clickToOpenObsidian': '点击在 Obsidian 中打开',
'graph.person': '人物',
+ 'graph.resetView': '重置视图',
'modal.dontShowAgain': '不再显示',
'reflections.loading': '正在加载反思...',
'reflections.empty': '暂无反思',
@@ -3900,60 +3902,6 @@ const messages: TranslationMap = {
'devOptions.menuComposioTriggersDesc': '为 Composio 集成触发器配置 AI 分级设置',
'memory.sourceFilterAria': '按来源过滤',
'calls.comingSoonDescription': '人工智能辅助通话即将推出。敬请关注。',
- 'vault.title': '知识库',
- 'vault.description': '指向本地文件夹;文件被分块并镜像到内存中。',
- 'vault.add': '添加保险库',
- 'vault.added': '添加了保险库',
- 'vault.createdMessage': '创建“{name}”。单击 {sync} 进行摄取。',
- 'vault.couldNotAdd': '无法添加保管库',
- 'vault.syncFailed': '同步失败',
- 'vault.syncFailedFor': '“{name}”同步失败',
- 'vault.syncFailedFiles': '{count} 文件失败',
- 'vault.syncedTitle': '已同步“{name}”',
- 'vault.syncSummary': '摄入 {ingested},未改变 {unchanged},移除 {removed}',
- 'vault.syncSummaryFailed': ',失败 {count}',
- 'vault.syncSummarySkipped': ',跳过 {count}',
- 'vault.syncSummaryDuration': ' · {seconds}s',
- 'vault.confirmRemovePurge':
- '删除存储库「{name}」?\n\n点击确定将同时清除其记忆(删除全部 {count} 个已导入文档)。\n点击取消将保留记忆中的文档。',
- 'vault.confirmRemove': '真的删除保险库“{name}”吗?',
- 'vault.removed': '保险库已移除',
- 'vault.removedPurgedMessage': '删除了“{name}”并清除了其内存。',
- 'vault.removedKeptMessage': '删除了“{name}”。保存在内存中的文档。',
- 'vault.couldNotRemove': '无法删除保管库',
- 'vault.name': '名称',
- 'vault.namePlaceholder': '我的研究笔记',
- 'vault.folderPath': '文件夹路径(绝对)',
- 'vault.folderPathPlaceholder': '/用户/您/文档/注释',
- 'vault.excludes': '排除(逗号分隔的子字符串,可选)',
- 'vault.excludesPlaceholder': '草稿/,.秘密',
- 'vault.creating': '创造……',
- 'vault.create': '创建保管库',
- 'vault.loading': '正在加载金库...',
- 'vault.failedToLoad': '无法加载保管库:{error}',
- 'vault.empty': '还没有金库。在上面添加一个即可开始摄取文件夹。',
- 'vault.fileCount': '{count} 文件',
- 'vault.syncedRelative': '已同步 {time}',
- 'vault.neverSynced': '从未同步过',
- 'vault.writeState.writable': '可写',
- 'vault.writeState.read_only': '只读',
- 'vault.writeState.unavailable': '不可用',
- 'vault.writeState.unknownReason': '写入状态未知。',
- 'vault.writeState.reasons.writable': '可将已批准的 Markdown/wiki 写入保存到此保险库。',
- 'vault.writeState.reasons.read_only': '此设备上的保险库文件夹是只读的。',
- 'vault.writeState.reasons.unavailable': '此设备上的保险库文件夹不可用。',
- 'vault.writeState.reasons.not_directory': '保险库路径不是文件夹。',
- 'vault.writeState.reasons.empty_path': '保险库文件夹路径为空。',
- 'vault.syncingProgress': '正在同步... {ingested}/{total}',
- 'vault.removing': '正在删除...',
- 'vault.relative.sec': '{count} 秒前',
- 'vault.relative.min': '{count}分钟前',
- 'vault.relative.hr': '{count} 小时前',
- 'vault.relative.day': '{count} 天前',
- 'vault.openButton': '打开',
- 'vault.openSuccess': '已在 Obsidian 中打开',
- 'vault.openFallback': '未找到 Obsidian — 已在文件管理器中打开',
- 'vault.openError': '无法打开保管库',
'whatsapp.title': 'WhatsApp',
'subconscious.interval.fiveMinutes': '5分钟',
'subconscious.interval.tenMinutes': '10分钟',
diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx
index 923564621..8e4fdc4e5 100644
--- a/app/src/pages/Intelligence.tsx
+++ b/app/src/pages/Intelligence.tsx
@@ -1,18 +1,10 @@
import { useCallback, useEffect, useState } from 'react';
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
-import ConnectionPathTab from '../components/intelligence/ConnectionPathTab';
-import DiagramViewerTab from '../components/intelligence/DiagramViewerTab';
-import EntityAssociationsTab from '../components/intelligence/EntityAssociationsTab';
-import GraphCentralityTab from '../components/intelligence/GraphCentralityTab';
-import GraphCohesionTab from '../components/intelligence/GraphCohesionTab';
import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab';
import IntelligenceTasksTab from '../components/intelligence/IntelligenceTasksTab';
-import MemoryFreshnessTab from '../components/intelligence/MemoryFreshnessTab';
-import MemoryTimelineTab from '../components/intelligence/MemoryTimelineTab';
-import { MemoryWorkspace } from '../components/intelligence/MemoryWorkspace';
+import MemorySection from '../components/intelligence/MemorySection';
import ModelCouncilTab from '../components/intelligence/ModelCouncilTab';
-import NamespaceOverviewTab from '../components/intelligence/NamespaceOverviewTab';
import { ToastContainer } from '../components/intelligence/Toast';
import PillTabBar from '../components/PillTabBar';
import {
@@ -27,20 +19,7 @@ import type {
} from '../types/intelligence';
import AgentWorkflows from './AgentWorkflows';
-type IntelligenceTab =
- | 'memory'
- | 'subconscious'
- | 'tasks'
- | 'workflows'
- | 'diagram'
- | 'centrality'
- | 'cohesion'
- | 'associations'
- | 'freshness'
- | 'timeline'
- | 'path'
- | 'namespaces'
- | 'council';
+type IntelligenceTab = 'memory' | 'subconscious' | 'tasks' | 'workflows' | 'council';
export default function Intelligence() {
const { t } = useT();
@@ -118,14 +97,6 @@ export default function Intelligence() {
label: t('memory.tab.workflows'),
description: t('memory.tab.workflowsDescription'),
},
- { id: 'diagram', label: t('memory.tab.diagram') },
- { id: 'centrality', label: t('memory.tab.centrality') },
- { id: 'cohesion', label: t('memory.tab.cohesion') },
- { id: 'associations', label: t('memory.tab.associations') },
- { id: 'freshness', label: t('memory.tab.freshness') },
- { id: 'timeline', label: t('memory.tab.timeline') },
- { id: 'path', label: t('memory.tab.path') },
- { id: 'namespaces', label: t('memory.tab.namespaces') },
{ id: 'council', label: t('memory.tab.council') },
];
const activeTabDef = tabs.find(tab => tab.id === activeTab);
@@ -187,7 +158,7 @@ export default function Intelligence() {
{/* Tab content */}
- {activeTab === 'memory' &&
}
+ {activeTab === 'memory' &&
}
{activeTab === 'subconscious' && (
}
- {activeTab === 'diagram' &&
}
-
- {activeTab === 'centrality' &&
}
-
- {activeTab === 'cohesion' &&
}
-
- {activeTab === 'associations' &&
}
-
- {activeTab === 'freshness' &&
}
-
- {activeTab === 'timeline' &&
}
-
- {activeTab === 'path' &&
}
-
- {activeTab === 'namespaces' &&
}
-
{activeTab === 'council' &&
}