total-recall-brain 3.21.2 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/frontend/dist/assets/index-C7_Xpk27.css +1 -0
  2. package/frontend/dist/assets/{index-D0XeeXhG.js → index-DJjuZ3t3.js} +106 -106
  3. package/frontend/dist/index.html +2 -2
  4. package/frontend/src/App.spec.tsx +1 -1
  5. package/frontend/src/api/_base.ts +3 -3
  6. package/frontend/src/api/embeddings.ts +46 -0
  7. package/frontend/src/api/mesh.ts +47 -0
  8. package/frontend/src/api/network.spec.ts +9 -9
  9. package/frontend/src/api/network.ts +4 -4
  10. package/frontend/src/api/notifications.ts +5 -4
  11. package/frontend/src/api/webhooks.ts +1 -1
  12. package/frontend/src/components/BrainSelector.spec.tsx +4 -4
  13. package/frontend/src/components/BrainSelector.tsx +1 -1
  14. package/frontend/src/components/ContextualHelp.spec.tsx +14 -14
  15. package/frontend/src/components/ContextualHelp.tsx +3 -1
  16. package/frontend/src/components/DocumentTable.spec.tsx +1 -1
  17. package/frontend/src/components/EmbeddingProviderPanel.spec.tsx +127 -0
  18. package/frontend/src/components/EmbeddingProviderPanel.tsx +177 -0
  19. package/frontend/src/components/Graph3D.spec.tsx +4 -4
  20. package/frontend/src/components/Graph3D.tsx +3 -3
  21. package/frontend/src/components/MobilePairing.spec.tsx +1 -1
  22. package/frontend/src/components/MobilePairing.tsx +1 -1
  23. package/frontend/src/components/UsageChart.spec.tsx +4 -4
  24. package/frontend/src/components/UsageChart.tsx +1 -1
  25. package/frontend/src/components/VoiceInput.spec.tsx +6 -6
  26. package/frontend/src/components/VoiceInput.tsx +19 -4
  27. package/frontend/src/components/brand/BrandMark.spec.tsx +3 -4
  28. package/frontend/src/index.css +6 -0
  29. package/frontend/src/pages/AutomationsPage.spec.tsx +4 -4
  30. package/frontend/src/pages/ChatPage.spec.tsx +3 -3
  31. package/frontend/src/pages/CollabPage.spec.tsx +1 -2
  32. package/frontend/src/pages/DesignDocsPage.spec.tsx +1 -1
  33. package/frontend/src/pages/FilesPage.spec.tsx +2 -2
  34. package/frontend/src/pages/GraphPage.spec.tsx +1 -1
  35. package/frontend/src/pages/HealthPage.spec.tsx +2 -2
  36. package/frontend/src/pages/HelpPage.spec.tsx +5 -5
  37. package/frontend/src/pages/InboxPage.spec.tsx +4 -4
  38. package/frontend/src/pages/InstructionsPage.spec.tsx +5 -5
  39. package/frontend/src/pages/IntegrationsPage.spec.tsx +7 -7
  40. package/frontend/src/pages/MemoryPage.spec.tsx +2 -2
  41. package/frontend/src/pages/MeshPage.spec.tsx +111 -2
  42. package/frontend/src/pages/MeshPage.tsx +95 -11
  43. package/frontend/src/pages/NetworkPage.spec.tsx +7 -8
  44. package/frontend/src/pages/NetworkPage.tsx +12 -12
  45. package/frontend/src/pages/NotificationsPage.tsx +9 -9
  46. package/frontend/src/pages/OnboardingPage.spec.tsx +7 -7
  47. package/frontend/src/pages/OpenWikiPage.tsx +3 -3
  48. package/frontend/src/pages/RulesPage.tsx +3 -3
  49. package/frontend/src/pages/SecretsPage.spec.tsx +5 -5
  50. package/frontend/src/pages/SecretsPage.tsx +13 -14
  51. package/frontend/src/pages/SettingsPage.spec.tsx +6 -6
  52. package/frontend/src/pages/SettingsPage.tsx +2 -2
  53. package/frontend/src/pages/SkillsPage.spec.tsx +1 -2
  54. package/frontend/src/pages/SkillsPage.tsx +3 -3
  55. package/frontend/src/pages/TasksPage.spec.tsx +3 -3
  56. package/frontend/src/pages/UsagePage.spec.tsx +2 -2
  57. package/frontend/src/pages/UsagePage.tsx +1 -1
  58. package/frontend/src/pages/WebhooksPage.spec.tsx +1 -1
  59. package/frontend/src/pages/WebhooksPage.tsx +19 -19
  60. package/frontend/src/setupTests.ts +4 -4
  61. package/package.json +1 -1
  62. package/scaffold/.agent/skills/total-recall/SKILL.md +92 -0
  63. package/scaffold/.agent/skills/total-recall/modules/agents/agents.yml +15 -13
  64. package/scaffold/.agent/skills/total-recall/research-queue.jsonl +0 -1
  65. package/src/cli/secret.mjs +93 -12
  66. package/src/core/browser-session.mjs +185 -0
  67. package/src/core/daemon-loop.mjs +28 -1
  68. package/src/core/embeddings.mjs +52 -10
  69. package/src/core/headscale-client.mjs +227 -0
  70. package/src/core/mesh-enroll.mjs +439 -0
  71. package/src/core/ollama-embeddings.mjs +336 -0
  72. package/src/core/provider-catalog.mjs +175 -6
  73. package/src/core/provider-rotation-recipes.mjs +299 -0
  74. package/src/core/rotation-capability.mjs +189 -0
  75. package/src/core/secrets-rotate.mjs +192 -0
  76. package/src/server/rest.mjs +2 -0
  77. package/src/server/route-manifest.json +20 -0
  78. package/src/server/routes/embeddings.mjs +111 -0
  79. package/src/server/routes/headscale.mjs +21 -81
  80. package/src/server/routes/mesh.mjs +41 -0
  81. package/frontend/dist/assets/index-B4RASWbV.css +0 -1
@@ -17,8 +17,8 @@
17
17
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18
18
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
19
19
  <title>Total Recall — Portable Memory</title>
20
- <script type="module" crossorigin src="/assets/index-D0XeeXhG.js"></script>
21
- <link rel="stylesheet" crossorigin href="/assets/index-B4RASWbV.css">
20
+ <script type="module" crossorigin src="/assets/index-DJjuZ3t3.js"></script>
21
+ <link rel="stylesheet" crossorigin href="/assets/index-C7_Xpk27.css">
22
22
  </head>
23
23
  <body>
24
24
  <div id="root"></div>
@@ -19,7 +19,7 @@ vi.mock('./utils/onboarding', () => ({
19
19
 
20
20
  // Mock the components since App contains a lot of them and we just want to test routing/auth logic
21
21
  vi.mock('./pages/LoginPage', () => ({
22
- default: ({ onAuthenticated }: any) => <div data-testid="login-page"><button onClick={onAuthenticated}>Login</button></div>
22
+ default: ({ onAuthenticated }: { onAuthenticated: () => void }) => <div data-testid="login-page"><button onClick={onAuthenticated}>Login</button></div>
23
23
  }));
24
24
  vi.mock('./pages/ChatPage', () => ({ default: () => <div data-testid="chat-page" /> }));
25
25
  vi.mock('./pages/MemoryPage', () => ({ default: () => <div data-testid="memory-page" /> }));
@@ -34,13 +34,13 @@ export async function apiFetch(url: string, options: RequestInit = {}): Promise<
34
34
  return res
35
35
  }
36
36
 
37
- export async function get<T = any>(path: string): Promise<T> {
37
+ export async function get<T = unknown>(path: string): Promise<T> {
38
38
  const res = await apiFetch(API_BASE + path);
39
39
  if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`);
40
40
  return res.json();
41
41
  }
42
42
 
43
- export async function post<T = any>(path: string, body: any): Promise<T> {
43
+ export async function post<T = unknown>(path: string, body: unknown): Promise<T> {
44
44
  const res = await apiFetch(API_BASE + path, {
45
45
  method: 'POST',
46
46
  headers: { 'Content-Type': 'application/json' },
@@ -50,7 +50,7 @@ export async function post<T = any>(path: string, body: any): Promise<T> {
50
50
  return res.json();
51
51
  }
52
52
 
53
- export async function del<T = any>(path: string): Promise<T> {
53
+ export async function del<T = unknown>(path: string): Promise<T> {
54
54
  const res = await apiFetch(API_BASE + path, {
55
55
  method: 'DELETE'
56
56
  });
@@ -0,0 +1,46 @@
1
+ import { get, post } from './_base'
2
+
3
+ /** One model on the Ollama host, annotated with why it can or cannot be used. */
4
+ export interface EmbeddingCandidate {
5
+ name: string
6
+ size: number | null
7
+ family: string | null
8
+ capabilities: string[]
9
+ /** Whether the model declares the embedding capability at all. */
10
+ embedding: boolean
11
+ /** Vector width the model produces, or null when it is not an embedding model. */
12
+ dims: number | null
13
+ /** True only when it embeds AND its width matches the vault index. */
14
+ compatible: boolean
15
+ }
16
+
17
+ export interface LocalEmbeddingProvider {
18
+ available: boolean
19
+ endpoint: string | null
20
+ selected: string | null
21
+ preferred: string | null
22
+ dims: number
23
+ candidates: EmbeddingCandidate[]
24
+ reason: string | null
25
+ }
26
+
27
+ export interface EmbeddingProviderStatus {
28
+ dims: number
29
+ local: LocalEmbeddingProvider
30
+ fallbacks: { provider: string; configured: boolean }[]
31
+ }
32
+
33
+ export async function fetchEmbeddingProvider(): Promise<EmbeddingProviderStatus> {
34
+ return get<EmbeddingProviderStatus>('/api/embeddings/provider')
35
+ }
36
+
37
+ /** Pin a model, or pass null to return to automatic selection. */
38
+ export async function setEmbeddingModel(
39
+ model: string | null,
40
+ ): Promise<{ ok: boolean; pinned: string | null; selected: string | null }> {
41
+ return post('/api/embeddings/model', { model })
42
+ }
43
+
44
+ export async function rediscoverEmbeddingProvider(): Promise<{ ok: boolean; local: LocalEmbeddingProvider }> {
45
+ return post('/api/embeddings/rediscover', {})
46
+ }
@@ -159,6 +159,53 @@ export async function fetchDeviceIo(): Promise<{
159
159
  return get('/api/mesh/io');
160
160
  }
161
161
 
162
+ export type EnrollmentState =
163
+ | 'enrolled'
164
+ | 'needs_login'
165
+ | 'stopped'
166
+ | 'client_unavailable'
167
+ | 'unknown';
168
+
169
+ export interface EnrollmentStatus {
170
+ state: EnrollmentState;
171
+ enrolled: boolean;
172
+ backend_state: string | null;
173
+ auth_url: string | null;
174
+ ips: string[];
175
+ hostname: string;
176
+ login_server: string | null;
177
+ can_auto_enroll: boolean;
178
+ auto_enroll_blocked_reason: string | null;
179
+ auto_enroll_enabled: boolean;
180
+ client_available: boolean;
181
+ checked_at: string;
182
+ }
183
+
184
+ export interface EnrollResult {
185
+ ok: boolean;
186
+ changed: boolean;
187
+ state: EnrollmentState;
188
+ status: EnrollmentStatus;
189
+ method?: 'preauth-key' | 'interactive' | 'resume';
190
+ auth_url?: string | null;
191
+ reason?: string | null;
192
+ hint?: string;
193
+ }
194
+
195
+ /** Enrollment state of this node on the mesh control server. */
196
+ export async function fetchEnrollmentStatus(): Promise<EnrollmentStatus> {
197
+ return get('/api/mesh/enrollment');
198
+ }
199
+
200
+ /** Enroll this node now (mints a pre-auth key when one can be minted). */
201
+ export async function enrollThisNode(opts?: {
202
+ login_server?: string;
203
+ user?: string;
204
+ force?: boolean;
205
+ }): Promise<EnrollResult> {
206
+ return post('/api/mesh/enroll', opts || {});
207
+ }
208
+
162
209
  /** Discover LAN TR peers and upsert mesh_node entities (config:write). */
163
210
  export async function registerLanMeshNodes(opts?: { probe?: boolean; limit?: number }): Promise<{
164
211
  success: boolean;
@@ -3,14 +3,14 @@ import { networkApi } from './network';
3
3
 
4
4
  describe('networkApi', () => {
5
5
  beforeEach(() => {
6
- globalThis.fetch = vi.fn() as any;
6
+ globalThis.fetch = vi.fn() as unknown as typeof fetch;
7
7
  });
8
8
 
9
9
  it('getStats fetches from /api/network/stats', async () => {
10
- (globalThis.fetch as any).mockResolvedValue({
10
+ vi.mocked(globalThis.fetch).mockResolvedValue({
11
11
  ok: true,
12
12
  json: () => Promise.resolve({ stats: { total: 10 }, audit_count: 5 })
13
- });
13
+ } as unknown as Response);
14
14
 
15
15
  const result = await networkApi.getStats();
16
16
  expect(globalThis.fetch).toHaveBeenCalledWith('/api/network/stats', expect.any(Object));
@@ -18,10 +18,10 @@ describe('networkApi', () => {
18
18
  });
19
19
 
20
20
  it('getPolicy fetches from /api/network/policy', async () => {
21
- (globalThis.fetch as any).mockResolvedValue({
21
+ vi.mocked(globalThis.fetch).mockResolvedValue({
22
22
  ok: true,
23
23
  json: () => Promise.resolve({ id: 'network-policy', blocked_domains: [] })
24
- });
24
+ } as unknown as Response);
25
25
 
26
26
  const result = await networkApi.getPolicy();
27
27
  expect(globalThis.fetch).toHaveBeenCalledWith('/api/network/policy', expect.any(Object));
@@ -29,10 +29,10 @@ describe('networkApi', () => {
29
29
  });
30
30
 
31
31
  it('blockDomain POSTs to /api/network/block', async () => {
32
- (globalThis.fetch as any).mockResolvedValue({
32
+ vi.mocked(globalThis.fetch).mockResolvedValue({
33
33
  ok: true,
34
34
  json: () => Promise.resolve({ success: true })
35
- });
35
+ } as unknown as Response);
36
36
 
37
37
  await networkApi.blockDomain('evil.com');
38
38
  expect(globalThis.fetch).toHaveBeenCalledWith('/api/network/block', expect.objectContaining({
@@ -42,10 +42,10 @@ describe('networkApi', () => {
42
42
  });
43
43
 
44
44
  it('unblockDomain DELETEs to /api/network/block/:domain', async () => {
45
- (globalThis.fetch as any).mockResolvedValue({
45
+ vi.mocked(globalThis.fetch).mockResolvedValue({
46
46
  ok: true,
47
47
  json: () => Promise.resolve({ success: true })
48
- });
48
+ } as unknown as Response);
49
49
 
50
50
  await networkApi.unblockDomain('evil.com');
51
51
  expect(globalThis.fetch).toHaveBeenCalledWith('/api/network/block/evil.com', expect.objectContaining({
@@ -51,7 +51,7 @@ export async function getNetworkPolicy(): Promise<NetworkPolicy> {
51
51
  return get('/api/network/policy');
52
52
  }
53
53
 
54
- export async function updateNetworkPolicy(patch: Partial<NetworkPolicy>): Promise<any> {
54
+ export async function updateNetworkPolicy(patch: Partial<NetworkPolicy>): Promise<unknown> {
55
55
  const res = await apiFetch(API_BASE + '/api/network/policy', {
56
56
  method: 'PUT',
57
57
  headers: { 'Content-Type': 'application/json' },
@@ -61,16 +61,16 @@ export async function updateNetworkPolicy(patch: Partial<NetworkPolicy>): Promis
61
61
  return res.json();
62
62
  }
63
63
 
64
- export async function blockDomain(domain: string): Promise<any> {
64
+ export async function blockDomain(domain: string): Promise<unknown> {
65
65
  return post('/api/network/block', { domain });
66
66
  }
67
67
 
68
- export async function unblockDomain(domain: string): Promise<any> {
68
+ export async function unblockDomain(domain: string): Promise<unknown> {
69
69
  return del(`/api/network/block/${encodeURIComponent(domain)}`);
70
70
  }
71
71
 
72
72
  export async function getAuditLog(params?: { domain?: string; status?: string; since?: string }): Promise<{ audit: AuditLogEntry[] }> {
73
- const query = params ? '?' + new URLSearchParams(params as any).toString() : '';
73
+ const query = params ? '?' + new URLSearchParams(params as Record<string, string>).toString() : '';
74
74
  return get(`/api/network/audit${query}`);
75
75
  }
76
76
 
@@ -18,9 +18,9 @@ export interface NotificationEntry {
18
18
  timestamp: string;
19
19
  }
20
20
 
21
- async function parseJsonOrThrow(res: Response, label: string): Promise<any> {
21
+ async function parseJsonOrThrow(res: Response, label: string): Promise<unknown> {
22
22
  const text = await res.text();
23
- let data: any = null;
23
+ let data: unknown = null;
24
24
  if (text) {
25
25
  try {
26
26
  data = JSON.parse(text);
@@ -29,8 +29,9 @@ async function parseJsonOrThrow(res: Response, label: string): Promise<any> {
29
29
  }
30
30
  }
31
31
  if (!res.ok) {
32
+ const obj = data as Record<string, unknown> | null;
32
33
  const msg =
33
- (data && (data.error || data.message)) ||
34
+ (obj && (obj.error || obj.message)) ||
34
35
  `${label} failed: ${res.status}`;
35
36
  throw new Error(typeof msg === 'string' ? msg : `${label} failed: ${res.status}`);
36
37
  }
@@ -62,7 +63,7 @@ export async function createNotificationRule(
62
63
  headers: { 'Content-Type': 'application/json' },
63
64
  body: JSON.stringify(rule),
64
65
  });
65
- return parseJsonOrThrow(res, 'POST /api/notifications/rules');
66
+ return parseJsonOrThrow(res, 'POST /api/notifications/rules') as Promise<NotificationRule>;
66
67
  }
67
68
 
68
69
  export async function deleteNotificationRule(id: string): Promise<void> {
@@ -17,7 +17,7 @@ export interface WebhookEvent {
17
17
  provider: string;
18
18
  event_type: string;
19
19
  received_at: string;
20
- payload?: any;
20
+ payload?: unknown;
21
21
  delivery_status?: string;
22
22
  parent_event_id?: string;
23
23
  delivery_id?: string | null;
@@ -30,7 +30,7 @@ describe('BrainSelector', () => {
30
30
  },
31
31
  ],
32
32
  }),
33
- } as any);
33
+ } as never);
34
34
 
35
35
  const onBrainChange = vi.fn();
36
36
  render(<BrainSelector activeBrainId="global" onBrainChange={onBrainChange} />);
@@ -51,7 +51,7 @@ describe('BrainSelector', () => {
51
51
  { id: 'project:demo', name: 'demo', layer: 'project', exists: true, node_count: 5 },
52
52
  ],
53
53
  }),
54
- } as any);
54
+ } as never);
55
55
 
56
56
  const onBrainChange = vi.fn();
57
57
  render(<BrainSelector activeBrainId="global" onBrainChange={onBrainChange} />);
@@ -71,7 +71,7 @@ describe('BrainSelector', () => {
71
71
  { id: 'project:demo', name: 'demo', layer: 'project', exists: true, node_count: 5 },
72
72
  ],
73
73
  }),
74
- } as any);
74
+ } as never);
75
75
 
76
76
  const onBrainChange = vi.fn();
77
77
  render(<BrainSelector activeBrainId="global" onBrainChange={onBrainChange} />);
@@ -87,7 +87,7 @@ describe('BrainSelector', () => {
87
87
  ok: false,
88
88
  status: 401,
89
89
  json: async () => ({ error: 'Authentication required' }),
90
- } as any);
90
+ } as never);
91
91
 
92
92
  render(<BrainSelector activeBrainId="global" onBrainChange={vi.fn()} />);
93
93
  await waitFor(() => {
@@ -60,7 +60,7 @@ export default function BrainSelector({ activeBrainId, onBrainChange }: BrainSel
60
60
  }
61
61
 
62
62
  useEffect(() => {
63
- void loadBrains()
63
+ setTimeout(() => { void loadBrains() }, 0)
64
64
  }, [])
65
65
 
66
66
  useEffect(() => {
@@ -1,5 +1,4 @@
1
- // @ts-nocheck
2
- import { describe, it, expect, vi } from 'vitest';
1
+ import { describe, it, expect } from 'vitest';
3
2
  import { render, screen, fireEvent } from '@testing-library/react';
4
3
  import { MemoryRouter } from 'react-router-dom';
5
4
  import ContextualHelp from './ContextualHelp';
@@ -20,42 +19,43 @@ describe('ContextualHelp', () => {
20
19
  expect(btn).toHaveTextContent('?');
21
20
  });
22
21
 
23
- it('opens overlay when help button is clicked', () => {
22
+ it('opens overlay when help button is clicked', async () => {
24
23
  renderWithRouter('/');
25
24
  fireEvent.click(screen.getByTitle('Page Help'));
26
- expect(screen.getByText(/Chat Interface/)).toBeInTheDocument();
25
+ expect(await screen.findByText(/Chat Interface/)).toBeInTheDocument();
27
26
  });
28
27
 
29
- it('shows memory vault help for /memory route', () => {
28
+ it('shows memory vault help for /memory route', async () => {
30
29
  renderWithRouter('/memory');
31
30
  fireEvent.click(screen.getByTitle('Page Help'));
32
- expect(screen.getByText(/Memory Vault/)).toBeInTheDocument();
31
+ expect(await screen.findByText(/Memory Vault/)).toBeInTheDocument();
33
32
  });
34
33
 
35
- it('shows graph help for /graph route', () => {
34
+ it('shows graph help for /graph route', async () => {
36
35
  renderWithRouter('/graph');
37
36
  fireEvent.click(screen.getByTitle('Page Help'));
38
- expect(screen.getByText(/Concept Graph/)).toBeInTheDocument();
37
+ expect(await screen.findByText(/Concept Graph/)).toBeInTheDocument();
39
38
  });
40
39
 
41
- it('shows fallback for unknown routes', () => {
40
+ it('shows fallback for unknown routes', async () => {
42
41
  renderWithRouter('/unknown-route');
43
42
  fireEvent.click(screen.getByTitle('Page Help'));
44
- expect(screen.getByText(/No specific documentation/)).toBeInTheDocument();
43
+ expect(await screen.findByText(/No specific documentation/)).toBeInTheDocument();
45
44
  });
46
45
 
47
- it('closes overlay when close button is clicked', () => {
46
+ it('closes overlay when close button is clicked', async () => {
48
47
  renderWithRouter('/');
49
48
  fireEvent.click(screen.getByTitle('Page Help'));
50
- expect(screen.getByText(/Chat Interface/)).toBeInTheDocument();
49
+ expect(await screen.findByText(/Chat Interface/)).toBeInTheDocument();
51
50
  fireEvent.click(screen.getByText('✕'));
52
51
  expect(screen.queryByText(/Chat Interface/)).not.toBeInTheDocument();
53
52
  });
54
53
 
55
- it('closes overlay when backdrop is clicked', () => {
54
+ it('closes overlay when backdrop is clicked', async () => {
56
55
  renderWithRouter('/');
57
56
  fireEvent.click(screen.getByTitle('Page Help'));
58
- const overlay = screen.getByText(/Chat Interface/).parentElement?.parentElement;
57
+ const text = await screen.findByText(/Chat Interface/);
58
+ const overlay = text.parentElement?.parentElement;
59
59
  if (overlay) fireEvent.click(overlay);
60
60
  expect(screen.queryByText(/Chat Interface/)).not.toBeInTheDocument();
61
61
  });
@@ -23,7 +23,9 @@ export default function ContextualHelp() {
23
23
  useEffect(() => {
24
24
  // Determine content based on current path
25
25
  const path = location.pathname;
26
- setContent(helpContent[path] || `### ${path}\nNo specific documentation is available for this route yet.`);
26
+ setTimeout(() => {
27
+ setContent(helpContent[path] || `### ${path}\nNo specific documentation is available for this route yet.`);
28
+ }, 0);
27
29
  }, [location.pathname]);
28
30
 
29
31
  return (
@@ -28,7 +28,7 @@ describe('DocumentTable', () => {
28
28
  it('renders custom column render correctly', () => {
29
29
  const customCols = [
30
30
  ...columns,
31
- { key: 'action', header: 'Action', render: (row: any) => <button>Edit {row.name}</button> }
31
+ { key: 'action', header: 'Action', render: (row: { name: string }) => <button>Edit {row.name}</button> }
32
32
  ];
33
33
  const data = [{ id: 1, name: 'Doc 1', status: 'Active' }];
34
34
 
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { render, screen, waitFor } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+
5
+ import { EmbeddingProviderPanel } from './EmbeddingProviderPanel'
6
+ import * as api from '../api/embeddings'
7
+ import type { EmbeddingProviderStatus } from '../api/embeddings'
8
+
9
+ vi.mock('../api/embeddings')
10
+
11
+ // Annotated against the real contract: without it TS narrows `endpoint` to
12
+ // string and `reason` to null from the literal, so the "nothing reachable"
13
+ // case below cannot be expressed.
14
+ const LOCAL_OK: EmbeddingProviderStatus = {
15
+ dims: 768,
16
+ local: {
17
+ available: true,
18
+ endpoint: 'http://100.64.0.2:11434',
19
+ selected: 'nomic-embed-text:latest',
20
+ preferred: null,
21
+ dims: 768,
22
+ candidates: [
23
+ {
24
+ name: 'nomic-embed-text:latest',
25
+ size: 274_000_000,
26
+ family: 'nomic-bert',
27
+ capabilities: ['embedding'],
28
+ embedding: true,
29
+ dims: 768,
30
+ compatible: true,
31
+ },
32
+ {
33
+ name: 'gemma4:latest',
34
+ size: 9_608_000_000,
35
+ family: 'gemma4',
36
+ capabilities: ['completion'],
37
+ embedding: false,
38
+ dims: 2560,
39
+ compatible: false,
40
+ },
41
+ ],
42
+ reason: null,
43
+ },
44
+ fallbacks: [
45
+ { provider: 'openrouter', configured: true },
46
+ { provider: 'google', configured: false },
47
+ { provider: 'openai', configured: false },
48
+ ],
49
+ }
50
+
51
+ beforeEach(() => {
52
+ vi.mocked(api.fetchEmbeddingProvider).mockResolvedValue(structuredClone(LOCAL_OK))
53
+ vi.mocked(api.setEmbeddingModel).mockResolvedValue({ ok: true, pinned: null, selected: null })
54
+ vi.mocked(api.rediscoverEmbeddingProvider).mockResolvedValue({ ok: true, local: LOCAL_OK.local })
55
+ })
56
+
57
+ describe('EmbeddingProviderPanel', () => {
58
+ it('shows the resolved endpoint and selected model', async () => {
59
+ render(<EmbeddingProviderPanel />)
60
+ expect(await screen.findByTestId('embed-endpoint')).toHaveTextContent('http://100.64.0.2:11434')
61
+ expect(screen.getByTestId('embed-selected')).toHaveTextContent('nomic-embed-text:latest')
62
+ expect(screen.getByTestId('embed-selected')).toHaveTextContent('auto')
63
+ })
64
+
65
+ it('lists incompatible models with the reason instead of hiding them', async () => {
66
+ render(<EmbeddingProviderPanel />)
67
+ // An operator who pulled a chat model needs to see why it is unusable.
68
+ expect(await screen.findByText('gemma4:latest')).toBeInTheDocument()
69
+ expect(screen.getByText(/cannot embed/i)).toBeInTheDocument()
70
+ })
71
+
72
+ it('offers no "Use" button for a model that cannot embed', async () => {
73
+ render(<EmbeddingProviderPanel />)
74
+ await screen.findByText('gemma4:latest')
75
+ // Only compatible, non-selected models are pinnable; here that set is empty.
76
+ expect(screen.queryByRole('button', { name: /^use$/i })).not.toBeInTheDocument()
77
+ })
78
+
79
+ it('pins a model when the operator picks one', async () => {
80
+ const withOther = structuredClone(LOCAL_OK)
81
+ withOther.local.candidates.push({
82
+ name: 'other-embed:latest',
83
+ size: 100_000_000,
84
+ family: 'other',
85
+ capabilities: ['embedding'],
86
+ embedding: true,
87
+ dims: 768,
88
+ compatible: true,
89
+ })
90
+ vi.mocked(api.fetchEmbeddingProvider).mockResolvedValue(withOther)
91
+
92
+ render(<EmbeddingProviderPanel />)
93
+ const useBtn = await screen.findByRole('button', { name: /^use$/i })
94
+ await userEvent.click(useBtn)
95
+
96
+ await waitFor(() => expect(api.setEmbeddingModel).toHaveBeenCalledWith('other-embed:latest'))
97
+ })
98
+
99
+ it('reports the hosted fallback state when nothing local answers', async () => {
100
+ const down = structuredClone(LOCAL_OK)
101
+ down.local = {
102
+ ...down.local,
103
+ available: false,
104
+ endpoint: null,
105
+ selected: null,
106
+ candidates: [],
107
+ reason: 'No Ollama endpoint answered on this host or any online mesh peer.',
108
+ }
109
+ vi.mocked(api.fetchEmbeddingProvider).mockResolvedValue(down)
110
+
111
+ render(<EmbeddingProviderPanel />)
112
+ expect(await screen.findByTestId('embed-availability')).toHaveTextContent('hosted fallback')
113
+ expect(screen.getByRole('status')).toHaveTextContent(/No Ollama endpoint answered/)
114
+ })
115
+
116
+ it('surfaces a load failure rather than rendering an empty panel', async () => {
117
+ vi.mocked(api.fetchEmbeddingProvider).mockRejectedValue(new Error('Authentication required'))
118
+ render(<EmbeddingProviderPanel />)
119
+ expect(await screen.findByRole('alert')).toHaveTextContent('Authentication required')
120
+ })
121
+
122
+ it('re-discovers on demand', async () => {
123
+ render(<EmbeddingProviderPanel />)
124
+ await userEvent.click(await screen.findByRole('button', { name: /re-discover/i }))
125
+ await waitFor(() => expect(api.rediscoverEmbeddingProvider).toHaveBeenCalled())
126
+ })
127
+ })