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.
- package/frontend/dist/assets/index-C7_Xpk27.css +1 -0
- package/frontend/dist/assets/{index-D0XeeXhG.js → index-DJjuZ3t3.js} +106 -106
- package/frontend/dist/index.html +2 -2
- package/frontend/src/App.spec.tsx +1 -1
- package/frontend/src/api/_base.ts +3 -3
- package/frontend/src/api/embeddings.ts +46 -0
- package/frontend/src/api/mesh.ts +47 -0
- package/frontend/src/api/network.spec.ts +9 -9
- package/frontend/src/api/network.ts +4 -4
- package/frontend/src/api/notifications.ts +5 -4
- package/frontend/src/api/webhooks.ts +1 -1
- package/frontend/src/components/BrainSelector.spec.tsx +4 -4
- package/frontend/src/components/BrainSelector.tsx +1 -1
- package/frontend/src/components/ContextualHelp.spec.tsx +14 -14
- package/frontend/src/components/ContextualHelp.tsx +3 -1
- package/frontend/src/components/DocumentTable.spec.tsx +1 -1
- package/frontend/src/components/EmbeddingProviderPanel.spec.tsx +127 -0
- package/frontend/src/components/EmbeddingProviderPanel.tsx +177 -0
- package/frontend/src/components/Graph3D.spec.tsx +4 -4
- package/frontend/src/components/Graph3D.tsx +3 -3
- package/frontend/src/components/MobilePairing.spec.tsx +1 -1
- package/frontend/src/components/MobilePairing.tsx +1 -1
- package/frontend/src/components/UsageChart.spec.tsx +4 -4
- package/frontend/src/components/UsageChart.tsx +1 -1
- package/frontend/src/components/VoiceInput.spec.tsx +6 -6
- package/frontend/src/components/VoiceInput.tsx +19 -4
- package/frontend/src/components/brand/BrandMark.spec.tsx +3 -4
- package/frontend/src/index.css +6 -0
- package/frontend/src/pages/AutomationsPage.spec.tsx +4 -4
- package/frontend/src/pages/ChatPage.spec.tsx +3 -3
- package/frontend/src/pages/CollabPage.spec.tsx +1 -2
- package/frontend/src/pages/DesignDocsPage.spec.tsx +1 -1
- package/frontend/src/pages/FilesPage.spec.tsx +2 -2
- package/frontend/src/pages/GraphPage.spec.tsx +1 -1
- package/frontend/src/pages/HealthPage.spec.tsx +2 -2
- package/frontend/src/pages/HelpPage.spec.tsx +5 -5
- package/frontend/src/pages/InboxPage.spec.tsx +4 -4
- package/frontend/src/pages/InstructionsPage.spec.tsx +5 -5
- package/frontend/src/pages/IntegrationsPage.spec.tsx +7 -7
- package/frontend/src/pages/MemoryPage.spec.tsx +2 -2
- package/frontend/src/pages/MeshPage.spec.tsx +111 -2
- package/frontend/src/pages/MeshPage.tsx +95 -11
- package/frontend/src/pages/NetworkPage.spec.tsx +7 -8
- package/frontend/src/pages/NetworkPage.tsx +12 -12
- package/frontend/src/pages/NotificationsPage.tsx +9 -9
- package/frontend/src/pages/OnboardingPage.spec.tsx +7 -7
- package/frontend/src/pages/OpenWikiPage.tsx +3 -3
- package/frontend/src/pages/RulesPage.tsx +3 -3
- package/frontend/src/pages/SecretsPage.spec.tsx +5 -5
- package/frontend/src/pages/SecretsPage.tsx +13 -14
- package/frontend/src/pages/SettingsPage.spec.tsx +6 -6
- package/frontend/src/pages/SettingsPage.tsx +2 -2
- package/frontend/src/pages/SkillsPage.spec.tsx +1 -2
- package/frontend/src/pages/SkillsPage.tsx +3 -3
- package/frontend/src/pages/TasksPage.spec.tsx +3 -3
- package/frontend/src/pages/UsagePage.spec.tsx +2 -2
- package/frontend/src/pages/UsagePage.tsx +1 -1
- package/frontend/src/pages/WebhooksPage.spec.tsx +1 -1
- package/frontend/src/pages/WebhooksPage.tsx +19 -19
- package/frontend/src/setupTests.ts +4 -4
- package/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/SKILL.md +92 -0
- package/scaffold/.agent/skills/total-recall/modules/agents/agents.yml +15 -13
- package/scaffold/.agent/skills/total-recall/research-queue.jsonl +0 -1
- package/src/cli/secret.mjs +93 -12
- package/src/core/browser-session.mjs +185 -0
- package/src/core/daemon-loop.mjs +28 -1
- package/src/core/embeddings.mjs +52 -10
- package/src/core/headscale-client.mjs +227 -0
- package/src/core/mesh-enroll.mjs +439 -0
- package/src/core/ollama-embeddings.mjs +336 -0
- package/src/core/provider-catalog.mjs +175 -6
- package/src/core/provider-rotation-recipes.mjs +299 -0
- package/src/core/rotation-capability.mjs +189 -0
- package/src/core/secrets-rotate.mjs +192 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +20 -0
- package/src/server/routes/embeddings.mjs +111 -0
- package/src/server/routes/headscale.mjs +21 -81
- package/src/server/routes/mesh.mjs +41 -0
- package/frontend/dist/assets/index-B4RASWbV.css +0 -1
package/frontend/dist/index.html
CHANGED
|
@@ -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-
|
|
21
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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 }:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|
package/frontend/src/api/mesh.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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<
|
|
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<
|
|
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<
|
|
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
|
|
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<
|
|
21
|
+
async function parseJsonOrThrow(res: Response, label: string): Promise<unknown> {
|
|
22
22
|
const text = await res.text();
|
|
23
|
-
let data:
|
|
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
|
-
(
|
|
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> {
|
|
@@ -30,7 +30,7 @@ describe('BrainSelector', () => {
|
|
|
30
30
|
},
|
|
31
31
|
],
|
|
32
32
|
}),
|
|
33
|
-
} as
|
|
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
|
|
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
|
|
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
|
|
90
|
+
} as never);
|
|
91
91
|
|
|
92
92
|
render(<BrainSelector activeBrainId="global" onBrainChange={vi.fn()} />);
|
|
93
93
|
await waitFor(() => {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
+
})
|