@polderlabs/bizar 5.4.0 → 5.5.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/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js → EnvVarsSection-L413CQEI.js} +3 -3
- package/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js.map → EnvVarsSection-L413CQEI.js.map} +1 -1
- package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js → MobileChat-0kGQb1S6.js} +1 -1
- package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js.map → MobileChat-0kGQb1S6.js.map} +1 -1
- package/bizar-dash/dist/assets/MobileSettings-Cq1oFIdO.js +1 -0
- package/bizar-dash/dist/assets/{MobileSettings-CVW9VhEo.js.map → MobileSettings-Cq1oFIdO.js.map} +1 -1
- package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js → Toast-C6f1zFJ2.js} +1 -1
- package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js.map → Toast-C6f1zFJ2.js.map} +1 -1
- package/bizar-dash/dist/assets/{icons-OMEGx5KA.js → icons-BsWXWkEF.js} +121 -126
- package/bizar-dash/dist/assets/icons-BsWXWkEF.js.map +1 -0
- package/bizar-dash/dist/assets/main-BklYy02e.css +1 -0
- package/bizar-dash/dist/assets/main-CBTgYSeG.js +18 -0
- package/bizar-dash/dist/assets/main-CBTgYSeG.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-CsaN4CxH.js +1 -0
- package/bizar-dash/dist/assets/{mobile-XN2P7YfM.js.map → mobile-CsaN4CxH.js.map} +1 -1
- package/bizar-dash/dist/assets/{mobile-layout-D6V-7WnA.css → mobile-layout-CJnZNLy3.css} +1 -1
- package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js +2 -0
- package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js.map +1 -0
- package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js → useSlashCommands-DGJcC9iQ.js} +2 -2
- package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js.map → useSlashCommands-DGJcC9iQ.js.map} +1 -1
- package/bizar-dash/dist/index.html +8 -8
- package/bizar-dash/dist/mobile.html +4 -4
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/server/backup-store.mjs +6 -1
- package/bizar-dash/src/server/bg-spawner.mjs +574 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +242 -0
- package/bizar-dash/src/server/memory-store.mjs +43 -3
- package/bizar-dash/src/server/routes/background.mjs +143 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +10 -0
- package/bizar-dash/src/server/routes/memory.mjs +8 -0
- package/bizar-dash/src/server/server.mjs +22 -0
- package/bizar-dash/src/web/App.tsx +3 -0
- package/bizar-dash/src/web/MobileApp.tsx +1 -10
- package/bizar-dash/src/web/components/InstallConfirmDialog.tsx +1 -1
- package/bizar-dash/src/web/components/SettingsNav.tsx +1 -10
- package/bizar-dash/src/web/lib/types.ts +39 -1
- package/bizar-dash/src/web/styles/main.css +43 -0
- package/bizar-dash/src/web/styles/mobile-layout.css +7 -5
- package/bizar-dash/src/web/styles/settings.css +46 -0
- package/bizar-dash/src/web/views/BackgroundAgents.tsx +399 -30
- package/bizar-dash/src/web/views/Settings.tsx +12 -12
- package/bizar-dash/src/web/views/SpawnAgentModal.tsx +221 -0
- package/bizar-dash/tests/BackgroundAgents.test.tsx +160 -0
- package/bizar-dash/tests/background-pause-resume.test.mjs +46 -0
- package/bizar-dash/tests/background-spawn-from-ui.test.mjs +36 -0
- package/bizar-dash/tests/background-steer.test.mjs +42 -0
- package/bizar-dash/tests/lightrag-startup-hook.test.mjs +206 -0
- package/bizar-dash/tests/memory-auto-reindex.test.mjs +196 -0
- package/bizar-dash/tests/memory-cli.test.mjs +1 -1
- package/bizar-dash/tests/memory-default-vault.test.mjs +90 -3
- package/bizar-dash/tests/memory-lightrag-extended.test.mjs +131 -0
- package/bizar-dash/tests/mobile-layout.test.tsx +36 -0
- package/bizar-dash/tests/settings-layout.test.tsx +3 -3
- package/bizar-dash/tests/settings-nav.test.tsx +7 -5
- package/cli/bin.mjs +16 -0
- package/cli/commands/deploy/docker.mjs +2 -1
- package/cli/commands/lightrag.mjs +171 -0
- package/cli/commands/service.mjs +7 -0
- package/cli/commands/util.mjs +8 -1
- package/cli/init.mjs +1 -1
- package/cli/post-install-smoke.mjs +230 -0
- package/cli/provision.mjs +177 -0
- package/cli/provision.test.mjs +180 -0
- package/cli/service-controller.mjs +378 -28
- package/cli/service-controller.test.mjs +100 -0
- package/cli/service-env.mjs +139 -0
- package/cli/service.mjs +23 -0
- package/config/agents/_shared/AGENT_BASELINE.md +6 -6
- package/install.sh +87 -6
- package/package.json +1 -1
- package/plugins/bizar/index.ts +147 -0
- package/plugins/bizar/src/background-state.ts +84 -5
- package/plugins/bizar/src/background.ts +388 -10
- package/plugins/bizar/src/hooks/memory-inject.ts +247 -0
- package/plugins/bizar/src/hooks/memory-write-on-end.ts +188 -0
- package/plugins/bizar/src/opencode-runner.ts +87 -1
- package/plugins/bizar/src/tools/bg-pause.ts +77 -0
- package/plugins/bizar/src/tools/bg-report-progress.ts +101 -0
- package/plugins/bizar/src/tools/bg-resume.ts +72 -0
- package/plugins/bizar/src/tools/bg-send-message.ts +105 -0
- package/plugins/bizar/src/tools/bg-spawn.ts +13 -6
- package/plugins/bizar/src/tools/bg-status.ts +10 -0
- package/plugins/bizar/src/tools/memory-list.ts +135 -0
- package/plugins/bizar/src/tools/memory-read.ts +142 -0
- package/plugins/bizar/src/tools/memory-search.ts +228 -0
- package/plugins/bizar/src/tools/memory-write.ts +183 -0
- package/plugins/bizar/tests/memory-write-on-end.test.ts +92 -0
- package/plugins/bizar/tests/tools/bg-pause.test.ts +64 -0
- package/plugins/bizar/tests/tools/bg-report-progress.test.ts +82 -0
- package/plugins/bizar/tests/tools/bg-resume.test.ts +43 -0
- package/plugins/bizar/tests/tools/bg-send-message.test.ts +46 -0
- package/bizar-dash/dist/assets/MobileSettings-CVW9VhEo.js +0 -1
- package/bizar-dash/dist/assets/icons-OMEGx5KA.js.map +0 -1
- package/bizar-dash/dist/assets/main-CMAGZouj.js +0 -16
- package/bizar-dash/dist/assets/main-CMAGZouj.js.map +0 -1
- package/bizar-dash/dist/assets/main-O2tZVdHm.css +0 -1
- package/bizar-dash/dist/assets/mobile-XN2P7YfM.js +0 -1
- package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js +0 -2
- package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js.map +0 -1
|
@@ -217,6 +217,69 @@ function redactLightRAG(cfg) {
|
|
|
217
217
|
};
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
/**
|
|
221
|
+
* v5.x — Detect which LLM provider is available for LightRAG.
|
|
222
|
+
*
|
|
223
|
+
* Decision order:
|
|
224
|
+
* 1. If `llmBinding === 'ollama'`, do a quick async probe of
|
|
225
|
+
* localhost:11434 (or the configured `llmBindingHost`).
|
|
226
|
+
* If reachable → ollama available. If unreachable but an API key
|
|
227
|
+
* is configured for OpenAI / Anthropic / MiniMax → use that instead.
|
|
228
|
+
* 2. For cloud bindings (openai / anthropic / minimax / etc.),
|
|
229
|
+
* check whether the corresponding env var is set. If yes → available.
|
|
230
|
+
* 3. If no provider is detectable, return null and the caller logs
|
|
231
|
+
* a warning and skips reindex.
|
|
232
|
+
*
|
|
233
|
+
* Returns { provider, available, detail } or null.
|
|
234
|
+
*/
|
|
235
|
+
export async function detectAvailableLLM(config) {
|
|
236
|
+
const binding = config.llmBinding || 'ollama';
|
|
237
|
+
|
|
238
|
+
if (binding === 'ollama') {
|
|
239
|
+
const ollamaHost = config.llmBindingHost || 'http://localhost:11434';
|
|
240
|
+
// Quick async probe — give it 1.5s to connect.
|
|
241
|
+
try {
|
|
242
|
+
const controller = new AbortController();
|
|
243
|
+
const t = setTimeout(() => controller.abort(), 1500);
|
|
244
|
+
const res = await fetch(`${ollamaHost}/`, {
|
|
245
|
+
method: 'HEAD',
|
|
246
|
+
signal: controller.signal,
|
|
247
|
+
});
|
|
248
|
+
clearTimeout(t);
|
|
249
|
+
if (res.ok || res.status < 500) {
|
|
250
|
+
return { provider: 'ollama', available: true, detail: ollamaHost };
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
// Fall through — ollama not reachable.
|
|
254
|
+
}
|
|
255
|
+
// ollama not reachable; check for cloud API keys as fallback.
|
|
256
|
+
const hasOpenAI = !!(process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY_2);
|
|
257
|
+
const hasAnthropic = !!(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY_2);
|
|
258
|
+
const hasMinimax = !!(process.env.MINIMAX_API_KEY || process.env.MINIMAX_API_KEY_2);
|
|
259
|
+
if (hasOpenAI || hasAnthropic || hasMinimax) {
|
|
260
|
+
const provider = hasOpenAI ? 'openai' : hasAnthropic ? 'anthropic' : 'minimax';
|
|
261
|
+
return { provider, available: true, detail: `${provider} (ollama unreachable, using API key)` };
|
|
262
|
+
}
|
|
263
|
+
return null; // no ollama, no API key
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Cloud bindings — available if API key is set.
|
|
267
|
+
const apiKeyEnvVars = {
|
|
268
|
+
openai: ['OPENAI_API_KEY', 'OPENAI_API_KEY_2'],
|
|
269
|
+
anthropic: ['ANTHROPIC_API_KEY', 'ANTHROPIC_API_KEY_2'],
|
|
270
|
+
minimax: ['MINIMAX_API_KEY', 'MINIMAX_API_KEY_2'],
|
|
271
|
+
azure_openai: ['AZURE_OPENAI_API_KEY'],
|
|
272
|
+
gemini: ['GEMINI_API_KEY'],
|
|
273
|
+
};
|
|
274
|
+
const relevantKeys = apiKeyEnvVars[binding] || [];
|
|
275
|
+
const hasKey = relevantKeys.some((k) => process.env[k]);
|
|
276
|
+
if (hasKey) {
|
|
277
|
+
return { provider: binding, available: true, detail: binding };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return null; // configured binding but no key detected
|
|
281
|
+
}
|
|
282
|
+
|
|
220
283
|
/**
|
|
221
284
|
* Resolve the LightRAG config for a project.
|
|
222
285
|
* Pulls from `.bizar/memory.json` (lightrag block) and applies defaults.
|
|
@@ -539,6 +602,11 @@ export async function stopServer(config, { logger } = {}) {
|
|
|
539
602
|
/**
|
|
540
603
|
* Idempotent: returns immediately if the server is healthy. Otherwise
|
|
541
604
|
* starts it. Returns { ok, started, pid?, error? }.
|
|
605
|
+
*
|
|
606
|
+
* v5.x — Before starting, calls `detectAvailableLLM` to check whether
|
|
607
|
+
* the configured LLM provider is reachable. If no provider is detected
|
|
608
|
+
* (ollama unreachable + no API key), returns { ok: false } with a
|
|
609
|
+
* descriptive error rather than starting LightRAG in a broken state.
|
|
542
610
|
*/
|
|
543
611
|
export async function ensureRunning(config, opts) {
|
|
544
612
|
if (!config.enabled) {
|
|
@@ -547,10 +615,116 @@ export async function ensureRunning(config, opts) {
|
|
|
547
615
|
if (await isRunning(config)) {
|
|
548
616
|
return { ok: true, started: false };
|
|
549
617
|
}
|
|
618
|
+
// v5.x — probe LLM availability before attempting to start.
|
|
619
|
+
const llm = await detectAvailableLLM(config);
|
|
620
|
+
if (!llm) {
|
|
621
|
+
const binding = config.llmBinding || 'ollama';
|
|
622
|
+
return {
|
|
623
|
+
ok: false,
|
|
624
|
+
started: false,
|
|
625
|
+
error: `lightrag cannot start: ${binding} is not reachable and no API key is configured. `
|
|
626
|
+
+ `Set an API key env var (OPENAI_API_KEY, ANTHROPIC_API_KEY, or MINIMAX_API_KEY) `
|
|
627
|
+
+ `or ensure ollama is running, then try again.`,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
550
630
|
const r = await startServer(config, opts);
|
|
551
631
|
return { ok: r.ok, started: r.ok, pid: r.pid, error: r.error };
|
|
552
632
|
}
|
|
553
633
|
|
|
634
|
+
/**
|
|
635
|
+
* v5.x — LightRAG startup hook (issue #6).
|
|
636
|
+
*
|
|
637
|
+
* Called by server.mjs on dashboard boot (and exposed via
|
|
638
|
+
* `bizar lightrag autostart`). Reads the active project from
|
|
639
|
+
* `state.projectRoot` (or whatever is passed in), resolves the
|
|
640
|
+
* LightRAG config, and starts the server if the user's settings
|
|
641
|
+
* allow it.
|
|
642
|
+
*
|
|
643
|
+
* Decision flow:
|
|
644
|
+
* 1. Resolve config from .bizar/memory.json — if `lightrag.enabled`
|
|
645
|
+
* is `false`, skip silently (return ok=true, started=false,
|
|
646
|
+
* reason='disabled').
|
|
647
|
+
* 2. Read the env override `BIZAR_LIGHTRAG_AUTOSTART` — if set to
|
|
648
|
+
* '0' / 'false' / 'no', skip.
|
|
649
|
+
* 3. Check if the server is already running — if so, return
|
|
650
|
+
* ok=true, started=false, reason='already-running'.
|
|
651
|
+
* 4. Otherwise call `startServer(config)` and return its result.
|
|
652
|
+
*
|
|
653
|
+
* Errors are caught and logged as warnings — startup must not fail
|
|
654
|
+
* the dashboard just because LightRAG couldn't be started (the user
|
|
655
|
+
* may not have the binary installed, may be running the dashboard
|
|
656
|
+
* in an environment where LightRAG isn't needed, etc.).
|
|
657
|
+
*
|
|
658
|
+
* @param {string} projectRoot — path to the active project root
|
|
659
|
+
* (where `.bizar/memory.json` lives). When omitted, the hook
|
|
660
|
+
* returns ok=false with reason='no-project' (the caller didn't
|
|
661
|
+
* tell us where to look).
|
|
662
|
+
* @param {{ logger?: { info?: Function, warn?: Function } }} [opts]
|
|
663
|
+
* @returns {Promise<{ ok: boolean, started: boolean, pid?: number|null, reason?: string, error?: string }>}
|
|
664
|
+
*/
|
|
665
|
+
export async function lightragStartupHook(projectRoot, opts = {}) {
|
|
666
|
+
const log = (...a) => opts?.logger?.info?.('[lightrag]', ...a) || console.log('[lightrag]', ...a);
|
|
667
|
+
const warn = (...a) => opts?.logger?.warn?.('[lightrag]', ...a) || console.warn('[lightrag]', ...a);
|
|
668
|
+
|
|
669
|
+
// 1. No project → can't resolve config.
|
|
670
|
+
if (!projectRoot || typeof projectRoot !== 'string') {
|
|
671
|
+
return { ok: false, started: false, reason: 'no-project' };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// 2. Resolve config (from .bizar/memory.json).
|
|
675
|
+
let config;
|
|
676
|
+
try {
|
|
677
|
+
config = resolveLightRAGConfig(projectRoot);
|
|
678
|
+
} catch (err) {
|
|
679
|
+
warn('startup hook: failed to resolve config:', err?.message || err);
|
|
680
|
+
return { ok: false, started: false, error: err?.message || String(err) };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// 3. Check lightrag.enabled flag.
|
|
684
|
+
if (config.enabled === false) {
|
|
685
|
+
log('startup hook: lightrag disabled in .bizar/memory.json, skipping');
|
|
686
|
+
return { ok: true, started: false, reason: 'disabled' };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// 4. Check env-var override.
|
|
690
|
+
const envAuto = process.env.BIZAR_LIGHTRAG_AUTOSTART;
|
|
691
|
+
if (typeof envAuto === 'string' && /^(0|false|no|off)$/i.test(envAuto.trim())) {
|
|
692
|
+
log(`startup hook: BIZAR_LIGHTRAG_AUTOSTART=${envAuto}, skipping`);
|
|
693
|
+
return { ok: true, started: false, reason: 'env-disabled' };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// 5. Already running? Nothing to do.
|
|
697
|
+
try {
|
|
698
|
+
if (await isRunning(config)) {
|
|
699
|
+
log('startup hook: lightrag already running');
|
|
700
|
+
return { ok: true, started: false, reason: 'already-running' };
|
|
701
|
+
}
|
|
702
|
+
} catch (err) {
|
|
703
|
+
warn('startup hook: isRunning probe failed:', err?.message || err);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// 6. Try to start.
|
|
707
|
+
try {
|
|
708
|
+
const r = await startServer(config, opts);
|
|
709
|
+
if (r.ok) {
|
|
710
|
+
if (r.alreadyRunning) {
|
|
711
|
+
log(`startup hook: lightrag already running (pid=${r.pid})`);
|
|
712
|
+
return { ok: true, started: false, reason: 'already-running', pid: r.pid };
|
|
713
|
+
}
|
|
714
|
+
log(`startup hook: lightrag started (pid=${r.pid})`);
|
|
715
|
+
return { ok: true, started: true, pid: r.pid };
|
|
716
|
+
}
|
|
717
|
+
warn(`startup hook: start failed: ${r.error || 'unknown'}`);
|
|
718
|
+
return { ok: false, started: false, error: r.error || 'start failed' };
|
|
719
|
+
} catch (err) {
|
|
720
|
+
// Catch-all: dashboard boot must not fail because LightRAG couldn't
|
|
721
|
+
// start. Common causes: binary missing (lightrag-server not on PATH),
|
|
722
|
+
// port already in use, working-dir not writable.
|
|
723
|
+
warn('startup hook: caught error:', err?.message || err);
|
|
724
|
+
return { ok: false, started: false, error: err?.message || String(err) };
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
554
728
|
// ── Document insertion ────────────────────────────────────────────────────
|
|
555
729
|
|
|
556
730
|
/**
|
|
@@ -784,6 +958,74 @@ export async function reindexVault(projectRoot, opts = {}) {
|
|
|
784
958
|
};
|
|
785
959
|
}
|
|
786
960
|
|
|
961
|
+
/**
|
|
962
|
+
* v5.x — Re-index a single note into LightRAG.
|
|
963
|
+
*
|
|
964
|
+
* Use this for incremental updates (e.g. after a note is written or
|
|
965
|
+
* updated via the REST API). Unlike `reindexVault` which walks the entire
|
|
966
|
+
* vault, this only inserts one document.
|
|
967
|
+
*
|
|
968
|
+
* Returns { ok, inserted, failed, error? }.
|
|
969
|
+
*/
|
|
970
|
+
export async function reindexSingleNote(projectRoot, relPath, opts = {}) {
|
|
971
|
+
const config = resolveLightRAGConfig(projectRoot);
|
|
972
|
+
if (!config.enabled) {
|
|
973
|
+
return { ok: false, error: 'lightrag disabled' };
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Resolve vault path (same logic as reindexVault).
|
|
977
|
+
const memPath = join(projectRoot, '.bizar', 'memory.json');
|
|
978
|
+
if (!existsSync(memPath)) {
|
|
979
|
+
return { ok: false, error: 'memory not initialized' };
|
|
980
|
+
}
|
|
981
|
+
const mem = JSON.parse(readFileSync(memPath, 'utf8'));
|
|
982
|
+
const mode = mem.memoryRepo?.mode || 'local-only';
|
|
983
|
+
let vaultRoot;
|
|
984
|
+
const projectId = mem.projectId || basename(projectRoot);
|
|
985
|
+
if (mode === 'local-only') {
|
|
986
|
+
vaultRoot = join(projectRoot, '.obsidian');
|
|
987
|
+
} else {
|
|
988
|
+
const rawPath = mem.memoryRepo?.path || '';
|
|
989
|
+
let expanded;
|
|
990
|
+
if (!rawPath) {
|
|
991
|
+
expanded = join(projectRoot, '.bizar', 'memory');
|
|
992
|
+
} else if (rawPath.startsWith('~')) {
|
|
993
|
+
expanded = join(homedir(), rawPath.slice(1));
|
|
994
|
+
} else if (rawPath.startsWith('/')) {
|
|
995
|
+
expanded = rawPath;
|
|
996
|
+
} else {
|
|
997
|
+
expanded = join(projectRoot, rawPath);
|
|
998
|
+
}
|
|
999
|
+
vaultRoot = join(expanded, 'projects', projectId);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const fullPath = join(vaultRoot, relPath);
|
|
1003
|
+
if (!fullPath.startsWith(vaultRoot)) {
|
|
1004
|
+
return { ok: false, error: 'path traversal attempt blocked' };
|
|
1005
|
+
}
|
|
1006
|
+
if (!existsSync(fullPath)) {
|
|
1007
|
+
return { ok: false, error: `note not found: ${relPath}` };
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Ensure server is running (uses the same LLM-detection logic as reindexVault).
|
|
1011
|
+
const runResult = await ensureRunning(config, opts);
|
|
1012
|
+
if (!runResult.ok) {
|
|
1013
|
+
return { ok: false, error: runResult.error || 'lightrag server unavailable' };
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const raw = readFileSync(fullPath, 'utf8');
|
|
1017
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
1018
|
+
const note = { relPath, frontmatter, body };
|
|
1019
|
+
const r = await insertNote(config, projectId, note);
|
|
1020
|
+
|
|
1021
|
+
return {
|
|
1022
|
+
ok: r.ok,
|
|
1023
|
+
inserted: r.ok ? 1 : 0,
|
|
1024
|
+
failed: r.ok ? 0 : 1,
|
|
1025
|
+
error: r.error,
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
|
|
787
1029
|
/**
|
|
788
1030
|
* Query LightRAG. Returns { ok, mode, response, error? }.
|
|
789
1031
|
*
|
|
@@ -4,11 +4,17 @@
|
|
|
4
4
|
* MarkdownMemoryStore — reads/writes Obsidian-compatible `.md` notes with YAML
|
|
5
5
|
* frontmatter for the Bizar Memory Service. Supports three modes:
|
|
6
6
|
* local-only — vault at <projectRoot>/.obsidian/
|
|
7
|
-
* managed — vault at ~/.
|
|
7
|
+
* managed — vault at ~/.bizar_memory/<repoName>/projects/<projectId>/
|
|
8
8
|
* linked — alias for managed
|
|
9
9
|
*
|
|
10
10
|
* The project namespace directory is created LAZY on first writeNote, not at
|
|
11
11
|
* initVault time (F7 invariant).
|
|
12
|
+
*
|
|
13
|
+
* v5.x — Default vault root relocated from `~/.local/share/bizar/memory` to
|
|
14
|
+
* `~/.bizar_memory` per issue #5. The old path still works if `BIZAR_MEMORY_VAULT`
|
|
15
|
+
* is set; on first run, `ensureVaultExists()` emits a one-time notice when the
|
|
16
|
+
* old path exists and the new one doesn't, but does NOT auto-migrate (too
|
|
17
|
+
* risky to move user data without consent).
|
|
12
18
|
*/
|
|
13
19
|
|
|
14
20
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
|
|
@@ -27,15 +33,26 @@ const HOME = homedir();
|
|
|
27
33
|
/**
|
|
28
34
|
* Default vault root — auto-created on first use.
|
|
29
35
|
* Override with BIZAR_MEMORY_VAULT env var.
|
|
36
|
+
*
|
|
37
|
+
* v5.x — moved from `~/.local/share/bizar/memory` to `~/.bizar_memory` so the
|
|
38
|
+
* vault lives at a memorable, top-level location in the user's home.
|
|
30
39
|
*/
|
|
31
|
-
export const DEFAULT_MEMORY_VAULT = join(HOME, '.
|
|
40
|
+
export const DEFAULT_MEMORY_VAULT = join(HOME, '.bizar_memory');
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* v5.x — Legacy vault path kept for migration detection only. The old
|
|
44
|
+
* `~/.local/share/bizar/memory` location is no longer the default; if a
|
|
45
|
+
* user still has notes there, `ensureVaultExists()` logs a one-time
|
|
46
|
+
* notice pointing them at the migration command.
|
|
47
|
+
*/
|
|
48
|
+
export const LEGACY_MEMORY_VAULT = join(HOME, '.local', 'share', 'bizar', 'memory');
|
|
32
49
|
|
|
33
50
|
/**
|
|
34
51
|
* Default git remote — set via BIZAR_MEMORY_GIT_REMOTE to enable sync.
|
|
35
52
|
*/
|
|
36
53
|
export const DEFAULT_GIT_REMOTE = process.env.BIZAR_MEMORY_GIT_REMOTE || null;
|
|
37
54
|
|
|
38
|
-
const BIZAR_MEMORY_ROOT = join(HOME, '.
|
|
55
|
+
const BIZAR_MEMORY_ROOT = join(HOME, '.bizar_memory');
|
|
39
56
|
|
|
40
57
|
/**
|
|
41
58
|
* Return the effective vault root for the default memory vault.
|
|
@@ -51,11 +68,34 @@ export function currentVault() {
|
|
|
51
68
|
* Ensure the default memory vault directory exists and is git-initialised.
|
|
52
69
|
* Idempotent — calling multiple times is safe.
|
|
53
70
|
*
|
|
71
|
+
* v5.x — On first call with a fresh default vault, if the legacy
|
|
72
|
+
* `~/.local/share/bizar/memory` path exists and the new
|
|
73
|
+
* `~/.bizar_memory` doesn't, log a one-time migration notice. We do
|
|
74
|
+
* NOT auto-migrate: the user owns the data and may have already moved
|
|
75
|
+
* it, or may want to keep the old path via `BIZAR_MEMORY_VAULT`.
|
|
76
|
+
*
|
|
54
77
|
* @returns {string} the vault path that was ensured
|
|
55
78
|
*/
|
|
56
79
|
export function ensureVaultExists() {
|
|
57
80
|
const vault = currentVault();
|
|
58
81
|
if (!existsSync(vault)) {
|
|
82
|
+
// v5.x — Migration notice: legacy path exists, new default does not.
|
|
83
|
+
// Only emit when the user is using the default (no env override) and
|
|
84
|
+
// the new path doesn't exist yet.
|
|
85
|
+
if (
|
|
86
|
+
!process.env.BIZAR_MEMORY_VAULT &&
|
|
87
|
+
vault === DEFAULT_MEMORY_VAULT &&
|
|
88
|
+
existsSync(LEGACY_MEMORY_VAULT)
|
|
89
|
+
) {
|
|
90
|
+
console.warn(
|
|
91
|
+
`[memory-store] Detected a legacy memory vault at ${LEGACY_MEMORY_VAULT}.\n` +
|
|
92
|
+
`[memory-store] The default location has moved to ${DEFAULT_MEMORY_VAULT}.\n` +
|
|
93
|
+
`[memory-store] To keep using the old vault, set BIZAR_MEMORY_VAULT=${LEGACY_MEMORY_VAULT}.\n` +
|
|
94
|
+
`[memory-store] To migrate, move the contents manually: ` +
|
|
95
|
+
`mv ${LEGACY_MEMORY_VAULT}/* ${DEFAULT_MEMORY_VAULT}/\n` +
|
|
96
|
+
`[memory-store] (not auto-migrated — please review before moving user data).`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
59
99
|
mkdirSync(vault, { recursive: true, mode: 0o700 });
|
|
60
100
|
if (!existsSync(join(vault, '.git'))) {
|
|
61
101
|
try {
|
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
* src/server/routes/background.mjs
|
|
3
3
|
*
|
|
4
4
|
* /api/background — list
|
|
5
|
+
* /api/background (POST) — spawn from UI (v5.x)
|
|
5
6
|
* /api/background/:id — single instance
|
|
6
7
|
* /api/background/:id/output — tail captured output
|
|
8
|
+
* /api/background/:id/tool-calls (GET) — tool call history (v5.x)
|
|
7
9
|
* /api/background/:id/tmux — tmux attach metadata
|
|
8
10
|
* /api/background/:id/message (POST) — send a follow-up message
|
|
11
|
+
* /api/background/:id/pause (POST) — pause subprocess (v5.x)
|
|
12
|
+
* /api/background/:id/resume (POST) — resume subprocess (v5.x)
|
|
13
|
+
* /api/background/:id/steer (POST) — kill+respawn with new prompt (v5.x)
|
|
9
14
|
* /api/background/:id/retry (POST) — manual unstick (v3.11.0)
|
|
10
15
|
* /api/background/:id (DELETE) — kill
|
|
11
16
|
*
|
|
@@ -23,12 +28,68 @@ import { wrap } from './_shared.mjs';
|
|
|
23
28
|
export function createBackgroundRouter({ broadcast }) {
|
|
24
29
|
const router = Router();
|
|
25
30
|
|
|
31
|
+
// Wire bg-spawner's broadcast so spawned agents broadcast on the
|
|
32
|
+
// existing WS bus (mirroring the way `bg-poller.mjs` does it).
|
|
33
|
+
import('../bg-spawner.mjs').then((m) => {
|
|
34
|
+
if (m && typeof m.configureSpawner === 'function') {
|
|
35
|
+
m.configureSpawner({ broadcast });
|
|
36
|
+
}
|
|
37
|
+
}).catch(() => { /* ignore — spawner optional */ });
|
|
38
|
+
|
|
26
39
|
router.get('/background', wrap(async (_req, res) => {
|
|
27
40
|
const { backgroundStore } = await import('../background-store.mjs');
|
|
28
41
|
const instances = await backgroundStore.list();
|
|
29
42
|
res.json({ instances, status: backgroundStore.status() });
|
|
30
43
|
}));
|
|
31
44
|
|
|
45
|
+
// v5.x — Spawn from UI. Body: { agent, prompt, model?, persistent?, maxRestarts?, tags?, timeoutMs?, worktree? }
|
|
46
|
+
router.post('/background', wrap(async (req, res) => {
|
|
47
|
+
const body = req.body || {};
|
|
48
|
+
const agent = String(body.agent || '').trim();
|
|
49
|
+
const prompt = String(body.prompt || '').trim();
|
|
50
|
+
const worktree = body.worktree ? String(body.worktree) : process.cwd();
|
|
51
|
+
if (!agent || !prompt) {
|
|
52
|
+
res.status(400).json({ error: 'bad_request', message: 'agent and prompt are required' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
let model;
|
|
56
|
+
if (body.model && typeof body.model === 'string') {
|
|
57
|
+
const idx = body.model.indexOf('/');
|
|
58
|
+
if (idx > 0) {
|
|
59
|
+
model = {
|
|
60
|
+
providerID: body.model.slice(0, idx).trim(),
|
|
61
|
+
modelID: body.model.slice(idx + 1).trim(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const tags = Array.isArray(body.tags)
|
|
66
|
+
? body.tags.filter((t) => typeof t === 'string').slice(0, 10)
|
|
67
|
+
: undefined;
|
|
68
|
+
const { spawnBgAgent } = await import('../bg-spawner.mjs');
|
|
69
|
+
const result = await spawnBgAgent({
|
|
70
|
+
agent,
|
|
71
|
+
prompt,
|
|
72
|
+
worktree,
|
|
73
|
+
model,
|
|
74
|
+
timeoutMs: Number(body.timeoutMs) || 300_000,
|
|
75
|
+
persistent: Boolean(body.persistent),
|
|
76
|
+
maxRestarts: Number(body.maxRestarts) || 3,
|
|
77
|
+
tags,
|
|
78
|
+
});
|
|
79
|
+
if (result.error) {
|
|
80
|
+
res.status(500).json({ error: 'spawn_failed', message: result.error, instanceId: result.instanceId });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
res.status(201).json({
|
|
84
|
+
instanceId: result.instanceId,
|
|
85
|
+
sessionId: result.sessionId ?? null,
|
|
86
|
+
processId: result.processId ?? null,
|
|
87
|
+
status: 'pending',
|
|
88
|
+
agent,
|
|
89
|
+
worktree,
|
|
90
|
+
});
|
|
91
|
+
}));
|
|
92
|
+
|
|
32
93
|
router.get('/background/:id', wrap(async (req, res) => {
|
|
33
94
|
const { backgroundStore } = await import('../background-store.mjs');
|
|
34
95
|
const inst = backgroundStore.get(req.params.id);
|
|
@@ -46,6 +107,73 @@ export function createBackgroundRouter({ broadcast }) {
|
|
|
46
107
|
res.json(result);
|
|
47
108
|
}));
|
|
48
109
|
|
|
110
|
+
// v5.x — Tool-call history. The state file carries a `toolCalls`
|
|
111
|
+
// array populated by the plugin's InstanceManager as it observes
|
|
112
|
+
// opencode events. This endpoint is read-only; the plugin owns the
|
|
113
|
+
// shape.
|
|
114
|
+
router.get('/background/:id/tool-calls', wrap(async (req, res) => {
|
|
115
|
+
const { backgroundStore } = await import('../background-store.mjs');
|
|
116
|
+
const inst = backgroundStore.get(req.params.id);
|
|
117
|
+
if (!inst) {
|
|
118
|
+
res.status(404).json({ error: 'not_found' });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const calls = Array.isArray(inst.toolCalls) ? inst.toolCalls : [];
|
|
122
|
+
res.json({ instanceId: req.params.id, toolCalls: calls, count: calls.length });
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
// v5.x — Pause. SIGSTOP the subprocess.
|
|
126
|
+
router.post('/background/:id/pause', wrap(async (req, res) => {
|
|
127
|
+
const { pauseBgAgent } = await import('../bg-spawner.mjs');
|
|
128
|
+
const result = pauseBgAgent(req.params.id);
|
|
129
|
+
if (!result.ok) {
|
|
130
|
+
res.status(result.error === 'unsupported_on_win32' ? 501 : 400).json({ ok: false, error: result.error });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
broadcast({ type: 'background:change', id: req.params.id, status: 'paused' });
|
|
134
|
+
res.json({ ok: true, status: 'paused' });
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
// v5.x — Resume. SIGCONT the subprocess.
|
|
138
|
+
router.post('/background/:id/resume', wrap(async (req, res) => {
|
|
139
|
+
const { resumeBgAgent } = await import('../bg-spawner.mjs');
|
|
140
|
+
const result = resumeBgAgent(req.params.id);
|
|
141
|
+
if (!result.ok) {
|
|
142
|
+
res.status(result.error === 'unsupported_on_win32' ? 501 : 400).json({ ok: false, error: result.error });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
broadcast({ type: 'background:change', id: req.params.id, status: 'running' });
|
|
146
|
+
res.json({ ok: true, status: 'running' });
|
|
147
|
+
}));
|
|
148
|
+
|
|
149
|
+
// v5.x — Steer (kill+restart with appended prompt). Body: { message }.
|
|
150
|
+
router.post('/background/:id/steer', wrap(async (req, res) => {
|
|
151
|
+
const message = String((req.body && req.body.message) || '').trim();
|
|
152
|
+
if (!message) {
|
|
153
|
+
res.status(400).json({ ok: false, error: 'message_empty' });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const { steerBgAgent } = await import('../bg-spawner.mjs');
|
|
157
|
+
const result = await steerBgAgent(req.params.id, message);
|
|
158
|
+
if (!result.ok) {
|
|
159
|
+
res.status(400).json({ ok: false, error: result.error });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
broadcast({
|
|
163
|
+
type: 'background:change',
|
|
164
|
+
id: req.params.id,
|
|
165
|
+
status: 'steered',
|
|
166
|
+
newInstanceId: result.newInstanceId,
|
|
167
|
+
});
|
|
168
|
+
broadcast({
|
|
169
|
+
type: 'background:change',
|
|
170
|
+
id: result.newInstanceId,
|
|
171
|
+
status: 'pending',
|
|
172
|
+
parentInstanceId: req.params.id,
|
|
173
|
+
});
|
|
174
|
+
res.json({ ok: true, status: 'steered', newInstanceId: result.newInstanceId, processId: result.processId });
|
|
175
|
+
}));
|
|
176
|
+
|
|
49
177
|
// v3.5.5 — Tmux session metadata. The UI uses this to render an
|
|
50
178
|
// "Attach" button next to a running bg instance. Returns the
|
|
51
179
|
// computed session name, the local attach command, and whether
|
|
@@ -126,6 +254,21 @@ export function createBackgroundRouter({ broadcast }) {
|
|
|
126
254
|
}));
|
|
127
255
|
|
|
128
256
|
router.delete('/background/:id', wrap(async (req, res) => {
|
|
257
|
+
// v5.x — Prefer the dashboard-side spawner's kill for instances
|
|
258
|
+
// that we own (spawned via POST /background). Falls back to the
|
|
259
|
+
// plugin's tmux/abort path for instances spawned by the plugin.
|
|
260
|
+
try {
|
|
261
|
+
const { isAlive: spawnerAlive, killBgAgent } = await import('../bg-spawner.mjs');
|
|
262
|
+
if (spawnerAlive(req.params.id)) {
|
|
263
|
+
const r = await killBgAgent(req.params.id, { signal: 'SIGTERM' });
|
|
264
|
+
if (r.ok) {
|
|
265
|
+
broadcast({ type: 'background:change', action: 'kill', id: req.params.id, source: 'dashboard' });
|
|
266
|
+
res.json({ ok: true, instanceId: req.params.id, source: 'dashboard' });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} catch { /* ignore — fall through to legacy */ }
|
|
271
|
+
|
|
129
272
|
const { backgroundStore } = await import('../background-store.mjs');
|
|
130
273
|
// v3.5.4 (bug #3) — `kill()` is now async (it awaits the abortSession
|
|
131
274
|
// HTTP call to opencode serve, then deletes the state file, then
|
|
@@ -155,5 +155,15 @@ export function createLightragRouter({ projectRoot }) {
|
|
|
155
155
|
});
|
|
156
156
|
}));
|
|
157
157
|
|
|
158
|
+
// POST /api/lightrag/autostart — v5.x — issue #6.
|
|
159
|
+
// Runs lightragStartupHook() against the dashboard's projectRoot.
|
|
160
|
+
// Used by `bizar lightrag autostart` (CLI) and by `install.sh` to
|
|
161
|
+
// ensure the LightRAG server is up after install/update.
|
|
162
|
+
router.post('/lightrag/autostart', wrap(async (_req, res) => {
|
|
163
|
+
const { lightragStartupHook } = await import('../memory-lightrag.mjs');
|
|
164
|
+
const r = await lightragStartupHook(projectRoot);
|
|
165
|
+
res.status(r.ok ? 200 : 503).json(r);
|
|
166
|
+
}));
|
|
167
|
+
|
|
158
168
|
return router;
|
|
159
169
|
}
|
|
@@ -198,6 +198,10 @@ export function createMemoryRouter({ projectRoot }) {
|
|
|
198
198
|
try {
|
|
199
199
|
const note = writeNote(projectRoot, relPath, { frontmatter: frontmatter || {}, body: body || '' });
|
|
200
200
|
res.status(201).json(note);
|
|
201
|
+
// v5.x — auto-reindex the written note in the background (fire-and-forget).
|
|
202
|
+
getMemoryLightrag().then((m) => {
|
|
203
|
+
void m.reindexSingleNote(projectRoot, relPath).catch(() => {});
|
|
204
|
+
});
|
|
201
205
|
} catch (err) {
|
|
202
206
|
if (err.code === 'SCHEMA_VALIDATION_FAILED' || err.code === 'SECRET_DETECTED') {
|
|
203
207
|
res.status(400).json({ error: err.code, message: err.message, findings: err.findings });
|
|
@@ -1276,6 +1280,10 @@ export function createMemoryRouter({ projectRoot }) {
|
|
|
1276
1280
|
try {
|
|
1277
1281
|
const note = writeNote(projectRoot, relPath, { frontmatter: frontmatter || {}, body: body || '' });
|
|
1278
1282
|
res.json(note);
|
|
1283
|
+
// v5.x — auto-reindex the updated note in the background (fire-and-forget).
|
|
1284
|
+
getMemoryLightrag().then((m) => {
|
|
1285
|
+
void m.reindexSingleNote(projectRoot, relPath).catch(() => {});
|
|
1286
|
+
});
|
|
1279
1287
|
} catch (err) {
|
|
1280
1288
|
if (err.code === 'SCHEMA_VALIDATION_FAILED' || err.code === 'SECRET_DETECTED') {
|
|
1281
1289
|
res.status(400).json({ error: err.code, message: err.message, findings: err.findings });
|
|
@@ -473,6 +473,28 @@ export async function createServer({
|
|
|
473
473
|
console.warn('[bizar-dash] headroom startup hook skipped:', err?.message || err);
|
|
474
474
|
}
|
|
475
475
|
|
|
476
|
+
// v5.x — LightRAG startup hook (issue #6). Mirrors the headroom hook:
|
|
477
|
+
// reads config from .bizar/memory.json + env vars, then calls
|
|
478
|
+
// lightragStartupHook() which respects `lightrag.enabled` and the
|
|
479
|
+
// BIZAR_LIGHTRAG_AUTOSTART env override. All errors are caught — the
|
|
480
|
+
// dashboard must boot even when LightRAG can't start.
|
|
481
|
+
try {
|
|
482
|
+
const { lightragStartupHook } = await import('./memory-lightrag.mjs');
|
|
483
|
+
lightragStartupHook(projectRoot).then((r) => {
|
|
484
|
+
if (!r.ok) {
|
|
485
|
+
console.warn('[bizar-dash] lightragStartupHook:', r.error || r.reason || 'not started');
|
|
486
|
+
} else if (r.started) {
|
|
487
|
+
console.log(`[bizar-dash] lightrag auto-started (pid=${r.pid})`);
|
|
488
|
+
} else {
|
|
489
|
+
console.log(`[bizar-dash] lightrag: ${r.reason || 'not started'}`);
|
|
490
|
+
}
|
|
491
|
+
}).catch((err) => {
|
|
492
|
+
console.warn('[bizar-dash] lightragStartupHook error:', err?.message || err);
|
|
493
|
+
});
|
|
494
|
+
} catch (err) {
|
|
495
|
+
console.warn('[bizar-dash] lightrag startup hook skipped:', err?.message || err);
|
|
496
|
+
}
|
|
497
|
+
|
|
476
498
|
// v5.2 — Background transcription worker for voice notes. Uploads
|
|
477
499
|
// save audio immediately and enqueue the noteId here; the worker
|
|
478
500
|
// drains the queue, calls Whisper (or BIZAR_WHISPER_ENDPOINT), and
|
|
@@ -30,6 +30,7 @@ import { Tasks } from './views/Tasks';
|
|
|
30
30
|
import { Activity } from './views/Activity';
|
|
31
31
|
import { Config } from './views/Config';
|
|
32
32
|
import { SettingsView } from './views/Settings';
|
|
33
|
+
import { Marketplace } from './views/Marketplace';
|
|
33
34
|
import { Mods } from './views/Mods';
|
|
34
35
|
import { Plugins } from './views/Plugins';
|
|
35
36
|
import { ModView, type ModView as ModViewType } from './views/ModView';
|
|
@@ -47,6 +48,7 @@ import { Spinner } from './components/Spinner';
|
|
|
47
48
|
import { Button } from './components/Button';
|
|
48
49
|
import { AlertTriangle, Globe, LayoutTemplate, X } from 'lucide-react';
|
|
49
50
|
import './styles/main.css';
|
|
51
|
+
import './styles/settings.css';
|
|
50
52
|
|
|
51
53
|
type ViewProps = {
|
|
52
54
|
snapshot: Snapshot;
|
|
@@ -90,6 +92,7 @@ const VIEW_MAP: Record<string, (p: ViewProps) => React.ReactNode> = {
|
|
|
90
92
|
background: BackgroundAgents,
|
|
91
93
|
config: Config,
|
|
92
94
|
settings: SettingsView,
|
|
95
|
+
marketplace: Marketplace,
|
|
93
96
|
mods: Mods,
|
|
94
97
|
schedules: Schedules,
|
|
95
98
|
skills: Skills,
|
|
@@ -13,7 +13,6 @@ import type { Notification, Settings, Snapshot, WsMessage } from './lib/types';
|
|
|
13
13
|
import { applyTheme, applyThemeTokens } from './lib/types';
|
|
14
14
|
import { Ws } from './lib/ws';
|
|
15
15
|
import { MobileBottomNav, type MobileTab } from './mobile/MobileBottomNav';
|
|
16
|
-
import { MobileTopbar } from './mobile/MobileTopbar';
|
|
17
16
|
import { MobileLayout, type DrawerTab } from './components/MobileLayout';
|
|
18
17
|
import { MobileActivity } from './mobile/views/MobileActivity';
|
|
19
18
|
import { MobileAgents } from './mobile/views/MobileAgents';
|
|
@@ -401,20 +400,12 @@ export function MobileApp() {
|
|
|
401
400
|
onExitSettings={() => goToTab('activity')}
|
|
402
401
|
onSearch={() => setSearchOpen(true)}
|
|
403
402
|
>
|
|
404
|
-
<MobileTopbar
|
|
405
|
-
activeTab={currentView.id}
|
|
406
|
-
snapshot={snapshot}
|
|
407
|
-
onSearch={() => setSearchOpen(true)}
|
|
408
|
-
onNavigate={handleNavigate}
|
|
409
|
-
/>
|
|
410
|
-
|
|
411
|
-
<main className="mobile-content">{renderView()}</main>
|
|
412
|
-
|
|
413
403
|
{stack.length > 0 && (
|
|
414
404
|
<button type="button" className="mobile-back-btn" onClick={popView} aria-label="Go back">
|
|
415
405
|
← Back
|
|
416
406
|
</button>
|
|
417
407
|
)}
|
|
408
|
+
{renderView()}
|
|
418
409
|
</MobileLayout>
|
|
419
410
|
|
|
420
411
|
<MobileSearchModal open={searchOpen} onClose={() => setSearchOpen(false)} onNavigate={handleNavigate} />
|