@polderlabs/bizar 3.23.1 → 4.2.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/README.md +83 -16
- package/bizar-dash/.bizar/activity.log +3 -0
- package/bizar-dash/.bizar/graph/.graphify_analysis.json +1540 -0
- package/bizar-dash/.bizar/graph/.graphify_labels.json +1 -0
- package/bizar-dash/.bizar/graph/GRAPH_REPORT.md +404 -0
- package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/0661b816358db84dfdb7f10443db8a61152fd20b3fee950caf8fbf32920a1790.json +1 -0
- package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/16da06d774142a6e5d74829ad982531286a6193ef798de8f593db029e123ec32.json +1 -0
- package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/27e5697c01254c69c64c5a33896669073b40afb5ba88375102ae77fa5404136d.json +1 -0
- package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/532997898027254aa4c0932243dc71d2f55888181232b65974961934997dc85b.json +1 -0
- package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/84e3451a64c7f99f0cd6a88b0748d7426780486f53ab4935b7476b28c08f8293.json +1 -0
- package/bizar-dash/.bizar/graph/cache/ast/v0.8.46/88e5ea5f3e78fa69353fe9e6a8d1a05391d54605e25600cb125531eb5a6432ff.json +1 -0
- package/bizar-dash/.bizar/graph/cache/stat-index.json +1 -0
- package/bizar-dash/.bizar/graph/graph.html +307 -0
- package/bizar-dash/.bizar/graph/graph.json +40267 -0
- package/bizar-dash/.bizar/graph/manifest.json +762 -0
- package/bizar-dash/.graphifyignore +40 -0
- package/bizar-dash/.obsidian/README.md +14 -0
- package/bizar-dash/.obsidian/daily/2026-06-25.md +9 -0
- package/bizar-dash/.obsidian/vault.json +7 -0
- package/bizar-dash/.od-skills/web-prototype-e1429c1737/SKILL.md +103 -0
- package/bizar-dash/.od-skills/web-prototype-e1429c1737/assets/template.html +338 -0
- package/bizar-dash/.od-skills/web-prototype-e1429c1737/example.html +81 -0
- package/bizar-dash/.od-skills/web-prototype-e1429c1737/open-design.json +172 -0
- package/bizar-dash/.od-skills/web-prototype-e1429c1737/references/checklist.md +44 -0
- package/bizar-dash/.od-skills/web-prototype-e1429c1737/references/layouts.md +247 -0
- package/bizar-dash/CHANGELOG.md +474 -0
- package/bizar-dash/DESIGN.md +136 -0
- package/bizar-dash/dist/assets/main-B3RgW6FS.js +331 -0
- package/bizar-dash/dist/assets/main-B3RgW6FS.js.map +1 -0
- package/bizar-dash/dist/assets/main-DlJ8wgLR.css +1 -0
- package/bizar-dash/dist/assets/mobile-CsZQAswA.css +1 -0
- package/bizar-dash/dist/assets/mobile-DbxIw8BG.js +2 -0
- package/bizar-dash/dist/assets/mobile-DbxIw8BG.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-Dvq2d53Y.js +354 -0
- package/bizar-dash/dist/assets/mobile-Dvq2d53Y.js.map +1 -0
- package/bizar-dash/dist/index.html +40 -0
- package/bizar-dash/dist/mobile.html +22 -0
- package/bizar-dash/plans/debug-plan/comments.json +1 -0
- package/bizar-dash/plans/debug-plan/meta.json +10 -0
- package/bizar-dash/plans/debug-plan/plan.json +174 -0
- package/bizar-dash/scripts/smoke-bg-retry.mjs +246 -0
- package/bizar-dash/src/cli/dashboard-ports.mjs +488 -0
- package/bizar-dash/src/cli.mjs +522 -0
- package/bizar-dash/src/server/activity-log.mjs +174 -0
- package/bizar-dash/src/server/agents-store.mjs +465 -0
- package/bizar-dash/src/server/api.mjs +138 -0
- package/bizar-dash/src/server/artifact-template.html +810 -0
- package/bizar-dash/src/server/artifacts-store.mjs +733 -0
- package/bizar-dash/src/server/auth.mjs +381 -0
- package/bizar-dash/src/server/background-store.mjs +447 -0
- package/bizar-dash/src/server/bg-poller.mjs +305 -0
- package/bizar-dash/src/server/bg-retry.mjs +574 -0
- package/bizar-dash/src/server/browser.mjs +40 -0
- package/bizar-dash/src/server/diagnostics-store.mjs +148 -0
- package/bizar-dash/src/server/dialog-poller.mjs +105 -0
- package/bizar-dash/src/server/dialog-store.mjs +177 -0
- package/bizar-dash/src/server/glyphs/mdx-compiler.mjs +583 -0
- package/bizar-dash/src/server/lib/path-safe.mjs +283 -0
- package/bizar-dash/src/server/memory-git.mjs +329 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +767 -0
- package/bizar-dash/src/server/memory-schema.mjs +145 -0
- package/bizar-dash/src/server/memory-secrets.mjs +84 -0
- package/bizar-dash/src/server/memory-store.mjs +471 -0
- package/bizar-dash/src/server/mod-security.mjs +398 -0
- package/bizar-dash/src/server/mods-loader.mjs +1067 -0
- package/bizar-dash/src/server/notifications-store.mjs +243 -0
- package/bizar-dash/src/server/obsidian-store.mjs +318 -0
- package/bizar-dash/src/server/opencode-runner.mjs +279 -0
- package/bizar-dash/src/server/pair-store.mjs +138 -0
- package/bizar-dash/src/server/projects-store.mjs +364 -0
- package/bizar-dash/src/server/providers-store.mjs +789 -0
- package/bizar-dash/src/server/routes/_shared.mjs +415 -0
- package/bizar-dash/src/server/routes/activity.mjs +110 -0
- package/bizar-dash/src/server/routes/agents.mjs +134 -0
- package/bizar-dash/src/server/routes/artifacts.mjs +279 -0
- package/bizar-dash/src/server/routes/auth.mjs +69 -0
- package/bizar-dash/src/server/routes/background.mjs +165 -0
- package/bizar-dash/src/server/routes/chat.mjs +485 -0
- package/bizar-dash/src/server/routes/config.mjs +129 -0
- package/bizar-dash/src/server/routes/diagnostics.mjs +50 -0
- package/bizar-dash/src/server/routes/dialogs.mjs +86 -0
- package/bizar-dash/src/server/routes/fs.mjs +429 -0
- package/bizar-dash/src/server/routes/history.mjs +78 -0
- package/bizar-dash/src/server/routes/memory.mjs +658 -0
- package/bizar-dash/src/server/routes/misc.mjs +89 -0
- package/bizar-dash/src/server/routes/mods.mjs +359 -0
- package/bizar-dash/src/server/routes/notifications.mjs +54 -0
- package/bizar-dash/src/server/routes/obsidian.mjs +239 -0
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +55 -0
- package/bizar-dash/src/server/routes/overview.mjs +91 -0
- package/bizar-dash/src/server/routes/pair.mjs +67 -0
- package/bizar-dash/src/server/routes/projects.mjs +175 -0
- package/bizar-dash/src/server/routes/providers.mjs +47 -0
- package/bizar-dash/src/server/routes/schedules.mjs +104 -0
- package/bizar-dash/src/server/routes/settings.mjs +51 -0
- package/bizar-dash/src/server/routes/skills.mjs +76 -0
- package/bizar-dash/src/server/routes/tasks.mjs +468 -0
- package/bizar-dash/src/server/routes/themes.mjs +51 -0
- package/bizar-dash/src/server/routes-v2/auth.mjs +59 -0
- package/bizar-dash/src/server/routes-v2/events.mjs +107 -0
- package/bizar-dash/src/server/routes-v2/health.mjs +21 -0
- package/bizar-dash/src/server/routes-v2/index.mjs +90 -0
- package/bizar-dash/src/server/routes-v2/sessions.mjs +111 -0
- package/bizar-dash/src/server/schedules-runner.mjs +369 -0
- package/bizar-dash/src/server/schedules-store.mjs +268 -0
- package/bizar-dash/src/server/search-store.mjs +220 -0
- package/bizar-dash/src/server/serve-info.mjs +684 -0
- package/bizar-dash/src/server/server.mjs +796 -0
- package/bizar-dash/src/server/settings-store.mjs +45 -0
- package/bizar-dash/src/server/skills-store.mjs +300 -0
- package/bizar-dash/src/server/state.mjs +437 -0
- package/bizar-dash/src/server/tailscale-store.mjs +113 -0
- package/bizar-dash/src/server/task-delegator.mjs +824 -0
- package/bizar-dash/src/server/tasks-store.mjs +508 -0
- package/bizar-dash/src/server/tui.mjs +923 -0
- package/bizar-dash/src/server/update-store.mjs +168 -0
- package/bizar-dash/src/server/v2-auth-file.mjs +99 -0
- package/bizar-dash/src/server/v2-event-bus.mjs +128 -0
- package/bizar-dash/src/server/watcher.mjs +81 -0
- package/bizar-dash/src/server/yaml.mjs +238 -0
- package/bizar-dash/src/web/App.tsx +718 -0
- package/bizar-dash/src/web/MobileApp.tsx +408 -0
- package/bizar-dash/src/web/components/ArtifactCreateDialog.tsx +95 -0
- package/bizar-dash/src/web/components/ArtifactListDialog.tsx +55 -0
- package/bizar-dash/src/web/components/ArtifactViewer.tsx +143 -0
- package/bizar-dash/src/web/components/AuditDialog.tsx +79 -0
- package/bizar-dash/src/web/components/BgStatusBadge.tsx +32 -0
- package/bizar-dash/src/web/components/Button.tsx +56 -0
- package/bizar-dash/src/web/components/CanvasContextMenu.tsx +68 -0
- package/bizar-dash/src/web/components/Card.tsx +40 -0
- package/bizar-dash/src/web/components/CollapsibleSection.tsx +72 -0
- package/bizar-dash/src/web/components/CommandDialog.tsx +66 -0
- package/bizar-dash/src/web/components/EmptyState.tsx +30 -0
- package/bizar-dash/src/web/components/FileBrowser.tsx +633 -0
- package/bizar-dash/src/web/components/HelpDialog.tsx +55 -0
- package/bizar-dash/src/web/components/KillConfirmDialog.tsx +88 -0
- package/bizar-dash/src/web/components/Modal.tsx +215 -0
- package/bizar-dash/src/web/components/Notifications.tsx +241 -0
- package/bizar-dash/src/web/components/SearchModal.tsx +224 -0
- package/bizar-dash/src/web/components/Sidebar.tsx +66 -0
- package/bizar-dash/src/web/components/Spinner.tsx +19 -0
- package/bizar-dash/src/web/components/StatusBadge.tsx +25 -0
- package/bizar-dash/src/web/components/Tag.tsx +28 -0
- package/bizar-dash/src/web/components/Toast.tsx +150 -0
- package/bizar-dash/src/web/components/Topbar.tsx +368 -0
- package/bizar-dash/src/web/components/VisualPlanDialog.tsx +58 -0
- package/bizar-dash/src/web/components/chat/AgentChip.tsx +68 -0
- package/bizar-dash/src/web/components/chat/ChatBubble.tsx +60 -0
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +88 -0
- package/bizar-dash/src/web/components/chat/ChatTopBar.tsx +50 -0
- package/bizar-dash/src/web/components/chat/Composer.tsx +164 -0
- package/bizar-dash/src/web/components/chat/ConfirmModal.tsx +54 -0
- package/bizar-dash/src/web/components/chat/EmptyState.tsx +21 -0
- package/bizar-dash/src/web/components/chat/FirstRunGreeting.tsx +18 -0
- package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +32 -0
- package/bizar-dash/src/web/components/chat/InfoPanel.tsx +119 -0
- package/bizar-dash/src/web/components/chat/LoadingSkeleton.tsx +19 -0
- package/bizar-dash/src/web/components/chat/MessageBubble.tsx +60 -0
- package/bizar-dash/src/web/components/chat/SessionList.tsx +153 -0
- package/bizar-dash/src/web/components/chat/StreamingIndicator.tsx +9 -0
- package/bizar-dash/src/web/components/chat/SuggestionCards.tsx +55 -0
- package/bizar-dash/src/web/components/chat/WelcomeScreen.tsx +36 -0
- package/bizar-dash/src/web/components/chat/index.ts +20 -0
- package/bizar-dash/src/web/components/chat/useAutoGrowTextarea.ts +18 -0
- package/bizar-dash/src/web/components/chat/useChat.ts +219 -0
- package/bizar-dash/src/web/components/chat/useSlashCommands.ts +47 -0
- package/bizar-dash/src/web/index.html +37 -0
- package/bizar-dash/src/web/lib/api.ts +193 -0
- package/bizar-dash/src/web/lib/markdown.tsx +59 -0
- package/bizar-dash/src/web/lib/types.ts +668 -0
- package/bizar-dash/src/web/lib/utils.ts +114 -0
- package/bizar-dash/src/web/lib/ws.ts +176 -0
- package/bizar-dash/src/web/main.tsx +34 -0
- package/bizar-dash/src/web/mobile/MobileApp.tsx +278 -0
- package/bizar-dash/src/web/mobile/MobileBottomNav.tsx +38 -0
- package/bizar-dash/src/web/mobile/MobileTopbar.tsx +115 -0
- package/bizar-dash/src/web/mobile/components/MobileBottomSheet.tsx +182 -0
- package/bizar-dash/src/web/mobile/components/MobileListItem.tsx +64 -0
- package/bizar-dash/src/web/mobile/components/MobileModal.tsx +123 -0
- package/bizar-dash/src/web/mobile/views/MobileActivity.tsx +273 -0
- package/bizar-dash/src/web/mobile/views/MobileAgents.tsx +374 -0
- package/bizar-dash/src/web/mobile/views/MobileArtifactCanvas.tsx +439 -0
- package/bizar-dash/src/web/mobile/views/MobileArtifacts.tsx +143 -0
- package/bizar-dash/src/web/mobile/views/MobileChat.tsx +180 -0
- package/bizar-dash/src/web/mobile/views/MobileConfig.tsx +175 -0
- package/bizar-dash/src/web/mobile/views/MobileHistory.tsx +191 -0
- package/bizar-dash/src/web/mobile/views/MobileMods.tsx +149 -0
- package/bizar-dash/src/web/mobile/views/MobileMore.tsx +121 -0
- package/bizar-dash/src/web/mobile/views/MobileNotifications.tsx +137 -0
- package/bizar-dash/src/web/mobile/views/MobilePlans.tsx +26 -0
- package/bizar-dash/src/web/mobile/views/MobileSchedules.tsx +685 -0
- package/bizar-dash/src/web/mobile/views/MobileSearchModal.tsx +172 -0
- package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +388 -0
- package/bizar-dash/src/web/mobile/views/MobileSkills.tsx +153 -0
- package/bizar-dash/src/web/mobile/views/MobileTasks.tsx +564 -0
- package/bizar-dash/src/web/mobile.html +20 -0
- package/bizar-dash/src/web/mobile.tsx +13 -0
- package/bizar-dash/src/web/styles/chat.css +198 -0
- package/bizar-dash/src/web/styles/glyphs.css +960 -0
- package/bizar-dash/src/web/styles/main.css +8855 -0
- package/bizar-dash/src/web/styles/mobile-chat.css +5 -0
- package/bizar-dash/src/web/styles/mobile.css +1960 -0
- package/bizar-dash/src/web/views/Activity.tsx +1203 -0
- package/bizar-dash/src/web/views/Agents.tsx +701 -0
- package/bizar-dash/src/web/views/Artifacts.tsx +1480 -0
- package/bizar-dash/src/web/views/BackgroundAgents.tsx +450 -0
- package/bizar-dash/src/web/views/Chat.tsx +190 -0
- package/bizar-dash/src/web/views/Config.tsx +2063 -0
- package/bizar-dash/src/web/views/History.tsx +335 -0
- package/bizar-dash/src/web/views/ModView.tsx +207 -0
- package/bizar-dash/src/web/views/Mods.tsx +693 -0
- package/bizar-dash/src/web/views/Overview.tsx +812 -0
- package/bizar-dash/src/web/views/Providers.tsx +302 -0
- package/bizar-dash/src/web/views/Schedules.tsx +802 -0
- package/bizar-dash/src/web/views/Settings.tsx +1787 -0
- package/bizar-dash/src/web/views/Skills.tsx +431 -0
- package/bizar-dash/src/web/views/Tasks.tsx +1523 -0
- package/bizar-dash/src/web/views/glyphs/GlyphRenderer.tsx +613 -0
- package/bizar-dash/src/web/views/glyphs/components.tsx +1098 -0
- package/bizar-dash/templates/mod/FORMAT.md +76 -0
- package/bizar-dash/templates/mod/hello-mod/README.md +19 -0
- package/bizar-dash/templates/mod/hello-mod/agents/greeter.md +8 -0
- package/bizar-dash/templates/mod/hello-mod/commands/hello.md +6 -0
- package/bizar-dash/templates/mod/hello-mod/mod.json +20 -0
- package/bizar-dash/templates/mod/hello-mod/routes/ping.mjs +9 -0
- package/bizar-dash/templates/mod/hello-mod/views/HelloView.tsx +10 -0
- package/bizar-dash/tests/dashboard-ports.test.mjs +138 -0
- package/bizar-dash/tests/graphify-mod-spawn.node.test.mjs +90 -0
- package/bizar-dash/tests/memory-cli.test.mjs +542 -0
- package/bizar-dash/tests/memory-config.test.mjs +422 -0
- package/bizar-dash/tests/memory-git.test.mjs +246 -0
- package/bizar-dash/tests/memory-lightrag.test.mjs +153 -0
- package/bizar-dash/tests/memory-protocol-drift.test.mjs +45 -0
- package/bizar-dash/tests/memory-schema.test.mjs +198 -0
- package/bizar-dash/tests/memory-secrets.test.mjs +121 -0
- package/bizar-dash/tests/memory-store.test.mjs +390 -0
- package/bizar-dash/tests/memory-sync.test.mjs +141 -0
- package/bizar-dash/tests/mod-instructions.node.test.mjs +188 -0
- package/bizar-dash/tests/mod-security.test.mjs +273 -0
- package/bizar-dash/tests/mod-upgrade.node.test.mjs +357 -0
- package/bizar-dash/tests/no-agent-browser.node.test.mjs +98 -0
- package/bizar-dash/tests/obsidian-back-compat.test.mjs +199 -0
- package/bizar-dash/tests/opencode-runner.test.mjs +172 -0
- package/bizar-dash/tests/path-safe.test.mjs +108 -0
- package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +333 -0
- package/bizar-dash/tests/smoke-v2.mjs +212 -0
- package/bizar-dash/tests/strip-thinking.test.mjs +175 -0
- package/bizar-dash/tests/submit-feedback.test.mjs +353 -0
- package/bizar-dash/tests/tmux-wrap.test.mjs +145 -0
- package/bizar-dash/tests/yaml.test.mjs +128 -0
- package/bizar-dash/tsconfig.json +23 -0
- package/bizar-dash/vite.config.ts +27 -0
- package/cli/atomic.mjs +73 -0
- package/cli/banner.mjs +1 -1
- package/cli/bin.mjs +44 -47
- package/cli/bootstrap.mjs +1 -1
- package/cli/copy.mjs +25 -16
- package/cli/dev-link.mjs +17 -1
- package/cli/doctor.mjs +4 -4
- package/cli/doctor.test.mjs +2 -2
- package/cli/init.mjs +135 -1
- package/cli/install.mjs +31 -39
- package/cli/memory-constants.mjs +59 -0
- package/cli/memory.mjs +1436 -0
- package/cli/service.mjs +5 -6
- package/cli/utils.mjs +6 -3
- package/config/AGENTS.md +7 -7
- package/config/agents/_shared/AGENT_BASELINE.md +59 -61
- package/config/opencode.json +13 -38
- package/config/skills/memory-protocol/SKILL.md +105 -0
- package/config/skills/obsidian/SKILL.md +270 -65
- package/install.sh +59 -19
- package/package.json +49 -14
- package/packages/sdk/LICENSE +21 -0
- package/packages/sdk/README.md +80 -0
- package/packages/sdk/src/client.ts +183 -0
- package/packages/sdk/src/errors.ts +120 -0
- package/packages/sdk/src/events.ts +153 -0
- package/packages/sdk/src/index.ts +63 -0
- package/packages/sdk/src/types.ts +176 -0
- package/packages/sdk/src/version.ts +4 -0
- package/packages/sdk/tests/client.test.ts +217 -0
- package/packages/sdk/tests/errors.test.ts +108 -0
- package/packages/sdk/tests/events.test.ts +139 -0
- package/packages/sdk/tests/fixtures/fetch-mock.ts +152 -0
- package/packages/sdk/tests/fixtures/sse-mock.ts +30 -0
- package/packages/sdk/tsconfig.json +25 -0
- package/packages/sdk/vitest.config.ts +9 -0
- package/plugins/bizar/LICENSE +21 -0
- package/plugins/bizar/README.md +448 -0
- package/plugins/bizar/dist/index.js +29901 -0
- package/plugins/bizar/index.ts +1491 -0
- package/plugins/bizar/scripts/check-forbidden-imports.sh +33 -0
- package/plugins/bizar/src/background-state.ts +551 -0
- package/plugins/bizar/src/background.ts +1250 -0
- package/plugins/bizar/src/commands-impl.ts +464 -0
- package/plugins/bizar/src/commands.ts +1168 -0
- package/plugins/bizar/src/dashboard-client.ts +233 -0
- package/plugins/bizar/src/event-stream.ts +606 -0
- package/plugins/bizar/src/fingerprint.ts +120 -0
- package/plugins/bizar/src/handoff.ts +79 -0
- package/plugins/bizar/src/http-client.ts +467 -0
- package/plugins/bizar/src/key-rotation.ts +218 -0
- package/plugins/bizar/src/logger.ts +144 -0
- package/plugins/bizar/src/loop.ts +176 -0
- package/plugins/bizar/src/opencode-runner.ts +390 -0
- package/plugins/bizar/src/options.ts +421 -0
- package/plugins/bizar/src/plan-fs.ts +323 -0
- package/plugins/bizar/src/reasoning-clean.ts +454 -0
- package/plugins/bizar/src/report.ts +178 -0
- package/plugins/bizar/src/research-prompt.ts +35 -0
- package/plugins/bizar/src/serve-info.ts +228 -0
- package/plugins/bizar/src/serve.ts +496 -0
- package/plugins/bizar/src/settings.ts +349 -0
- package/plugins/bizar/src/state.ts +298 -0
- package/plugins/bizar/src/tools/bg-collect.ts +104 -0
- package/plugins/bizar/src/tools/bg-get-comments.ts +239 -0
- package/plugins/bizar/src/tools/bg-kill.ts +87 -0
- package/plugins/bizar/src/tools/bg-spawn.ts +401 -0
- package/plugins/bizar/src/tools/bg-status.ts +99 -0
- package/plugins/bizar/src/tools/open-kb.ts +191 -0
- package/plugins/bizar/src/tools/plan-action.ts +767 -0
- package/plugins/bizar/src/tools/read-glyph-feedback.ts +170 -0
- package/plugins/bizar/src/tools/wait-for-feedback.ts +402 -0
- package/plugins/bizar/tests/attach-handler-bug.test.ts +169 -0
- package/plugins/bizar/tests/background-state.test.ts +277 -0
- package/plugins/bizar/tests/background.test.ts +402 -0
- package/plugins/bizar/tests/block.test.ts +195 -0
- package/plugins/bizar/tests/canonical-key-order.test.ts +75 -0
- package/plugins/bizar/tests/commands-impl.test.ts +442 -0
- package/plugins/bizar/tests/commands.test.ts +584 -0
- package/plugins/bizar/tests/config.test.ts +122 -0
- package/plugins/bizar/tests/dashboard-client.test.ts +159 -0
- package/plugins/bizar/tests/dispose.test.ts +336 -0
- package/plugins/bizar/tests/event-stream.test.ts +409 -0
- package/plugins/bizar/tests/event.test.ts +262 -0
- package/plugins/bizar/tests/fingerprint.test.ts +162 -0
- package/plugins/bizar/tests/http-client.test.ts +404 -0
- package/plugins/bizar/tests/init-helpers.test.ts +203 -0
- package/plugins/bizar/tests/integration/slash-command.test.ts +348 -0
- package/plugins/bizar/tests/integration/tool-routing.test.ts +314 -0
- package/plugins/bizar/tests/key-rotation.test.ts +396 -0
- package/plugins/bizar/tests/loop.test.ts +397 -0
- package/plugins/bizar/tests/options.test.ts +276 -0
- package/plugins/bizar/tests/reasoning-clean.test.ts +422 -0
- package/plugins/bizar/tests/serve.test.ts +339 -0
- package/plugins/bizar/tests/settings.test.ts +351 -0
- package/plugins/bizar/tests/stall-think.test.ts +750 -0
- package/plugins/bizar/tests/state.test.ts +276 -0
- package/plugins/bizar/tests/tools/bg-collect.test.ts +337 -0
- package/plugins/bizar/tests/tools/bg-get-comments.test.ts +485 -0
- package/plugins/bizar/tests/tools/bg-kill.test.ts +235 -0
- package/plugins/bizar/tests/tools/bg-spawn-delegation.test.ts +147 -0
- package/plugins/bizar/tests/tools/bg-spawn.test.ts +311 -0
- package/plugins/bizar/tests/tools/bg-status.test.ts +217 -0
- package/plugins/bizar/tests/tools/opencode-runner.test.ts +115 -0
- package/plugins/bizar/tests/tools/plan-action.test.ts +599 -0
- package/plugins/bizar/tests/tools/read-glyph-feedback.test.ts +253 -0
- package/plugins/bizar/tests/tools/wait-for-feedback.test.ts +390 -0
- package/plugins/bizar/tests/update-deadlock.test.ts +145 -0
- package/plugins/bizar/tsconfig.json +29 -0
|
@@ -0,0 +1,2063 @@
|
|
|
1
|
+
// src/views/Config.tsx — v3.4.0: sidebar nav + fully editable providers/MCPs.
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
Settings2,
|
|
5
|
+
RefreshCw,
|
|
6
|
+
Save,
|
|
7
|
+
FileCode2,
|
|
8
|
+
Stethoscope,
|
|
9
|
+
Server as ServerIcon,
|
|
10
|
+
Plug,
|
|
11
|
+
Plus,
|
|
12
|
+
Trash2,
|
|
13
|
+
Pencil,
|
|
14
|
+
Download,
|
|
15
|
+
Database,
|
|
16
|
+
History as HistoryIcon,
|
|
17
|
+
X,
|
|
18
|
+
ShieldCheck,
|
|
19
|
+
AlertTriangle,
|
|
20
|
+
ChevronDown,
|
|
21
|
+
ChevronRight,
|
|
22
|
+
} from 'lucide-react';
|
|
23
|
+
import { Spinner } from '../components/Spinner';
|
|
24
|
+
import { Button } from '../components/Button';
|
|
25
|
+
import { Card, CardTitle, CardMeta } from '../components/Card';
|
|
26
|
+
import { useToast } from '../components/Toast';
|
|
27
|
+
import { useModal } from '../components/Modal';
|
|
28
|
+
import { api } from '../lib/api';
|
|
29
|
+
import { cn, debounce, hashText } from '../lib/utils';
|
|
30
|
+
import type {
|
|
31
|
+
ConfigResponse,
|
|
32
|
+
Diagnostics,
|
|
33
|
+
McpServer,
|
|
34
|
+
Provider,
|
|
35
|
+
Settings,
|
|
36
|
+
Snapshot,
|
|
37
|
+
} from '../lib/types';
|
|
38
|
+
|
|
39
|
+
type LightragStatus = {
|
|
40
|
+
running: boolean;
|
|
41
|
+
pid: number | null;
|
|
42
|
+
host: string;
|
|
43
|
+
port: number;
|
|
44
|
+
llmBinding: string;
|
|
45
|
+
embeddingBinding: string;
|
|
46
|
+
llmModel: string;
|
|
47
|
+
embeddingModel: string;
|
|
48
|
+
lastError: string | null;
|
|
49
|
+
logTail: string[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type Props = {
|
|
53
|
+
snapshot: Snapshot;
|
|
54
|
+
settings: Settings;
|
|
55
|
+
activeTab: string;
|
|
56
|
+
setActiveTab: (id: string) => void;
|
|
57
|
+
refreshSnapshot: () => Promise<void>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type NavId = 'opencode' | 'providers' | 'mcps' | 'diagnostics' | 'memory' | 'export';
|
|
61
|
+
|
|
62
|
+
const NAV_ITEMS: { id: NavId; label: string; icon: typeof Settings2; desc: string }[] = [
|
|
63
|
+
{ id: 'opencode', label: 'OpenCode config', icon: FileCode2, desc: 'Edit opencode.json directly' },
|
|
64
|
+
{ id: 'providers', label: 'Providers', icon: ServerIcon, desc: 'AI providers + API keys' },
|
|
65
|
+
{ id: 'mcps', label: 'MCPs', icon: Plug, desc: 'Model Context Protocol servers' },
|
|
66
|
+
{ id: 'diagnostics', label: 'Diagnostics', icon: Stethoscope, desc: 'Service health + counts' },
|
|
67
|
+
{ id: 'memory', label: 'Memory & LightRAG', icon: Database, desc: 'Configure Bizar Memory Service and the LightRAG embedding server.' },
|
|
68
|
+
{ id: 'export', label: 'Export / Import', icon: Download, desc: 'Download diagnostics bundle' },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
export function Config({ snapshot, refreshSnapshot }: Props) {
|
|
72
|
+
const toast = useToast();
|
|
73
|
+
const modal = useModal();
|
|
74
|
+
const initial: ConfigResponse | undefined = snapshot.config;
|
|
75
|
+
const [activeNav, setActiveNav] = useState<NavId>('opencode');
|
|
76
|
+
|
|
77
|
+
// OpenCode editor state
|
|
78
|
+
const [original, setOriginal] = useState<string>(
|
|
79
|
+
initial?.raw || (initial?.data ? JSON.stringify(initial.data, null, 2) : ''),
|
|
80
|
+
);
|
|
81
|
+
const [parsed, setParsed] = useState({
|
|
82
|
+
raw: initial?.raw || '',
|
|
83
|
+
data: initial?.data ?? null,
|
|
84
|
+
error: null as string | null,
|
|
85
|
+
});
|
|
86
|
+
const [dirty, setDirty] = useState(false);
|
|
87
|
+
const [saving, setSaving] = useState(false);
|
|
88
|
+
const [path, setPath] = useState<string>(initial?.path || '');
|
|
89
|
+
const taRef = useRef<HTMLTextAreaElement>(null);
|
|
90
|
+
|
|
91
|
+
// Diagnostics state
|
|
92
|
+
const [diagnostics, setDiagnostics] = useState<Diagnostics | null>(null);
|
|
93
|
+
|
|
94
|
+
// Memory & LightRAG state
|
|
95
|
+
const [lightragStatus, setLightragStatus] = useState<LightragStatus | null>(null);
|
|
96
|
+
const [lightragLog, setLightragLog] = useState<string[]>([]);
|
|
97
|
+
|
|
98
|
+
// Providers + MCPs state — re-fetch when nav switches so we always have fresh data.
|
|
99
|
+
const [providers, setProviders] = useState<Provider[]>(snapshot.providers || []);
|
|
100
|
+
const [mcps, setMcps] = useState<McpServer[]>(snapshot.mcps || []);
|
|
101
|
+
|
|
102
|
+
const originalHash = useMemo(() => hashText(original), [original]);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (snapshot.providers) setProviders(snapshot.providers);
|
|
106
|
+
if (snapshot.mcps) setMcps(snapshot.mcps);
|
|
107
|
+
}, [snapshot.providers, snapshot.mcps]);
|
|
108
|
+
|
|
109
|
+
const reloadConfig = async () => {
|
|
110
|
+
try {
|
|
111
|
+
const d = await api.post<ConfigResponse>('/config/reload');
|
|
112
|
+
const raw = d.raw || (d.data ? JSON.stringify(d.data, null, 2) : '');
|
|
113
|
+
setOriginal(raw);
|
|
114
|
+
setParsed({ raw, data: d.data ?? null, error: null });
|
|
115
|
+
setDirty(false);
|
|
116
|
+
setPath(d.path || '');
|
|
117
|
+
toast.info('Config reloaded.', 1500);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
toast.error(`Reload failed: ${(err as Error).message}`);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const reloadProviders = async () => {
|
|
124
|
+
try {
|
|
125
|
+
const r = await api.get<{ providers: Provider[] }>('/config/providers');
|
|
126
|
+
setProviders(r.providers || []);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
toast.error(`Providers load failed: ${(err as Error).message}`);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const reloadMcps = async () => {
|
|
133
|
+
try {
|
|
134
|
+
const r = await api.get<{ mcps: McpServer[] }>('/config/mcps');
|
|
135
|
+
setMcps(r.mcps || []);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
toast.error(`MCPs load failed: ${(err as Error).message}`);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Reload data when nav changes so each panel shows fresh server data.
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (activeNav === 'providers' && providers.length === 0) reloadProviders();
|
|
144
|
+
if (activeNav === 'mcps' && mcps.length === 0) reloadMcps();
|
|
145
|
+
if (activeNav === 'diagnostics' && !diagnostics) loadDiagnostics();
|
|
146
|
+
if (activeNav === 'memory') loadLightragStatus();
|
|
147
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
148
|
+
}, [activeNav]);
|
|
149
|
+
|
|
150
|
+
const applyEdit = (val: string) => {
|
|
151
|
+
setParsed((cur) => {
|
|
152
|
+
const next: typeof parsed = { raw: val, data: cur.data, error: null };
|
|
153
|
+
try {
|
|
154
|
+
next.data = JSON.parse(val);
|
|
155
|
+
next.error = null;
|
|
156
|
+
} catch (e) {
|
|
157
|
+
next.data = null;
|
|
158
|
+
next.error = (e as Error).message;
|
|
159
|
+
}
|
|
160
|
+
return next;
|
|
161
|
+
});
|
|
162
|
+
onChangeDebounced(val);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const onChangeDebounced = useMemo(
|
|
166
|
+
() =>
|
|
167
|
+
debounce((val: string) => {
|
|
168
|
+
setDirty(hashText(val) !== originalHash);
|
|
169
|
+
}, 100),
|
|
170
|
+
[originalHash],
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const save = async () => {
|
|
174
|
+
if (!parsed.data || parsed.error || saving) return;
|
|
175
|
+
setSaving(true);
|
|
176
|
+
try {
|
|
177
|
+
const result = await api.put<ConfigResponse>('/config', parsed.data);
|
|
178
|
+
const raw = result.raw || JSON.stringify(result.data, null, 2);
|
|
179
|
+
setOriginal(raw);
|
|
180
|
+
setParsed({ raw, data: result.data ?? null, error: null });
|
|
181
|
+
setDirty(false);
|
|
182
|
+
toast.success('Config saved.');
|
|
183
|
+
await refreshSnapshot();
|
|
184
|
+
// Re-fetch providers + MCPs because config may have changed.
|
|
185
|
+
await reloadProviders();
|
|
186
|
+
await reloadMcps();
|
|
187
|
+
} catch (err) {
|
|
188
|
+
toast.error(`Save failed: ${(err as Error).message}`);
|
|
189
|
+
} finally {
|
|
190
|
+
setSaving(false);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const loadDiagnostics = async () => {
|
|
195
|
+
try {
|
|
196
|
+
const d = await api.get<Diagnostics>('/diagnostics');
|
|
197
|
+
setDiagnostics(d);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
toast.error(`Diagnostics load failed: ${(err as Error).message}`);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const loadLightragStatus = async () => {
|
|
204
|
+
try {
|
|
205
|
+
const d = await api.get<LightragStatus>('/memory/lightrag/status');
|
|
206
|
+
setLightragStatus(d);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
toast.error(`LightRAG status failed: ${(err as Error).message}`);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const loadLightragLog = async () => {
|
|
213
|
+
try {
|
|
214
|
+
const d = await api.get<{ lines: string[] }>('/memory/lightrag/log');
|
|
215
|
+
setLightragLog(d.lines || []);
|
|
216
|
+
} catch {
|
|
217
|
+
setLightragLog([]);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const onDownloadDiagnostics = () => {
|
|
222
|
+
const bundle = {
|
|
223
|
+
generatedAt: new Date().toISOString(),
|
|
224
|
+
diagnostics,
|
|
225
|
+
config: parsed.data,
|
|
226
|
+
opencodeJsonPath: path,
|
|
227
|
+
providers,
|
|
228
|
+
mcps,
|
|
229
|
+
};
|
|
230
|
+
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
|
|
231
|
+
const url = URL.createObjectURL(blob);
|
|
232
|
+
const a = document.createElement('a');
|
|
233
|
+
a.href = url;
|
|
234
|
+
a.download = `bizar-diagnostics-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
|
235
|
+
a.click();
|
|
236
|
+
URL.revokeObjectURL(url);
|
|
237
|
+
toast.success('Bundle downloaded.');
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<div className="view view-config">
|
|
242
|
+
<header className="view-header">
|
|
243
|
+
<div className="view-header-text">
|
|
244
|
+
<h2 className="view-title">
|
|
245
|
+
<Settings2 size={18} /> Config
|
|
246
|
+
</h2>
|
|
247
|
+
<p className="view-subtitle">
|
|
248
|
+
Manage OpenCode config, providers, MCPs, and diagnostics.
|
|
249
|
+
</p>
|
|
250
|
+
</div>
|
|
251
|
+
</header>
|
|
252
|
+
|
|
253
|
+
<div className="config-layout">
|
|
254
|
+
<nav className="config-nav" role="navigation" aria-label="Config sections">
|
|
255
|
+
{NAV_ITEMS.map((n) => {
|
|
256
|
+
const Icon = n.icon;
|
|
257
|
+
return (
|
|
258
|
+
<button
|
|
259
|
+
key={n.id}
|
|
260
|
+
type="button"
|
|
261
|
+
className={cn('config-nav-item', activeNav === n.id && 'config-nav-item-active')}
|
|
262
|
+
onClick={() => setActiveNav(n.id)}
|
|
263
|
+
aria-current={activeNav === n.id ? 'page' : undefined}
|
|
264
|
+
>
|
|
265
|
+
<Icon size={14} />
|
|
266
|
+
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
|
|
267
|
+
<span>{n.label}</span>
|
|
268
|
+
<span style={{ fontSize: 10, color: 'var(--text-dim)', fontWeight: 400 }}>
|
|
269
|
+
{n.desc}
|
|
270
|
+
</span>
|
|
271
|
+
</div>
|
|
272
|
+
</button>
|
|
273
|
+
);
|
|
274
|
+
})}
|
|
275
|
+
</nav>
|
|
276
|
+
|
|
277
|
+
<div className="config-content">
|
|
278
|
+
{activeNav === 'opencode' && (
|
|
279
|
+
<ConfigEditorPanel
|
|
280
|
+
parsed={parsed}
|
|
281
|
+
dirty={dirty}
|
|
282
|
+
saving={saving}
|
|
283
|
+
path={path}
|
|
284
|
+
onReload={reloadConfig}
|
|
285
|
+
onSave={save}
|
|
286
|
+
onChange={applyEdit}
|
|
287
|
+
textareaRef={taRef}
|
|
288
|
+
/>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
{activeNav === 'providers' && (
|
|
292
|
+
<ProvidersPanel providers={providers} onChange={setProviders} onReload={reloadProviders} />
|
|
293
|
+
)}
|
|
294
|
+
|
|
295
|
+
{activeNav === 'mcps' && (
|
|
296
|
+
<McpsPanel mcps={mcps} onChange={setMcps} onReload={reloadMcps} />
|
|
297
|
+
)}
|
|
298
|
+
|
|
299
|
+
{activeNav === 'diagnostics' && (
|
|
300
|
+
<DiagnosticsPanel diagnostics={diagnostics} loading={!diagnostics} onReload={loadDiagnostics} />
|
|
301
|
+
)}
|
|
302
|
+
|
|
303
|
+
{activeNav === 'memory' && (
|
|
304
|
+
<MemoryLightragPanel
|
|
305
|
+
status={lightragStatus}
|
|
306
|
+
onReload={loadLightragStatus}
|
|
307
|
+
logLines={lightragLog}
|
|
308
|
+
onReloadLog={loadLightragLog}
|
|
309
|
+
/>
|
|
310
|
+
)}
|
|
311
|
+
|
|
312
|
+
{activeNav === 'export' && (
|
|
313
|
+
<ExportPanel onDownload={onDownloadDiagnostics} />
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─── OpenCode config form (v3.5.2) ─────────────────────────────────
|
|
322
|
+
// Structured UI for opencode.json sections: Model, Plugins, Tools,
|
|
323
|
+
// Permissions, Hooks. Raw JSON available via "Advanced" toggle.
|
|
324
|
+
|
|
325
|
+
type OcSection = 'model' | 'plugins' | 'tools' | 'permissions' | 'hooks' | 'advanced';
|
|
326
|
+
|
|
327
|
+
const OC_SECTIONS: { id: OcSection; label: string }[] = [
|
|
328
|
+
{ id: 'model', label: 'Model' },
|
|
329
|
+
{ id: 'plugins', label: 'Plugins' },
|
|
330
|
+
{ id: 'tools', label: 'Tools' },
|
|
331
|
+
{ id: 'permissions', label: 'Permissions' },
|
|
332
|
+
{ id: 'hooks', label: 'Hooks' },
|
|
333
|
+
{ id: 'advanced', label: 'Advanced' },
|
|
334
|
+
];
|
|
335
|
+
|
|
336
|
+
function ConfigEditorPanel({
|
|
337
|
+
parsed,
|
|
338
|
+
dirty,
|
|
339
|
+
saving,
|
|
340
|
+
path,
|
|
341
|
+
onReload,
|
|
342
|
+
onSave,
|
|
343
|
+
onChange,
|
|
344
|
+
textareaRef,
|
|
345
|
+
}: {
|
|
346
|
+
parsed: { raw: string; data: unknown; error: string | null };
|
|
347
|
+
dirty: boolean;
|
|
348
|
+
saving: boolean;
|
|
349
|
+
path: string;
|
|
350
|
+
onReload: () => void;
|
|
351
|
+
onSave: () => void;
|
|
352
|
+
onChange: (val: string) => void;
|
|
353
|
+
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
|
354
|
+
}) {
|
|
355
|
+
const [section, setSection] = useState<OcSection>('model');
|
|
356
|
+
|
|
357
|
+
const cfg = parsed.data as Record<string, unknown> | null;
|
|
358
|
+
|
|
359
|
+
const apply = (patch: Record<string, unknown>) => {
|
|
360
|
+
if (!cfg) return;
|
|
361
|
+
const next = { ...cfg, ...patch };
|
|
362
|
+
onChange(JSON.stringify(next, null, 2));
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
<Card>
|
|
367
|
+
<CardTitle>
|
|
368
|
+
<FileCode2 size={14} /> OpenCode config
|
|
369
|
+
</CardTitle>
|
|
370
|
+
<CardMeta>
|
|
371
|
+
<code className="mono ellipsis" style={{ fontSize: 11 }}>
|
|
372
|
+
{path || '~/.config/opencode/opencode.json'}
|
|
373
|
+
</code>
|
|
374
|
+
</CardMeta>
|
|
375
|
+
<div className="view-actions" style={{ marginBottom: 12 }}>
|
|
376
|
+
<Button variant="secondary" size="sm" onClick={onReload}>
|
|
377
|
+
<RefreshCw size={14} /> Reload
|
|
378
|
+
</Button>
|
|
379
|
+
<Button
|
|
380
|
+
variant="primary"
|
|
381
|
+
size="sm"
|
|
382
|
+
disabled={!parsed.data || !!parsed.error || !dirty}
|
|
383
|
+
onClick={onSave}
|
|
384
|
+
>
|
|
385
|
+
{saving ? <span className="btn-spinner" /> : <Save size={14} />}
|
|
386
|
+
Save
|
|
387
|
+
</Button>
|
|
388
|
+
</div>
|
|
389
|
+
|
|
390
|
+
{/* Section tabs */}
|
|
391
|
+
<div className="config-section-tabs" style={{ display: 'flex', gap: 4, marginBottom: 16, flexWrap: 'wrap' }}>
|
|
392
|
+
{OC_SECTIONS.map((s) => (
|
|
393
|
+
<button
|
|
394
|
+
key={s.id}
|
|
395
|
+
type="button"
|
|
396
|
+
className={cn('tab-btn', section === s.id && 'active')}
|
|
397
|
+
onClick={() => setSection(s.id)}
|
|
398
|
+
>
|
|
399
|
+
{s.label}
|
|
400
|
+
</button>
|
|
401
|
+
))}
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
{/* Model section */}
|
|
405
|
+
{section === 'model' && (
|
|
406
|
+
<ModelForm cfg={cfg} apply={apply} />
|
|
407
|
+
)}
|
|
408
|
+
|
|
409
|
+
{/* Plugins section */}
|
|
410
|
+
{section === 'plugins' && (
|
|
411
|
+
<PluginsForm cfg={cfg} apply={apply} />
|
|
412
|
+
)}
|
|
413
|
+
|
|
414
|
+
{/* Tools section */}
|
|
415
|
+
{section === 'tools' && (
|
|
416
|
+
<ToolsForm cfg={cfg} apply={apply} />
|
|
417
|
+
)}
|
|
418
|
+
|
|
419
|
+
{/* Permissions section */}
|
|
420
|
+
{section === 'permissions' && (
|
|
421
|
+
<PermissionsForm cfg={cfg} apply={apply} />
|
|
422
|
+
)}
|
|
423
|
+
|
|
424
|
+
{/* Hooks section */}
|
|
425
|
+
{section === 'hooks' && (
|
|
426
|
+
<HooksForm cfg={cfg} apply={apply} />
|
|
427
|
+
)}
|
|
428
|
+
|
|
429
|
+
{/* Advanced: raw JSON editor */}
|
|
430
|
+
{section === 'advanced' && (
|
|
431
|
+
<div>
|
|
432
|
+
<div className="config-grid-label" style={{ marginBottom: 8 }}>
|
|
433
|
+
{parsed.error ? (
|
|
434
|
+
<span className="text-error">{parsed.error}</span>
|
|
435
|
+
) : (
|
|
436
|
+
<span className="muted">Raw JSON — edit with care</span>
|
|
437
|
+
)}
|
|
438
|
+
</div>
|
|
439
|
+
<textarea
|
|
440
|
+
ref={textareaRef}
|
|
441
|
+
className={cn('textarea config-textarea', parsed.error && 'invalid')}
|
|
442
|
+
spellCheck={false}
|
|
443
|
+
value={parsed.raw}
|
|
444
|
+
onChange={(e) => onChange(e.target.value)}
|
|
445
|
+
style={{ minHeight: 300 }}
|
|
446
|
+
/>
|
|
447
|
+
</div>
|
|
448
|
+
)}
|
|
449
|
+
</Card>
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ── Sub-forms ─────────────────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
function ModelForm({ cfg, apply }: { cfg: Record<string, unknown> | null; apply: (p: Record<string, unknown>) => void }) {
|
|
456
|
+
const model = (cfg?.model || {}) as Record<string, unknown>;
|
|
457
|
+
return (
|
|
458
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
459
|
+
<p className="muted text-sm">Configure the default model used by OpenCode agents.</p>
|
|
460
|
+
<div className="field-row">
|
|
461
|
+
<label className="field-label">Provider</label>
|
|
462
|
+
<input
|
|
463
|
+
className="input"
|
|
464
|
+
value={String(model.provider || '')}
|
|
465
|
+
onChange={(e) => apply({ model: { ...model, provider: e.target.value } })}
|
|
466
|
+
placeholder="e.g. openai, anthropic, google"
|
|
467
|
+
/>
|
|
468
|
+
</div>
|
|
469
|
+
<div className="field-row">
|
|
470
|
+
<label className="field-label">Model ID</label>
|
|
471
|
+
<input
|
|
472
|
+
className="input"
|
|
473
|
+
value={String(model.model || '')}
|
|
474
|
+
onChange={(e) => apply({ model: { ...model, model: e.target.value } })}
|
|
475
|
+
placeholder="e.g. gpt-4o, claude-sonnet-4-20250514"
|
|
476
|
+
/>
|
|
477
|
+
</div>
|
|
478
|
+
{!!model.apiKey && (
|
|
479
|
+
<div className="field-row">
|
|
480
|
+
<label className="field-label">API Key</label>
|
|
481
|
+
<input
|
|
482
|
+
className="input"
|
|
483
|
+
type="password"
|
|
484
|
+
value={String(model.apiKey || '')}
|
|
485
|
+
onChange={(e) => apply({ model: { ...model, apiKey: e.target.value } })}
|
|
486
|
+
placeholder="sk-..."
|
|
487
|
+
/>
|
|
488
|
+
</div>
|
|
489
|
+
)}
|
|
490
|
+
{!!model.baseURL && (
|
|
491
|
+
<div className="field-row">
|
|
492
|
+
<label className="field-label">Base URL</label>
|
|
493
|
+
<input
|
|
494
|
+
className="input"
|
|
495
|
+
value={String(model.baseURL || '')}
|
|
496
|
+
onChange={(e) => apply({ model: { ...model, baseURL: e.target.value } })}
|
|
497
|
+
placeholder="https://api.openai.com/v1"
|
|
498
|
+
/>
|
|
499
|
+
</div>
|
|
500
|
+
)}
|
|
501
|
+
</div>
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function PluginsForm({ cfg, apply }: { cfg: Record<string, unknown> | null; apply: (p: Record<string, unknown>) => void }) {
|
|
506
|
+
const plugins = Array.isArray(cfg?.plugins) ? (cfg.plugins as Record<string, unknown>[]) : [];
|
|
507
|
+
const [editing, setEditing] = useState<number | null>(null);
|
|
508
|
+
const [draft, setDraft] = useState<Record<string, unknown>>({});
|
|
509
|
+
|
|
510
|
+
const addPlugin = () => {
|
|
511
|
+
const next = [...plugins, { name: '', enabled: true }];
|
|
512
|
+
apply({ plugins: next });
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const removePlugin = (i: number) => {
|
|
516
|
+
const next = plugins.filter((_, idx) => idx !== i);
|
|
517
|
+
apply({ plugins: next });
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const updatePlugin = (i: number, patch: Record<string, unknown>) => {
|
|
521
|
+
const next = plugins.map((p, idx) => (idx === i ? { ...p, ...patch } : p));
|
|
522
|
+
apply({ plugins: next });
|
|
523
|
+
setEditing(null);
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
return (
|
|
527
|
+
<div>
|
|
528
|
+
<p className="muted text-sm" style={{ marginBottom: 12 }}>Manage enabled plugins.</p>
|
|
529
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
530
|
+
{plugins.length === 0 && <p className="muted text-sm">No plugins configured.</p>}
|
|
531
|
+
{plugins.map((p, i) => (
|
|
532
|
+
<div key={i} className="config-list-row">
|
|
533
|
+
{editing === i ? (
|
|
534
|
+
<div style={{ display: 'flex', gap: 8, flex: 1, alignItems: 'center' }}>
|
|
535
|
+
<input
|
|
536
|
+
className="input"
|
|
537
|
+
value={String(draft.name || '')}
|
|
538
|
+
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
|
539
|
+
placeholder="plugin-name"
|
|
540
|
+
/>
|
|
541
|
+
<label style={{ display: 'flex', gap: 4, fontSize: 12 }}>
|
|
542
|
+
<input
|
|
543
|
+
type="checkbox"
|
|
544
|
+
checked={!!draft.enabled}
|
|
545
|
+
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
|
|
546
|
+
/>
|
|
547
|
+
enabled
|
|
548
|
+
</label>
|
|
549
|
+
<Button variant="primary" size="sm" onClick={() => updatePlugin(i, draft)}>Apply</Button>
|
|
550
|
+
<Button variant="ghost" size="sm" onClick={() => setEditing(null)}><X size={12} /></Button>
|
|
551
|
+
</div>
|
|
552
|
+
) : (
|
|
553
|
+
<>
|
|
554
|
+
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 13 }}>
|
|
555
|
+
{(p as { name?: string }).name || 'unnamed'}
|
|
556
|
+
</span>
|
|
557
|
+
<span className={cn('tag', (p as { enabled?: boolean }).enabled !== false ? 'tag-success' : 'tag-neutral')}>
|
|
558
|
+
{(p as { enabled?: boolean }).enabled !== false ? 'enabled' : 'disabled'}
|
|
559
|
+
</span>
|
|
560
|
+
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
|
|
561
|
+
<button type="button" className="icon-btn" onClick={() => { setEditing(i); setDraft(p as Record<string, unknown>); }} title="Edit">
|
|
562
|
+
<Pencil size={12} />
|
|
563
|
+
</button>
|
|
564
|
+
<button type="button" className="icon-btn text-error" onClick={() => removePlugin(i)} title="Remove">
|
|
565
|
+
<Trash2 size={12} />
|
|
566
|
+
</button>
|
|
567
|
+
</div>
|
|
568
|
+
</>
|
|
569
|
+
)}
|
|
570
|
+
</div>
|
|
571
|
+
))}
|
|
572
|
+
</div>
|
|
573
|
+
<Button variant="secondary" size="sm" onClick={addPlugin} style={{ marginTop: 8 }}>
|
|
574
|
+
<Plus size={12} /> Add plugin
|
|
575
|
+
</Button>
|
|
576
|
+
</div>
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function ToolsForm({ cfg, apply }: { cfg: Record<string, unknown> | null; apply: (p: Record<string, unknown>) => void }) {
|
|
581
|
+
const tools = (cfg?.tools as Record<string, boolean> | null) || {};
|
|
582
|
+
|
|
583
|
+
const toggle = (name: string) => {
|
|
584
|
+
apply({ tools: { ...tools, [name]: !tools[name] } });
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const addTool = (name: string) => {
|
|
588
|
+
if (!name || tools[name] !== undefined) return;
|
|
589
|
+
apply({ tools: { ...tools, [name]: true } });
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const removeTool = (name: string) => {
|
|
593
|
+
const next = { ...tools };
|
|
594
|
+
delete next[name];
|
|
595
|
+
apply({ tools: next });
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
return (
|
|
599
|
+
<div>
|
|
600
|
+
<p className="muted text-sm" style={{ marginBottom: 12 }}>Enable or disable built-in tools.</p>
|
|
601
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
602
|
+
{Object.keys(tools).length === 0 && <p className="muted text-sm">No tools configured.</p>}
|
|
603
|
+
{Object.entries(tools).map(([name, enabled]) => (
|
|
604
|
+
<div key={name} className="config-list-row">
|
|
605
|
+
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 13 }}>{name}</span>
|
|
606
|
+
<label style={{ display: 'flex', gap: 4, alignItems: 'center', fontSize: 12 }}>
|
|
607
|
+
<input type="checkbox" checked={!!enabled} onChange={() => toggle(name)} />
|
|
608
|
+
enabled
|
|
609
|
+
</label>
|
|
610
|
+
<button type="button" className="icon-btn text-error" style={{ marginLeft: 'auto' }} onClick={() => removeTool(name)} title="Remove">
|
|
611
|
+
<Trash2 size={12} />
|
|
612
|
+
</button>
|
|
613
|
+
</div>
|
|
614
|
+
))}
|
|
615
|
+
</div>
|
|
616
|
+
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
|
617
|
+
<input
|
|
618
|
+
className="input"
|
|
619
|
+
placeholder="tool-name"
|
|
620
|
+
onKeyDown={(e) => {
|
|
621
|
+
if (e.key === 'Enter') addTool((e.target as HTMLInputElement).value.trim());
|
|
622
|
+
}}
|
|
623
|
+
/>
|
|
624
|
+
<Button variant="secondary" size="sm"
|
|
625
|
+
onClick={(e) => addTool((e.currentTarget.closest('div')?.querySelector('input') as HTMLInputElement)?.value.trim() || '')}
|
|
626
|
+
>
|
|
627
|
+
<Plus size={12} /> Add
|
|
628
|
+
</Button>
|
|
629
|
+
</div>
|
|
630
|
+
</div>
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function PermissionsForm({ cfg, apply }: { cfg: Record<string, unknown> | null; apply: (p: Record<string, unknown>) => void }) {
|
|
635
|
+
const permissions = (cfg?.permissions as Record<string, unknown> | null) || {};
|
|
636
|
+
const [tab, setTab] = useState<'allow' | 'deny'>('allow');
|
|
637
|
+
const allow = Array.isArray(permissions.allow) ? (permissions.allow as string[]) : [];
|
|
638
|
+
const deny = Array.isArray(permissions.deny) ? (permissions.deny as string[]) : [];
|
|
639
|
+
|
|
640
|
+
const addRule = (list: string[], key: 'allow' | 'deny') => (name: string) => {
|
|
641
|
+
if (!name) return;
|
|
642
|
+
apply({ permissions: { ...permissions, [key]: [...list, name] } });
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
const removeRule = (key: 'allow' | 'deny', list: string[], i: number) => {
|
|
646
|
+
apply({ permissions: { ...permissions, [key]: list.filter((_, idx) => idx !== i) } });
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
const list = tab === 'allow' ? allow : deny;
|
|
650
|
+
|
|
651
|
+
return (
|
|
652
|
+
<div>
|
|
653
|
+
<p className="muted text-sm" style={{ marginBottom: 12 }}>Allow or deny tool/scope rules.</p>
|
|
654
|
+
<div style={{ display: 'flex', gap: 4, marginBottom: 12 }}>
|
|
655
|
+
<button type="button" className={cn('tab-btn', tab === 'allow' && 'active')} onClick={() => setTab('allow')}>Allow ({allow.length})</button>
|
|
656
|
+
<button type="button" className={cn('tab-btn', tab === 'deny' && 'active')} onClick={() => setTab('deny')}>Deny ({deny.length})</button>
|
|
657
|
+
</div>
|
|
658
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
659
|
+
{list.length === 0 && <p className="muted text-sm">No {tab} rules.</p>}
|
|
660
|
+
{list.map((rule, i) => (
|
|
661
|
+
<div key={i} className="config-list-row">
|
|
662
|
+
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 13 }}>{rule}</span>
|
|
663
|
+
<button type="button" className="icon-btn text-error" style={{ marginLeft: 'auto' }} onClick={() => removeRule(tab, list, i)}>
|
|
664
|
+
<Trash2 size={12} />
|
|
665
|
+
</button>
|
|
666
|
+
</div>
|
|
667
|
+
))}
|
|
668
|
+
</div>
|
|
669
|
+
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
|
670
|
+
<input
|
|
671
|
+
className="input"
|
|
672
|
+
placeholder={`${tab} rule (e.g. tool:read, scope:filesystem)`}
|
|
673
|
+
onKeyDown={(e) => {
|
|
674
|
+
if (e.key === 'Enter') {
|
|
675
|
+
const v = (e.target as HTMLInputElement).value.trim();
|
|
676
|
+
if (v) { addRule(list, tab)(v); (e.target as HTMLInputElement).value = ''; }
|
|
677
|
+
}
|
|
678
|
+
}}
|
|
679
|
+
/>
|
|
680
|
+
<Button variant="secondary" size="sm" onClick={(e) => {
|
|
681
|
+
const inp = e.currentTarget.closest('div')?.querySelector('input') as HTMLInputElement;
|
|
682
|
+
const v = inp?.value.trim();
|
|
683
|
+
if (v) { addRule(list, tab)(v); inp.value = ''; }
|
|
684
|
+
}}>
|
|
685
|
+
<Plus size={12} /> Add
|
|
686
|
+
</Button>
|
|
687
|
+
</div>
|
|
688
|
+
</div>
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function HooksForm({ cfg, apply }: { cfg: Record<string, unknown> | null; apply: (p: Record<string, unknown>) => void }) {
|
|
693
|
+
const hooks = (cfg?.hooks as Record<string, unknown> | null) || {};
|
|
694
|
+
|
|
695
|
+
const updateHook = (name: string, value: unknown) => {
|
|
696
|
+
apply({ hooks: { ...hooks, [name]: value } });
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
return (
|
|
700
|
+
<div>
|
|
701
|
+
<p className="muted text-sm" style={{ marginBottom: 12 }}>Configure pre/post tool hooks.</p>
|
|
702
|
+
{Object.keys(hooks).length === 0 && <p className="muted text-sm">No hooks configured.</p>}
|
|
703
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
704
|
+
{(['preTool', 'postTool', 'preAgent', 'postAgent'] as const).map((hookName) => {
|
|
705
|
+
const hook = hooks[hookName];
|
|
706
|
+
return (
|
|
707
|
+
<div key={hookName} className="field-row" style={{ alignItems: 'flex-start' }}>
|
|
708
|
+
<label className="field-label" style={{ minWidth: 100 }}>{hookName}</label>
|
|
709
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1 }}>
|
|
710
|
+
<input
|
|
711
|
+
className="input"
|
|
712
|
+
value={typeof hook === 'object' && hook !== null && 'command' in hook ? String((hook as { command: string }).command || '') : ''}
|
|
713
|
+
onChange={(e) => updateHook(hookName, { command: e.target.value })}
|
|
714
|
+
placeholder={`${hookName} command (e.g. node /path/to/hook.js)`}
|
|
715
|
+
/>
|
|
716
|
+
</div>
|
|
717
|
+
</div>
|
|
718
|
+
);
|
|
719
|
+
})}
|
|
720
|
+
</div>
|
|
721
|
+
</div>
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function DiagnosticsPanel({
|
|
726
|
+
diagnostics,
|
|
727
|
+
loading,
|
|
728
|
+
onReload,
|
|
729
|
+
}: {
|
|
730
|
+
diagnostics: Diagnostics | null;
|
|
731
|
+
loading: boolean;
|
|
732
|
+
onReload: () => void;
|
|
733
|
+
}) {
|
|
734
|
+
return (
|
|
735
|
+
<Card>
|
|
736
|
+
<CardTitle>
|
|
737
|
+
<Stethoscope size={14} /> Diagnostics
|
|
738
|
+
</CardTitle>
|
|
739
|
+
<CardMeta>
|
|
740
|
+
System health, file counts, recent errors.{' '}
|
|
741
|
+
<button type="button" className="link-btn" onClick={onReload}>Refresh</button>
|
|
742
|
+
</CardMeta>
|
|
743
|
+
{loading && <p className="muted">Loading diagnostics…</p>}
|
|
744
|
+
{diagnostics && (
|
|
745
|
+
<>
|
|
746
|
+
<div className="diagnostics-grid">
|
|
747
|
+
<div className="diagnostic-tile">
|
|
748
|
+
<div className="diagnostic-tile-label">Version</div>
|
|
749
|
+
<div className="diagnostic-tile-value mono">{diagnostics.version}</div>
|
|
750
|
+
</div>
|
|
751
|
+
<div className="diagnostic-tile">
|
|
752
|
+
<div className="diagnostic-tile-label">Uptime</div>
|
|
753
|
+
<div className="diagnostic-tile-value mono">{diagnostics.uptime}s</div>
|
|
754
|
+
</div>
|
|
755
|
+
<div className="diagnostic-tile">
|
|
756
|
+
<div className="diagnostic-tile-label">Node</div>
|
|
757
|
+
<div className="diagnostic-tile-value mono">{diagnostics.nodeVersion}</div>
|
|
758
|
+
</div>
|
|
759
|
+
<div className="diagnostic-tile">
|
|
760
|
+
<div className="diagnostic-tile-label">Platform</div>
|
|
761
|
+
<div className="diagnostic-tile-value mono">{diagnostics.platform}</div>
|
|
762
|
+
</div>
|
|
763
|
+
<div className="diagnostic-tile">
|
|
764
|
+
<div className="diagnostic-tile-label">Memory (heap)</div>
|
|
765
|
+
<div className="diagnostic-tile-value mono">
|
|
766
|
+
{Math.round(diagnostics.memory.heapUsed / 1024 / 1024)}MB
|
|
767
|
+
</div>
|
|
768
|
+
</div>
|
|
769
|
+
<div className="diagnostic-tile">
|
|
770
|
+
<div className="diagnostic-tile-label">Memory (RSS)</div>
|
|
771
|
+
<div className="diagnostic-tile-value mono">
|
|
772
|
+
{Math.round(diagnostics.memory.rss / 1024 / 1024)}MB
|
|
773
|
+
</div>
|
|
774
|
+
</div>
|
|
775
|
+
<div className="diagnostic-tile">
|
|
776
|
+
<div className="diagnostic-tile-label">Service</div>
|
|
777
|
+
<div className="diagnostic-tile-value">
|
|
778
|
+
{diagnostics.service.running ? (
|
|
779
|
+
<span className="tag tag-success">running (pid {diagnostics.service.pid})</span>
|
|
780
|
+
) : (
|
|
781
|
+
<span className="tag tag-neutral">stopped</span>
|
|
782
|
+
)}
|
|
783
|
+
</div>
|
|
784
|
+
</div>
|
|
785
|
+
<div className="diagnostic-tile">
|
|
786
|
+
<div className="diagnostic-tile-label">Active project</div>
|
|
787
|
+
<div className="diagnostic-tile-value mono">
|
|
788
|
+
{diagnostics.counts.activeProject || '—'}
|
|
789
|
+
</div>
|
|
790
|
+
</div>
|
|
791
|
+
</div>
|
|
792
|
+
<div className="diagnostic-counts">
|
|
793
|
+
<span>agents: <strong>{diagnostics.counts.agents}</strong></span>
|
|
794
|
+
<span>projects: <strong>{diagnostics.counts.projects}</strong></span>
|
|
795
|
+
<span>mods: <strong>{diagnostics.counts.mods}</strong></span>
|
|
796
|
+
<span>schedules: <strong>{diagnostics.counts.schedules}</strong></span>
|
|
797
|
+
<span>tasks: <strong>{diagnostics.counts.tasks}</strong></span>
|
|
798
|
+
<span>providers: <strong>{diagnostics.counts.providers}</strong></span>
|
|
799
|
+
<span>mcps: <strong>{diagnostics.counts.mcps}</strong></span>
|
|
800
|
+
</div>
|
|
801
|
+
<div className="diagnostic-errors">
|
|
802
|
+
<div className="muted">Last {diagnostics.errors.length} errors from service.log</div>
|
|
803
|
+
{diagnostics.errors.length === 0 ? (
|
|
804
|
+
<p className="muted">No errors recorded.</p>
|
|
805
|
+
) : (
|
|
806
|
+
<ul>
|
|
807
|
+
{diagnostics.errors.map((e, i) => (
|
|
808
|
+
<li key={i} className="mono">
|
|
809
|
+
{e.ts && <span className="muted">[{e.ts}] </span>}
|
|
810
|
+
{e.line}
|
|
811
|
+
</li>
|
|
812
|
+
))}
|
|
813
|
+
</ul>
|
|
814
|
+
)}
|
|
815
|
+
</div>
|
|
816
|
+
</>
|
|
817
|
+
)}
|
|
818
|
+
</Card>
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/* ──────────────────────────────────────────────────────────────
|
|
823
|
+
Providers Panel — fully editable with proper modal forms.
|
|
824
|
+
────────────────────────────────────────────────────────────── */
|
|
825
|
+
|
|
826
|
+
type ProviderDraft = {
|
|
827
|
+
id: string;
|
|
828
|
+
name: string;
|
|
829
|
+
baseURL: string;
|
|
830
|
+
apiKey: string;
|
|
831
|
+
models: string[];
|
|
832
|
+
enabled: boolean;
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
function ProvidersPanel({
|
|
836
|
+
providers,
|
|
837
|
+
onChange,
|
|
838
|
+
onReload,
|
|
839
|
+
}: {
|
|
840
|
+
providers: Provider[];
|
|
841
|
+
onChange: (p: Provider[]) => void;
|
|
842
|
+
onReload: () => Promise<void>;
|
|
843
|
+
}) {
|
|
844
|
+
const toast = useToast();
|
|
845
|
+
const modal = useModal();
|
|
846
|
+
|
|
847
|
+
const openProviderModal = (existing?: Provider) => {
|
|
848
|
+
let idEl: HTMLInputElement | null = null;
|
|
849
|
+
let nameEl: HTMLInputElement | null = null;
|
|
850
|
+
let baseEl: HTMLInputElement | null = null;
|
|
851
|
+
let keyEl: HTMLInputElement | null = null;
|
|
852
|
+
let modelsEl: HTMLTextAreaElement | null = null;
|
|
853
|
+
let enabledEl: HTMLInputElement | null = null;
|
|
854
|
+
|
|
855
|
+
const isEdit = !!existing;
|
|
856
|
+
const initialId = existing?.id || '';
|
|
857
|
+
const initialName = existing?.name || '';
|
|
858
|
+
const initialBase = existing?.baseURL || '';
|
|
859
|
+
// Show masked key as-is in edit mode (so user can see what's stored).
|
|
860
|
+
const initialKey = existing?.apiKey || '';
|
|
861
|
+
const initialModels = (existing?.models || []).join(', ');
|
|
862
|
+
const initialEnabled = existing?.enabled !== false;
|
|
863
|
+
|
|
864
|
+
modal.open({
|
|
865
|
+
title: isEdit ? `Edit provider "${existing!.id}"` : 'Add provider',
|
|
866
|
+
children: (
|
|
867
|
+
<div>
|
|
868
|
+
<div className="modal-form-row">
|
|
869
|
+
<label>ID (a-z, 0-9, dashes)</label>
|
|
870
|
+
<input
|
|
871
|
+
ref={(el) => (idEl = el)}
|
|
872
|
+
className="input"
|
|
873
|
+
type="text"
|
|
874
|
+
defaultValue={initialId}
|
|
875
|
+
placeholder="anthropic"
|
|
876
|
+
disabled={isEdit}
|
|
877
|
+
/>
|
|
878
|
+
</div>
|
|
879
|
+
<div className="modal-form-row">
|
|
880
|
+
<label>Display name</label>
|
|
881
|
+
<input
|
|
882
|
+
ref={(el) => (nameEl = el)}
|
|
883
|
+
className="input"
|
|
884
|
+
type="text"
|
|
885
|
+
defaultValue={initialName}
|
|
886
|
+
placeholder="Anthropic"
|
|
887
|
+
/>
|
|
888
|
+
</div>
|
|
889
|
+
<div className="modal-form-row">
|
|
890
|
+
<label>Base URL</label>
|
|
891
|
+
<input
|
|
892
|
+
ref={(el) => (baseEl = el)}
|
|
893
|
+
className="input"
|
|
894
|
+
type="text"
|
|
895
|
+
defaultValue={initialBase}
|
|
896
|
+
placeholder="https://api.anthropic.com"
|
|
897
|
+
/>
|
|
898
|
+
</div>
|
|
899
|
+
<div className="modal-form-row">
|
|
900
|
+
<label>API key{isEdit && ' (leave masked value unchanged to keep)'}</label>
|
|
901
|
+
<input
|
|
902
|
+
ref={(el) => (keyEl = el)}
|
|
903
|
+
className="input"
|
|
904
|
+
type="password"
|
|
905
|
+
defaultValue={initialKey}
|
|
906
|
+
placeholder="sk-..."
|
|
907
|
+
autoComplete="off"
|
|
908
|
+
/>
|
|
909
|
+
</div>
|
|
910
|
+
<div className="modal-form-row">
|
|
911
|
+
<label>Models (comma-separated)</label>
|
|
912
|
+
<textarea
|
|
913
|
+
ref={(el) => (modelsEl = el)}
|
|
914
|
+
className="textarea"
|
|
915
|
+
defaultValue={initialModels}
|
|
916
|
+
placeholder="claude-sonnet-4-5, claude-opus-4-1"
|
|
917
|
+
rows={3}
|
|
918
|
+
/>
|
|
919
|
+
</div>
|
|
920
|
+
<div className="modal-form-row">
|
|
921
|
+
<label className="checkbox-row">
|
|
922
|
+
<input
|
|
923
|
+
ref={(el) => (enabledEl = el)}
|
|
924
|
+
type="checkbox"
|
|
925
|
+
defaultChecked={initialEnabled}
|
|
926
|
+
/>
|
|
927
|
+
Enabled
|
|
928
|
+
</label>
|
|
929
|
+
</div>
|
|
930
|
+
</div>
|
|
931
|
+
),
|
|
932
|
+
footer: (
|
|
933
|
+
<div className="modal-footer-actions">
|
|
934
|
+
<Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
|
|
935
|
+
<Button
|
|
936
|
+
variant="primary"
|
|
937
|
+
onClick={async () => {
|
|
938
|
+
const id = (idEl?.value || '').trim();
|
|
939
|
+
const name = (nameEl?.value || '').trim();
|
|
940
|
+
const baseURL = (baseEl?.value || '').trim();
|
|
941
|
+
const apiKey = keyEl?.value || '';
|
|
942
|
+
const modelsRaw = modelsEl?.value || '';
|
|
943
|
+
const models = modelsRaw.split(/[,\n]/).map((m) => m.trim()).filter(Boolean);
|
|
944
|
+
const enabled = !!enabledEl?.checked;
|
|
945
|
+
|
|
946
|
+
if (!id) {
|
|
947
|
+
toast.warning('ID is required.');
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(id)) {
|
|
951
|
+
toast.warning('ID must be a-z, 0-9, dashes.');
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
try {
|
|
955
|
+
const payload: ProviderDraft & { id: string } = {
|
|
956
|
+
id,
|
|
957
|
+
name: name || id,
|
|
958
|
+
baseURL,
|
|
959
|
+
apiKey,
|
|
960
|
+
models,
|
|
961
|
+
enabled,
|
|
962
|
+
};
|
|
963
|
+
if (isEdit) {
|
|
964
|
+
const updated = await api.put<Provider>(
|
|
965
|
+
`/config/providers/${encodeURIComponent(id)}`,
|
|
966
|
+
payload,
|
|
967
|
+
);
|
|
968
|
+
onChange(providers.map((p) => (p.id === id ? updated : p)));
|
|
969
|
+
toast.success(`Provider "${id}" updated.`);
|
|
970
|
+
} else {
|
|
971
|
+
const created = await api.post<Provider>('/config/providers', payload);
|
|
972
|
+
onChange([...providers, created]);
|
|
973
|
+
toast.success(`Provider "${id}" added.`);
|
|
974
|
+
}
|
|
975
|
+
modal.close();
|
|
976
|
+
} catch (err) {
|
|
977
|
+
toast.error(`Save failed: ${(err as Error).message}`);
|
|
978
|
+
}
|
|
979
|
+
}}
|
|
980
|
+
>
|
|
981
|
+
{isEdit ? 'Update' : 'Add'} provider
|
|
982
|
+
</Button>
|
|
983
|
+
</div>
|
|
984
|
+
),
|
|
985
|
+
});
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
const onRemove = async (id: string) => {
|
|
989
|
+
if (!confirm(`Remove provider "${id}"?`)) return;
|
|
990
|
+
try {
|
|
991
|
+
await api.del(`/config/providers/${encodeURIComponent(id)}`);
|
|
992
|
+
onChange(providers.filter((p) => p.id !== id));
|
|
993
|
+
toast.success('Provider removed.');
|
|
994
|
+
} catch (err) {
|
|
995
|
+
toast.error(`Remove failed: ${(err as Error).message}`);
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
return (
|
|
1000
|
+
<Card>
|
|
1001
|
+
<CardTitle>
|
|
1002
|
+
<ServerIcon size={14} /> Providers ({providers.length})
|
|
1003
|
+
</CardTitle>
|
|
1004
|
+
<CardMeta>
|
|
1005
|
+
AI providers configured under <code>opencode.json#provider</code>.
|
|
1006
|
+
Each provider has a base URL, API key, and a list of model IDs.
|
|
1007
|
+
</CardMeta>
|
|
1008
|
+
<AutoDetectBanner onAdd={(id, name, baseURL, key) => {
|
|
1009
|
+
openProviderModal({ id, name, baseURL, apiKey: key } as Provider);
|
|
1010
|
+
}} />
|
|
1011
|
+
<div className="view-actions" style={{ marginBottom: 12 }}>
|
|
1012
|
+
<Button variant="primary" size="sm" onClick={() => openProviderModal()}>
|
|
1013
|
+
<Plus size={14} /> Add provider
|
|
1014
|
+
</Button>
|
|
1015
|
+
<Button variant="ghost" size="sm" onClick={onReload}>
|
|
1016
|
+
<RefreshCw size={14} /> Refresh
|
|
1017
|
+
</Button>
|
|
1018
|
+
</div>
|
|
1019
|
+
{providers.length === 0 ? (
|
|
1020
|
+
<EmptyProviders onAdd={() => openProviderModal()} />
|
|
1021
|
+
) : (
|
|
1022
|
+
<div className="provider-list">
|
|
1023
|
+
{providers.map((p) => (
|
|
1024
|
+
<Card key={p.id} className="provider-row">
|
|
1025
|
+
<div className="provider-row-head">
|
|
1026
|
+
<div style={{ minWidth: 0, flex: 1 }}>
|
|
1027
|
+
<div className="provider-name">{p.name || p.id}</div>
|
|
1028
|
+
<div className="provider-id">{p.id}</div>
|
|
1029
|
+
</div>
|
|
1030
|
+
<div className="provider-actions">
|
|
1031
|
+
<label className="toggle-row" title="Enabled">
|
|
1032
|
+
<input
|
|
1033
|
+
type="checkbox"
|
|
1034
|
+
checked={p.enabled !== false}
|
|
1035
|
+
onChange={async (e) => {
|
|
1036
|
+
try {
|
|
1037
|
+
const updated = await api.put<Provider>(
|
|
1038
|
+
`/config/providers/${encodeURIComponent(p.id)}`,
|
|
1039
|
+
{ enabled: e.target.checked },
|
|
1040
|
+
);
|
|
1041
|
+
onChange(providers.map((x) => (x.id === p.id ? updated : x)));
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
toast.error(`Toggle failed: ${(err as Error).message}`);
|
|
1044
|
+
}
|
|
1045
|
+
}}
|
|
1046
|
+
/>
|
|
1047
|
+
{p.enabled !== false ? 'on' : 'off'}
|
|
1048
|
+
</label>
|
|
1049
|
+
<button
|
|
1050
|
+
type="button"
|
|
1051
|
+
className="icon-btn"
|
|
1052
|
+
aria-label="Edit"
|
|
1053
|
+
title="Edit"
|
|
1054
|
+
onClick={() => openProviderModal(p)}
|
|
1055
|
+
>
|
|
1056
|
+
<Pencil size={12} />
|
|
1057
|
+
</button>
|
|
1058
|
+
<button
|
|
1059
|
+
type="button"
|
|
1060
|
+
className="icon-btn icon-btn-danger"
|
|
1061
|
+
aria-label="Remove"
|
|
1062
|
+
title="Remove"
|
|
1063
|
+
onClick={() => onRemove(p.id)}
|
|
1064
|
+
>
|
|
1065
|
+
<Trash2 size={12} />
|
|
1066
|
+
</button>
|
|
1067
|
+
</div>
|
|
1068
|
+
</div>
|
|
1069
|
+
<div className="provider-meta">
|
|
1070
|
+
<div><span className="muted">Base URL:</span> <code>{p.baseURL || '—'}</code></div>
|
|
1071
|
+
<div><span className="muted">API key:</span> <code>{p.apiKey || '—'}</code></div>
|
|
1072
|
+
<div><span className="muted">Models:</span> {(p.models || []).join(', ') || '—'}</div>
|
|
1073
|
+
</div>
|
|
1074
|
+
</Card>
|
|
1075
|
+
))}
|
|
1076
|
+
</div>
|
|
1077
|
+
)}
|
|
1078
|
+
</Card>
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function EmptyProviders({ onAdd }: { onAdd: () => void }) {
|
|
1083
|
+
return (
|
|
1084
|
+
<div style={{
|
|
1085
|
+
padding: '32px 16px',
|
|
1086
|
+
textAlign: 'center',
|
|
1087
|
+
color: 'var(--text-dim)',
|
|
1088
|
+
border: '1px dashed var(--border)',
|
|
1089
|
+
borderRadius: 'var(--radius-md)',
|
|
1090
|
+
display: 'flex',
|
|
1091
|
+
flexDirection: 'column',
|
|
1092
|
+
gap: 8,
|
|
1093
|
+
alignItems: 'center',
|
|
1094
|
+
}}>
|
|
1095
|
+
<ServerIcon size={28} />
|
|
1096
|
+
<div>No providers configured.</div>
|
|
1097
|
+
<div style={{ fontSize: 12 }}>Add a provider to connect an AI model.</div>
|
|
1098
|
+
<Button variant="primary" size="sm" onClick={onAdd}>
|
|
1099
|
+
<Plus size={14} /> Add your first provider
|
|
1100
|
+
</Button>
|
|
1101
|
+
</div>
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/* ──────────────────────────────────────────────────────────────
|
|
1106
|
+
MCPs Panel — fully editable with proper modal forms.
|
|
1107
|
+
────────────────────────────────────────────────────────────── */
|
|
1108
|
+
|
|
1109
|
+
type McpDraft = {
|
|
1110
|
+
id: string;
|
|
1111
|
+
type: 'local' | 'remote';
|
|
1112
|
+
command: string;
|
|
1113
|
+
args: string[];
|
|
1114
|
+
env: Record<string, string>;
|
|
1115
|
+
url: string;
|
|
1116
|
+
headers: Record<string, string>;
|
|
1117
|
+
oauth: boolean;
|
|
1118
|
+
enabled: boolean;
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
function parseEnv(s: string): Record<string, string> {
|
|
1122
|
+
const out: Record<string, string> = {};
|
|
1123
|
+
for (const line of s.split(/\r?\n/)) {
|
|
1124
|
+
const trimmed = line.trim();
|
|
1125
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
1126
|
+
const eq = trimmed.indexOf('=');
|
|
1127
|
+
if (eq <= 0) continue;
|
|
1128
|
+
const k = trimmed.slice(0, eq).trim();
|
|
1129
|
+
const v = trimmed.slice(eq + 1).trim();
|
|
1130
|
+
if (k) out[k] = v;
|
|
1131
|
+
}
|
|
1132
|
+
return out;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function parseHeaders(s: string): Record<string, string> {
|
|
1136
|
+
const out: Record<string, string> = {};
|
|
1137
|
+
for (const line of s.split(/\r?\n/)) {
|
|
1138
|
+
const trimmed = line.trim();
|
|
1139
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
1140
|
+
const sep = trimmed.indexOf(':');
|
|
1141
|
+
if (sep <= 0) continue;
|
|
1142
|
+
const k = trimmed.slice(0, sep).trim();
|
|
1143
|
+
const v = trimmed.slice(sep + 1).trim();
|
|
1144
|
+
if (k) out[k] = v;
|
|
1145
|
+
}
|
|
1146
|
+
return out;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function McpsPanel({
|
|
1150
|
+
mcps,
|
|
1151
|
+
onChange,
|
|
1152
|
+
onReload,
|
|
1153
|
+
}: {
|
|
1154
|
+
mcps: McpServer[];
|
|
1155
|
+
onChange: (m: McpServer[]) => void;
|
|
1156
|
+
onReload: () => Promise<void>;
|
|
1157
|
+
}) {
|
|
1158
|
+
const toast = useToast();
|
|
1159
|
+
const modal = useModal();
|
|
1160
|
+
|
|
1161
|
+
const openMcpModal = (existing?: McpServer) => {
|
|
1162
|
+
let idEl: HTMLInputElement | null = null;
|
|
1163
|
+
let typeLocalEl: HTMLInputElement | null = null;
|
|
1164
|
+
let typeRemoteEl: HTMLInputElement | null = null;
|
|
1165
|
+
let commandEl: HTMLInputElement | null = null;
|
|
1166
|
+
let argsEl: HTMLInputElement | null = null;
|
|
1167
|
+
let envEl: HTMLTextAreaElement | null = null;
|
|
1168
|
+
let urlEl: HTMLInputElement | null = null;
|
|
1169
|
+
let headersEl: HTMLTextAreaElement | null = null;
|
|
1170
|
+
let oauthEl: HTMLInputElement | null = null;
|
|
1171
|
+
let enabledEl: HTMLInputElement | null = null;
|
|
1172
|
+
|
|
1173
|
+
const isEdit = !!existing;
|
|
1174
|
+
const isRemote = existing?.type === 'remote' || (!existing && false);
|
|
1175
|
+
const initialType: 'local' | 'remote' = existing?.type === 'remote' ? 'remote' : 'local';
|
|
1176
|
+
const initialCommand = existing?.command || '';
|
|
1177
|
+
const initialArgs = (existing?.args || []).join(' ');
|
|
1178
|
+
const initialEnv = existing?.env ? Object.entries(existing.env).map(([k, v]) => `${k}=${v}`).join('\n') : '';
|
|
1179
|
+
const initialUrl = existing?.url || '';
|
|
1180
|
+
const initialHeaders = existing?.headers ? Object.entries(existing.headers).map(([k, v]) => `${k}: ${v}`).join('\n') : '';
|
|
1181
|
+
const initialOauth = !!existing?.oauth;
|
|
1182
|
+
const initialEnabled = existing?.enabled !== false;
|
|
1183
|
+
|
|
1184
|
+
modal.open({
|
|
1185
|
+
title: isEdit ? `Edit MCP "${existing!.id}"` : 'Add MCP',
|
|
1186
|
+
children: (
|
|
1187
|
+
<div>
|
|
1188
|
+
<div className="modal-form-row">
|
|
1189
|
+
<label>ID (a-z, 0-9, dashes)</label>
|
|
1190
|
+
<input
|
|
1191
|
+
ref={(el) => (idEl = el)}
|
|
1192
|
+
className="input"
|
|
1193
|
+
type="text"
|
|
1194
|
+
defaultValue={existing?.id || ''}
|
|
1195
|
+
placeholder="supabase"
|
|
1196
|
+
disabled={isEdit}
|
|
1197
|
+
/>
|
|
1198
|
+
</div>
|
|
1199
|
+
<div className="modal-form-row">
|
|
1200
|
+
<label>Type</label>
|
|
1201
|
+
<div style={{ display: 'flex', gap: 12 }}>
|
|
1202
|
+
<label className="radio-label">
|
|
1203
|
+
<input
|
|
1204
|
+
ref={(el) => (typeLocalEl = el)}
|
|
1205
|
+
type="radio"
|
|
1206
|
+
name="mcp-type"
|
|
1207
|
+
value="local"
|
|
1208
|
+
defaultChecked={initialType === 'local'}
|
|
1209
|
+
/>
|
|
1210
|
+
Local (stdio)
|
|
1211
|
+
</label>
|
|
1212
|
+
<label className="radio-label">
|
|
1213
|
+
<input
|
|
1214
|
+
ref={(el) => (typeRemoteEl = el)}
|
|
1215
|
+
type="radio"
|
|
1216
|
+
name="mcp-type"
|
|
1217
|
+
value="remote"
|
|
1218
|
+
defaultChecked={initialType === 'remote'}
|
|
1219
|
+
/>
|
|
1220
|
+
Remote (HTTP)
|
|
1221
|
+
</label>
|
|
1222
|
+
</div>
|
|
1223
|
+
</div>
|
|
1224
|
+
|
|
1225
|
+
<div data-mcp-section="local">
|
|
1226
|
+
<div className="modal-form-row">
|
|
1227
|
+
<label>Command (binary to invoke)</label>
|
|
1228
|
+
<input
|
|
1229
|
+
ref={(el) => (commandEl = el)}
|
|
1230
|
+
className="input"
|
|
1231
|
+
type="text"
|
|
1232
|
+
defaultValue={initialCommand}
|
|
1233
|
+
placeholder="uvx --from semble[mcp] semble"
|
|
1234
|
+
/>
|
|
1235
|
+
</div>
|
|
1236
|
+
<div className="modal-form-row">
|
|
1237
|
+
<label>Extra args (space-separated)</label>
|
|
1238
|
+
<input
|
|
1239
|
+
ref={(el) => (argsEl = el)}
|
|
1240
|
+
className="input"
|
|
1241
|
+
type="text"
|
|
1242
|
+
defaultValue={initialArgs}
|
|
1243
|
+
placeholder="--port 8080"
|
|
1244
|
+
/>
|
|
1245
|
+
<div className="field-help">These are appended after the command.</div>
|
|
1246
|
+
</div>
|
|
1247
|
+
<div className="modal-form-row">
|
|
1248
|
+
<label>Environment variables</label>
|
|
1249
|
+
<textarea
|
|
1250
|
+
ref={(el) => (envEl = el)}
|
|
1251
|
+
className="textarea"
|
|
1252
|
+
defaultValue={initialEnv}
|
|
1253
|
+
placeholder="API_KEY=xxx DEBUG=true"
|
|
1254
|
+
rows={3}
|
|
1255
|
+
/>
|
|
1256
|
+
<div className="field-help">One KEY=VALUE per line.</div>
|
|
1257
|
+
</div>
|
|
1258
|
+
</div>
|
|
1259
|
+
|
|
1260
|
+
<div data-mcp-section="remote">
|
|
1261
|
+
<div className="modal-form-row">
|
|
1262
|
+
<label>URL</label>
|
|
1263
|
+
<input
|
|
1264
|
+
ref={(el) => (urlEl = el)}
|
|
1265
|
+
className="input"
|
|
1266
|
+
type="text"
|
|
1267
|
+
defaultValue={initialUrl}
|
|
1268
|
+
placeholder="https://mcp.example.com/mcp"
|
|
1269
|
+
/>
|
|
1270
|
+
</div>
|
|
1271
|
+
<div className="modal-form-row">
|
|
1272
|
+
<label>Headers</label>
|
|
1273
|
+
<textarea
|
|
1274
|
+
ref={(el) => (headersEl = el)}
|
|
1275
|
+
className="textarea"
|
|
1276
|
+
defaultValue={initialHeaders}
|
|
1277
|
+
placeholder="Authorization: Bearer xxx Content-Type: application/json"
|
|
1278
|
+
rows={3}
|
|
1279
|
+
/>
|
|
1280
|
+
<div className="field-help">One "Key: Value" per line.</div>
|
|
1281
|
+
</div>
|
|
1282
|
+
<div className="modal-form-row">
|
|
1283
|
+
<label className="checkbox-row">
|
|
1284
|
+
<input
|
|
1285
|
+
ref={(el) => (oauthEl = el)}
|
|
1286
|
+
type="checkbox"
|
|
1287
|
+
defaultChecked={initialOauth}
|
|
1288
|
+
/>
|
|
1289
|
+
Use OAuth (browser-based auth flow)
|
|
1290
|
+
</label>
|
|
1291
|
+
</div>
|
|
1292
|
+
</div>
|
|
1293
|
+
|
|
1294
|
+
<div className="modal-form-row">
|
|
1295
|
+
<label className="checkbox-row">
|
|
1296
|
+
<input
|
|
1297
|
+
ref={(el) => (enabledEl = el)}
|
|
1298
|
+
type="checkbox"
|
|
1299
|
+
defaultChecked={initialEnabled}
|
|
1300
|
+
/>
|
|
1301
|
+
Enabled
|
|
1302
|
+
</label>
|
|
1303
|
+
</div>
|
|
1304
|
+
</div>
|
|
1305
|
+
),
|
|
1306
|
+
footer: (
|
|
1307
|
+
<div className="modal-footer-actions">
|
|
1308
|
+
<Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
|
|
1309
|
+
<Button
|
|
1310
|
+
variant="primary"
|
|
1311
|
+
onClick={async () => {
|
|
1312
|
+
const id = (idEl?.value || '').trim();
|
|
1313
|
+
const isRemote = !!typeRemoteEl?.checked;
|
|
1314
|
+
const command = (commandEl?.value || '').trim();
|
|
1315
|
+
const argsStr = (argsEl?.value || '').trim();
|
|
1316
|
+
const args = argsStr ? argsStr.split(/\s+/) : [];
|
|
1317
|
+
const env = parseEnv(envEl?.value || '');
|
|
1318
|
+
const url = (urlEl?.value || '').trim();
|
|
1319
|
+
const headers = parseHeaders(headersEl?.value || '');
|
|
1320
|
+
const oauth = !!oauthEl?.checked;
|
|
1321
|
+
const enabled = !!enabledEl?.checked;
|
|
1322
|
+
|
|
1323
|
+
if (!id) {
|
|
1324
|
+
toast.warning('ID is required.');
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(id)) {
|
|
1328
|
+
toast.warning('ID must be a-z, 0-9, dashes.');
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
try {
|
|
1332
|
+
const payload: McpDraft = {
|
|
1333
|
+
id,
|
|
1334
|
+
type: isRemote ? 'remote' : 'local',
|
|
1335
|
+
command,
|
|
1336
|
+
args,
|
|
1337
|
+
env,
|
|
1338
|
+
url,
|
|
1339
|
+
headers,
|
|
1340
|
+
oauth,
|
|
1341
|
+
enabled,
|
|
1342
|
+
};
|
|
1343
|
+
if (isEdit) {
|
|
1344
|
+
const updated = await api.put<McpServer>(
|
|
1345
|
+
`/config/mcps/${encodeURIComponent(id)}`,
|
|
1346
|
+
payload,
|
|
1347
|
+
);
|
|
1348
|
+
onChange(mcps.map((m) => (m.id === id ? updated : m)));
|
|
1349
|
+
toast.success(`MCP "${id}" updated.`);
|
|
1350
|
+
} else {
|
|
1351
|
+
const created = await api.post<McpServer>('/config/mcps', payload);
|
|
1352
|
+
onChange([...mcps, created]);
|
|
1353
|
+
toast.success(`MCP "${id}" added.`);
|
|
1354
|
+
}
|
|
1355
|
+
modal.close();
|
|
1356
|
+
} catch (err) {
|
|
1357
|
+
toast.error(`Save failed: ${(err as Error).message}`);
|
|
1358
|
+
}
|
|
1359
|
+
}}
|
|
1360
|
+
>
|
|
1361
|
+
{isEdit ? 'Update' : 'Add'} MCP
|
|
1362
|
+
</Button>
|
|
1363
|
+
</div>
|
|
1364
|
+
),
|
|
1365
|
+
});
|
|
1366
|
+
};
|
|
1367
|
+
|
|
1368
|
+
const onRemove = async (id: string) => {
|
|
1369
|
+
if (!confirm(`Remove MCP "${id}"?`)) return;
|
|
1370
|
+
try {
|
|
1371
|
+
await api.del(`/config/mcps/${encodeURIComponent(id)}`);
|
|
1372
|
+
onChange(mcps.filter((m) => m.id !== id));
|
|
1373
|
+
toast.success('MCP removed.');
|
|
1374
|
+
} catch (err) {
|
|
1375
|
+
toast.error(`Remove failed: ${(err as Error).message}`);
|
|
1376
|
+
}
|
|
1377
|
+
};
|
|
1378
|
+
|
|
1379
|
+
return (
|
|
1380
|
+
<Card>
|
|
1381
|
+
<CardTitle>
|
|
1382
|
+
<Plug size={14} /> MCPs ({mcps.length})
|
|
1383
|
+
</CardTitle>
|
|
1384
|
+
<CardMeta>
|
|
1385
|
+
Model Context Protocol servers under <code>opencode.json#mcp</code>.
|
|
1386
|
+
Local MCPs run as stdio subprocesses; remote MCPs are HTTP endpoints.
|
|
1387
|
+
</CardMeta>
|
|
1388
|
+
<div className="view-actions" style={{ marginBottom: 12 }}>
|
|
1389
|
+
<Button variant="primary" size="sm" onClick={() => openMcpModal()}>
|
|
1390
|
+
<Plus size={14} /> Add MCP
|
|
1391
|
+
</Button>
|
|
1392
|
+
<Button variant="ghost" size="sm" onClick={onReload}>
|
|
1393
|
+
<RefreshCw size={14} /> Refresh
|
|
1394
|
+
</Button>
|
|
1395
|
+
</div>
|
|
1396
|
+
{mcps.length === 0 ? (
|
|
1397
|
+
<div style={{
|
|
1398
|
+
padding: '32px 16px',
|
|
1399
|
+
textAlign: 'center',
|
|
1400
|
+
color: 'var(--text-dim)',
|
|
1401
|
+
border: '1px dashed var(--border)',
|
|
1402
|
+
borderRadius: 'var(--radius-md)',
|
|
1403
|
+
display: 'flex',
|
|
1404
|
+
flexDirection: 'column',
|
|
1405
|
+
gap: 8,
|
|
1406
|
+
alignItems: 'center',
|
|
1407
|
+
}}>
|
|
1408
|
+
<Plug size={28} />
|
|
1409
|
+
<div>No MCPs configured.</div>
|
|
1410
|
+
<Button variant="primary" size="sm" onClick={() => openMcpModal()}>
|
|
1411
|
+
<Plus size={14} /> Add your first MCP
|
|
1412
|
+
</Button>
|
|
1413
|
+
</div>
|
|
1414
|
+
) : (
|
|
1415
|
+
<div className="mcp-list">
|
|
1416
|
+
{mcps.map((m) => (
|
|
1417
|
+
<Card key={m.id} className="mcp-row">
|
|
1418
|
+
<div className="mcp-row-head">
|
|
1419
|
+
<div style={{ minWidth: 0, flex: 1 }}>
|
|
1420
|
+
<div className="mcp-name">{m.id}</div>
|
|
1421
|
+
<div className="mcp-id">
|
|
1422
|
+
{m.type === 'remote' ? 'remote' : 'local'} {m.enabled !== false ? '· enabled' : '· disabled'}
|
|
1423
|
+
</div>
|
|
1424
|
+
</div>
|
|
1425
|
+
<div className="mcp-actions">
|
|
1426
|
+
<label className="toggle-row" title="Enabled">
|
|
1427
|
+
<input
|
|
1428
|
+
type="checkbox"
|
|
1429
|
+
checked={m.enabled !== false}
|
|
1430
|
+
onChange={async (e) => {
|
|
1431
|
+
try {
|
|
1432
|
+
const updated = await api.put<McpServer>(
|
|
1433
|
+
`/config/mcps/${encodeURIComponent(m.id)}`,
|
|
1434
|
+
{ enabled: e.target.checked },
|
|
1435
|
+
);
|
|
1436
|
+
onChange(mcps.map((x) => (x.id === m.id ? updated : x)));
|
|
1437
|
+
} catch (err) {
|
|
1438
|
+
toast.error(`Toggle failed: ${(err as Error).message}`);
|
|
1439
|
+
}
|
|
1440
|
+
}}
|
|
1441
|
+
/>
|
|
1442
|
+
{m.enabled !== false ? 'on' : 'off'}
|
|
1443
|
+
</label>
|
|
1444
|
+
<button
|
|
1445
|
+
type="button"
|
|
1446
|
+
className="icon-btn"
|
|
1447
|
+
aria-label="Edit"
|
|
1448
|
+
title="Edit"
|
|
1449
|
+
onClick={() => openMcpModal(m)}
|
|
1450
|
+
>
|
|
1451
|
+
<Pencil size={12} />
|
|
1452
|
+
</button>
|
|
1453
|
+
<button
|
|
1454
|
+
type="button"
|
|
1455
|
+
className="icon-btn icon-btn-danger"
|
|
1456
|
+
aria-label="Remove"
|
|
1457
|
+
title="Remove"
|
|
1458
|
+
onClick={() => onRemove(m.id)}
|
|
1459
|
+
>
|
|
1460
|
+
<Trash2 size={12} />
|
|
1461
|
+
</button>
|
|
1462
|
+
</div>
|
|
1463
|
+
</div>
|
|
1464
|
+
<div className="mcp-meta">
|
|
1465
|
+
{m.type === 'remote' ? (
|
|
1466
|
+
<>
|
|
1467
|
+
<div><span className="muted">URL:</span> <code>{m.url || '—'}</code></div>
|
|
1468
|
+
{Object.keys(m.headers || {}).length > 0 && (
|
|
1469
|
+
<div><span className="muted">Headers:</span> <code>{Object.keys(m.headers || {}).length} header(s)</code></div>
|
|
1470
|
+
)}
|
|
1471
|
+
{m.oauth && <div><span className="muted">Auth:</span> <code>OAuth</code></div>}
|
|
1472
|
+
</>
|
|
1473
|
+
) : (
|
|
1474
|
+
<>
|
|
1475
|
+
<div><span className="muted">Command:</span> <code>{m.command || '—'}</code></div>
|
|
1476
|
+
{m.args && m.args.length > 0 && (
|
|
1477
|
+
<div><span className="muted">Args:</span> <code>{m.args.join(' ')}</code></div>
|
|
1478
|
+
)}
|
|
1479
|
+
{m.env && Object.keys(m.env).length > 0 && (
|
|
1480
|
+
<div><span className="muted">Env:</span> <code>{Object.keys(m.env).length} var(s)</code></div>
|
|
1481
|
+
)}
|
|
1482
|
+
</>
|
|
1483
|
+
)}
|
|
1484
|
+
</div>
|
|
1485
|
+
</Card>
|
|
1486
|
+
))}
|
|
1487
|
+
</div>
|
|
1488
|
+
)}
|
|
1489
|
+
</Card>
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
function ExportPanel({ onDownload }: { onDownload: () => void }) {
|
|
1494
|
+
return (
|
|
1495
|
+
<Card>
|
|
1496
|
+
<CardTitle>
|
|
1497
|
+
<Download size={14} /> Export / Import
|
|
1498
|
+
</CardTitle>
|
|
1499
|
+
<CardMeta>
|
|
1500
|
+
Download a JSON bundle of diagnostics, configuration, providers, and MCPs
|
|
1501
|
+
for support requests or backup. Server-side imports are not yet implemented.
|
|
1502
|
+
</CardMeta>
|
|
1503
|
+
<div className="view-actions" style={{ marginTop: 12 }}>
|
|
1504
|
+
<Button variant="primary" onClick={onDownload}>
|
|
1505
|
+
<Download size={14} /> Download diagnostics bundle
|
|
1506
|
+
</Button>
|
|
1507
|
+
</div>
|
|
1508
|
+
<div style={{ marginTop: 16, fontSize: 12, color: 'var(--text-dim)' }}>
|
|
1509
|
+
<Database size={12} style={{ display: 'inline', verticalAlign: -2 }} /> Bundles include:
|
|
1510
|
+
opencode.json snapshot, providers list (with masked API keys), MCP list, recent
|
|
1511
|
+
service log errors, and active project metadata.
|
|
1512
|
+
</div>
|
|
1513
|
+
</Card>
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/* ──────────────────────────────────────────────────────────────
|
|
1518
|
+
Memory & LightRAG Panel
|
|
1519
|
+
────────────────────────────────────────────────────────────── */
|
|
1520
|
+
|
|
1521
|
+
type LightragDraft = {
|
|
1522
|
+
enabled: boolean;
|
|
1523
|
+
host: string;
|
|
1524
|
+
port: number | '';
|
|
1525
|
+
workingDir: string;
|
|
1526
|
+
llmBinding: string;
|
|
1527
|
+
embeddingBinding: string;
|
|
1528
|
+
llmBindingHost: string;
|
|
1529
|
+
embeddingBindingHost: string;
|
|
1530
|
+
llmModel: string;
|
|
1531
|
+
embeddingModel: string;
|
|
1532
|
+
apiKeySource: 'env' | 'file';
|
|
1533
|
+
apiKey: string;
|
|
1534
|
+
};
|
|
1535
|
+
|
|
1536
|
+
const LLM_BINDING_OPTIONS = ['ollama', 'openai', 'lollms', 'azure_openai', 'bedrock', 'gemini'];
|
|
1537
|
+
const EMBEDDING_BINDING_OPTIONS = ['ollama', 'openai', 'azure_openai', 'bedrock', 'jina', 'gemini', 'voyageai'];
|
|
1538
|
+
|
|
1539
|
+
function MemoryLightragPanel({
|
|
1540
|
+
status,
|
|
1541
|
+
onReload,
|
|
1542
|
+
logLines,
|
|
1543
|
+
onReloadLog,
|
|
1544
|
+
}: {
|
|
1545
|
+
status: LightragStatus | null;
|
|
1546
|
+
onReload: () => void;
|
|
1547
|
+
logLines: string[];
|
|
1548
|
+
onReloadLog: () => void;
|
|
1549
|
+
}) {
|
|
1550
|
+
const toast = useToast();
|
|
1551
|
+
const [saving, setSaving] = useState(false);
|
|
1552
|
+
const [starting, setStarting] = useState(false);
|
|
1553
|
+
const [stopping, setStopping] = useState(false);
|
|
1554
|
+
const [loadingLog, setLoadingLog] = useState(false);
|
|
1555
|
+
const [showLog, setShowLog] = useState(false);
|
|
1556
|
+
|
|
1557
|
+
// Draft form state — initialise from status once loaded
|
|
1558
|
+
const [draft, setDraft] = useState<LightragDraft>({
|
|
1559
|
+
enabled: true,
|
|
1560
|
+
host: '127.0.0.1',
|
|
1561
|
+
port: 9621,
|
|
1562
|
+
workingDir: '',
|
|
1563
|
+
llmBinding: 'ollama',
|
|
1564
|
+
embeddingBinding: 'ollama',
|
|
1565
|
+
llmBindingHost: '',
|
|
1566
|
+
embeddingBindingHost: '',
|
|
1567
|
+
llmModel: 'minimax/MiniMax-M3',
|
|
1568
|
+
embeddingModel: 'text-embedding-3-small',
|
|
1569
|
+
apiKeySource: 'env',
|
|
1570
|
+
apiKey: '',
|
|
1571
|
+
});
|
|
1572
|
+
const [dirty, setDirty] = useState(false);
|
|
1573
|
+
|
|
1574
|
+
// Sync draft from status when status loads
|
|
1575
|
+
useEffect(() => {
|
|
1576
|
+
if (!status) return;
|
|
1577
|
+
setDraft((d) => ({
|
|
1578
|
+
...d,
|
|
1579
|
+
enabled: true,
|
|
1580
|
+
host: status.host,
|
|
1581
|
+
port: status.port,
|
|
1582
|
+
llmBinding: status.llmBinding,
|
|
1583
|
+
embeddingBinding: status.embeddingBinding,
|
|
1584
|
+
llmBindingHost: status.llmBindingHost || '',
|
|
1585
|
+
embeddingBindingHost: status.embeddingBindingHost || '',
|
|
1586
|
+
llmModel: status.llmModel,
|
|
1587
|
+
embeddingModel: status.embeddingModel,
|
|
1588
|
+
apiKeySource: 'env',
|
|
1589
|
+
apiKey: '',
|
|
1590
|
+
}));
|
|
1591
|
+
setDirty(false);
|
|
1592
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1593
|
+
}, [status?.running]);
|
|
1594
|
+
|
|
1595
|
+
const set = <K extends keyof LightragDraft>(key: K, value: LightragDraft[K]) => {
|
|
1596
|
+
setDraft((d) => ({ ...d, [key]: value }));
|
|
1597
|
+
setDirty(true);
|
|
1598
|
+
};
|
|
1599
|
+
|
|
1600
|
+
const handleSave = async () => {
|
|
1601
|
+
if (saving) return;
|
|
1602
|
+
setSaving(true);
|
|
1603
|
+
try {
|
|
1604
|
+
// Build patch payload
|
|
1605
|
+
const patch: Record<string, unknown> = {
|
|
1606
|
+
enabled: draft.enabled,
|
|
1607
|
+
host: draft.host,
|
|
1608
|
+
port: draft.port === '' ? 9621 : Number(draft.port),
|
|
1609
|
+
workingDir: draft.workingDir,
|
|
1610
|
+
llmBinding: draft.llmBinding,
|
|
1611
|
+
embeddingBinding: draft.embeddingBinding,
|
|
1612
|
+
llmBindingHost: draft.llmBindingHost || null,
|
|
1613
|
+
embeddingBindingHost: draft.embeddingBindingHost || null,
|
|
1614
|
+
llmModel: draft.llmModel,
|
|
1615
|
+
embeddingModel: draft.embeddingModel,
|
|
1616
|
+
apiKeySource: draft.apiKeySource,
|
|
1617
|
+
};
|
|
1618
|
+
if (draft.apiKeySource === 'file' && draft.apiKey !== '') {
|
|
1619
|
+
patch.apiKey = draft.apiKey;
|
|
1620
|
+
}
|
|
1621
|
+
if (draft.apiKeySource === 'env') {
|
|
1622
|
+
patch.apiKey = '<empty>';
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
await api.post('/memory/config', { patch: true, lightrag: patch });
|
|
1626
|
+
toast.success('LightRAG config saved.');
|
|
1627
|
+
setDirty(false);
|
|
1628
|
+
onReload();
|
|
1629
|
+
} catch (err) {
|
|
1630
|
+
toast.error(`Save failed: ${(err as Error).message}`);
|
|
1631
|
+
} finally {
|
|
1632
|
+
setSaving(false);
|
|
1633
|
+
}
|
|
1634
|
+
};
|
|
1635
|
+
|
|
1636
|
+
const handleStart = async () => {
|
|
1637
|
+
if (starting) return;
|
|
1638
|
+
setStarting(true);
|
|
1639
|
+
try {
|
|
1640
|
+
const r = await api.post<{ ok: boolean; error?: string; pid?: number }>('/memory/lightrag/start');
|
|
1641
|
+
if (r.ok) {
|
|
1642
|
+
toast.success(`LightRAG started (pid ${r.pid}).`);
|
|
1643
|
+
} else {
|
|
1644
|
+
toast.error(`Start failed: ${r.error}`);
|
|
1645
|
+
}
|
|
1646
|
+
onReload();
|
|
1647
|
+
} catch (err) {
|
|
1648
|
+
toast.error(`Start failed: ${(err as Error).message}`);
|
|
1649
|
+
} finally {
|
|
1650
|
+
setStarting(false);
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
|
|
1654
|
+
const handleStop = async () => {
|
|
1655
|
+
if (stopping) return;
|
|
1656
|
+
setStopping(true);
|
|
1657
|
+
try {
|
|
1658
|
+
await api.post('/memory/lightrag/stop');
|
|
1659
|
+
toast.success('LightRAG stopped.');
|
|
1660
|
+
onReload();
|
|
1661
|
+
} catch (err) {
|
|
1662
|
+
toast.error(`Stop failed: ${(err as Error).message}`);
|
|
1663
|
+
} finally {
|
|
1664
|
+
setStopping(false);
|
|
1665
|
+
}
|
|
1666
|
+
};
|
|
1667
|
+
|
|
1668
|
+
const handleRestart = async () => {
|
|
1669
|
+
await handleStop();
|
|
1670
|
+
await handleStart();
|
|
1671
|
+
};
|
|
1672
|
+
|
|
1673
|
+
const handleShowLog = async () => {
|
|
1674
|
+
if (!showLog) {
|
|
1675
|
+
setLoadingLog(true);
|
|
1676
|
+
await onReloadLog();
|
|
1677
|
+
setLoadingLog(false);
|
|
1678
|
+
}
|
|
1679
|
+
setShowLog((v) => !v);
|
|
1680
|
+
};
|
|
1681
|
+
|
|
1682
|
+
const running = status?.running ?? false;
|
|
1683
|
+
|
|
1684
|
+
return (
|
|
1685
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
1686
|
+
{/* Status card */}
|
|
1687
|
+
<Card>
|
|
1688
|
+
<CardTitle>
|
|
1689
|
+
<Database size={14} /> LightRAG Server
|
|
1690
|
+
</CardTitle>
|
|
1691
|
+
<CardMeta>
|
|
1692
|
+
In-process embedding server for semantic memory search.{' '}
|
|
1693
|
+
<button type="button" className="link-btn" onClick={onReload}>Refresh</button>
|
|
1694
|
+
</CardMeta>
|
|
1695
|
+
<div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
1696
|
+
{running ? (
|
|
1697
|
+
<span className="tag tag-success">
|
|
1698
|
+
running {status?.pid ? `(pid ${status.pid})` : ''}
|
|
1699
|
+
</span>
|
|
1700
|
+
) : (
|
|
1701
|
+
<span className="tag tag-neutral">stopped</span>
|
|
1702
|
+
)}
|
|
1703
|
+
{status && (
|
|
1704
|
+
<span className="mono text-sm muted">
|
|
1705
|
+
{status.host}:{status.port} · {status.llmBinding} · {status.embeddingModel}
|
|
1706
|
+
</span>
|
|
1707
|
+
)}
|
|
1708
|
+
{status?.lastError && (
|
|
1709
|
+
<span className="text-error text-sm">⚠ {status.lastError}</span>
|
|
1710
|
+
)}
|
|
1711
|
+
</div>
|
|
1712
|
+
<div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
|
|
1713
|
+
{!running ? (
|
|
1714
|
+
<Button variant="primary" size="sm" onClick={handleStart} disabled={starting}>
|
|
1715
|
+
{starting ? <Spinner size="sm" /> : <RefreshCw size={12} />}
|
|
1716
|
+
{starting ? 'Starting…' : 'Start'}
|
|
1717
|
+
</Button>
|
|
1718
|
+
) : (
|
|
1719
|
+
<>
|
|
1720
|
+
<Button variant="secondary" size="sm" onClick={handleRestart} disabled={stopping || starting}>
|
|
1721
|
+
<RefreshCw size={12} /> Restart
|
|
1722
|
+
</Button>
|
|
1723
|
+
<Button variant="ghost" size="sm" onClick={handleStop} disabled={stopping}>
|
|
1724
|
+
{stopping ? <Spinner size="sm" /> : null}
|
|
1725
|
+
{stopping ? 'Stopping…' : 'Stop'}
|
|
1726
|
+
</Button>
|
|
1727
|
+
</>
|
|
1728
|
+
)}
|
|
1729
|
+
<Button variant="ghost" size="sm" onClick={handleShowLog}>
|
|
1730
|
+
{showLog ? 'Hide' : 'Show'} log
|
|
1731
|
+
</Button>
|
|
1732
|
+
</div>
|
|
1733
|
+
{showLog && (
|
|
1734
|
+
<div style={{ marginTop: 12 }}>
|
|
1735
|
+
<div className="field-help" style={{ marginBottom: 6 }}>
|
|
1736
|
+
Last {logLines.length} line(s)
|
|
1737
|
+
<button type="button" className="link-btn" style={{ marginLeft: 8 }} onClick={onReloadLog}>
|
|
1738
|
+
Refresh
|
|
1739
|
+
</button>
|
|
1740
|
+
</div>
|
|
1741
|
+
{loadingLog ? (
|
|
1742
|
+
<p className="muted text-sm">Loading…</p>
|
|
1743
|
+
) : logLines.length === 0 ? (
|
|
1744
|
+
<p className="muted text-sm">No log output yet.</p>
|
|
1745
|
+
) : (
|
|
1746
|
+
<pre className="log-pre">{logLines.join('\n')}</pre>
|
|
1747
|
+
)}
|
|
1748
|
+
</div>
|
|
1749
|
+
)}
|
|
1750
|
+
</Card>
|
|
1751
|
+
|
|
1752
|
+
{/* Binding card */}
|
|
1753
|
+
<Card>
|
|
1754
|
+
<CardTitle>
|
|
1755
|
+
<ShieldCheck size={14} /> Bindings
|
|
1756
|
+
</CardTitle>
|
|
1757
|
+
<CardMeta>Choose the LLM and embedding provider bindings.</CardMeta>
|
|
1758
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
|
|
1759
|
+
<div className="field-row">
|
|
1760
|
+
<label className="field-label">LLM Binding</label>
|
|
1761
|
+
<select
|
|
1762
|
+
className="input"
|
|
1763
|
+
value={draft.llmBinding}
|
|
1764
|
+
onChange={(e) => set('llmBinding', e.target.value)}
|
|
1765
|
+
>
|
|
1766
|
+
{LLM_BINDING_OPTIONS.map((b) => (
|
|
1767
|
+
<option key={b} value={b}>{b}</option>
|
|
1768
|
+
))}
|
|
1769
|
+
</select>
|
|
1770
|
+
</div>
|
|
1771
|
+
<div className="field-row">
|
|
1772
|
+
<label className="field-label">Embedding Binding</label>
|
|
1773
|
+
<select
|
|
1774
|
+
className="input"
|
|
1775
|
+
value={draft.embeddingBinding}
|
|
1776
|
+
onChange={(e) => set('embeddingBinding', e.target.value)}
|
|
1777
|
+
>
|
|
1778
|
+
{EMBEDDING_BINDING_OPTIONS.map((b) => (
|
|
1779
|
+
<option key={b} value={b}>{b}</option>
|
|
1780
|
+
))}
|
|
1781
|
+
</select>
|
|
1782
|
+
</div>
|
|
1783
|
+
{draft.llmBinding !== 'ollama' && (
|
|
1784
|
+
<div className="field-row">
|
|
1785
|
+
<label className="field-label">LLM Binding Host</label>
|
|
1786
|
+
<input
|
|
1787
|
+
className="input"
|
|
1788
|
+
type="text"
|
|
1789
|
+
value={draft.llmBindingHost}
|
|
1790
|
+
onChange={(e) => set('llmBindingHost', e.target.value)}
|
|
1791
|
+
placeholder="https://api.minimax.chat/v1"
|
|
1792
|
+
/>
|
|
1793
|
+
</div>
|
|
1794
|
+
)}
|
|
1795
|
+
{draft.embeddingBinding !== 'ollama' && (
|
|
1796
|
+
<div className="field-row">
|
|
1797
|
+
<label className="field-label">Embedding Binding Host</label>
|
|
1798
|
+
<input
|
|
1799
|
+
className="input"
|
|
1800
|
+
type="text"
|
|
1801
|
+
value={draft.embeddingBindingHost}
|
|
1802
|
+
onChange={(e) => set('embeddingBindingHost', e.target.value)}
|
|
1803
|
+
placeholder="https://api.minimax.chat/v1"
|
|
1804
|
+
/>
|
|
1805
|
+
</div>
|
|
1806
|
+
)}
|
|
1807
|
+
</div>
|
|
1808
|
+
</Card>
|
|
1809
|
+
|
|
1810
|
+
{/* Models card */}
|
|
1811
|
+
<Card>
|
|
1812
|
+
<CardTitle>
|
|
1813
|
+
<ServerIcon size={14} /> Models
|
|
1814
|
+
</CardTitle>
|
|
1815
|
+
<CardMeta>Model IDs for the LLM and embedding engine.</CardMeta>
|
|
1816
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
|
|
1817
|
+
<div className="field-row">
|
|
1818
|
+
<label className="field-label">LLM Model</label>
|
|
1819
|
+
<input
|
|
1820
|
+
className="input"
|
|
1821
|
+
type="text"
|
|
1822
|
+
value={draft.llmModel}
|
|
1823
|
+
onChange={(e) => set('llmModel', e.target.value)}
|
|
1824
|
+
placeholder="minimax/MiniMax-M3"
|
|
1825
|
+
/>
|
|
1826
|
+
</div>
|
|
1827
|
+
<div className="field-row">
|
|
1828
|
+
<label className="field-label">Embedding Model</label>
|
|
1829
|
+
<input
|
|
1830
|
+
className="input"
|
|
1831
|
+
type="text"
|
|
1832
|
+
value={draft.embeddingModel}
|
|
1833
|
+
onChange={(e) => set('embeddingModel', e.target.value)}
|
|
1834
|
+
placeholder="text-embedding-3-small"
|
|
1835
|
+
/>
|
|
1836
|
+
</div>
|
|
1837
|
+
</div>
|
|
1838
|
+
</Card>
|
|
1839
|
+
|
|
1840
|
+
{/* Connection card */}
|
|
1841
|
+
<Card>
|
|
1842
|
+
<CardTitle>
|
|
1843
|
+
<Plug size={14} /> Connection
|
|
1844
|
+
</CardTitle>
|
|
1845
|
+
<CardMeta>Host, port, and working directory for the LightRAG server.</CardMeta>
|
|
1846
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
|
|
1847
|
+
<div className="field-row">
|
|
1848
|
+
<label className="field-label">Host</label>
|
|
1849
|
+
<input
|
|
1850
|
+
className="input"
|
|
1851
|
+
type="text"
|
|
1852
|
+
value={draft.host}
|
|
1853
|
+
onChange={(e) => set('host', e.target.value)}
|
|
1854
|
+
placeholder="127.0.0.1"
|
|
1855
|
+
/>
|
|
1856
|
+
</div>
|
|
1857
|
+
<div className="field-row">
|
|
1858
|
+
<label className="field-label">Port</label>
|
|
1859
|
+
<input
|
|
1860
|
+
className="input"
|
|
1861
|
+
type="number"
|
|
1862
|
+
min={1}
|
|
1863
|
+
max={65535}
|
|
1864
|
+
value={draft.port}
|
|
1865
|
+
onChange={(e) => set('port', e.target.value === '' ? '' : Number(e.target.value))}
|
|
1866
|
+
placeholder="9621"
|
|
1867
|
+
/>
|
|
1868
|
+
</div>
|
|
1869
|
+
<div className="field-row">
|
|
1870
|
+
<label className="field-label">Working Dir</label>
|
|
1871
|
+
<input
|
|
1872
|
+
className="input"
|
|
1873
|
+
type="text"
|
|
1874
|
+
value={draft.workingDir}
|
|
1875
|
+
onChange={(e) => set('workingDir', e.target.value)}
|
|
1876
|
+
placeholder=".bizar/lightrag (default)"
|
|
1877
|
+
/>
|
|
1878
|
+
</div>
|
|
1879
|
+
</div>
|
|
1880
|
+
</Card>
|
|
1881
|
+
|
|
1882
|
+
{/* Credentials card */}
|
|
1883
|
+
<Card>
|
|
1884
|
+
<CardTitle>
|
|
1885
|
+
<ShieldCheck size={14} /> Credentials
|
|
1886
|
+
</CardTitle>
|
|
1887
|
+
<CardMeta>API key source — set in your shell env, or stored in .bizar/memory.json.</CardMeta>
|
|
1888
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
|
|
1889
|
+
<div className="field-row">
|
|
1890
|
+
<label className="field-label">Source</label>
|
|
1891
|
+
<select
|
|
1892
|
+
className="input"
|
|
1893
|
+
value={draft.apiKeySource}
|
|
1894
|
+
onChange={(e) => set('apiKeySource', e.target.value as 'env' | 'file')}
|
|
1895
|
+
>
|
|
1896
|
+
<option value="env">Environment variable (recommended)</option>
|
|
1897
|
+
<option value="file">Stored in .bizar/memory.json</option>
|
|
1898
|
+
</select>
|
|
1899
|
+
</div>
|
|
1900
|
+
{draft.apiKeySource === 'env' ? (
|
|
1901
|
+
<p className="muted text-sm" style={{ padding: '8px 0' }}>
|
|
1902
|
+
Set <code>OPENAI_API_KEY</code> (or provider-specific env var) in your shell before
|
|
1903
|
+
running reindex or queries.
|
|
1904
|
+
</p>
|
|
1905
|
+
) : (
|
|
1906
|
+
<div className="field-row">
|
|
1907
|
+
<label className="field-label">API Key</label>
|
|
1908
|
+
<input
|
|
1909
|
+
className="input"
|
|
1910
|
+
type="password"
|
|
1911
|
+
value={draft.apiKey}
|
|
1912
|
+
onChange={(e) => set('apiKey', e.target.value)}
|
|
1913
|
+
placeholder={status?.running ? '(unchanged — leave blank)' : 'sk-…'}
|
|
1914
|
+
autoComplete="off"
|
|
1915
|
+
/>
|
|
1916
|
+
</div>
|
|
1917
|
+
)}
|
|
1918
|
+
</div>
|
|
1919
|
+
</Card>
|
|
1920
|
+
|
|
1921
|
+
<div className="view-actions">
|
|
1922
|
+
<Button variant="primary" disabled={!dirty || saving} onClick={handleSave}>
|
|
1923
|
+
{saving ? <Spinner size="sm" /> : <Save size={14} />}
|
|
1924
|
+
{saving ? 'Saving…' : 'Save config'}
|
|
1925
|
+
</Button>
|
|
1926
|
+
</div>
|
|
1927
|
+
</div>
|
|
1928
|
+
);
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// ─── AutoDetectBanner (v3.16.0) ────────────────────────────────────
|
|
1932
|
+
// Scans env vars + opencode.json for known provider API keys
|
|
1933
|
+
// (Anthropic, OpenAI, Google, Mistral, Groq, Cohere, OpenRouter,
|
|
1934
|
+
// DeepSeek, MiniMax). Surfaces status: configured / unknown / no-key.
|
|
1935
|
+
type AutoDetectResult = {
|
|
1936
|
+
id: string;
|
|
1937
|
+
name: string;
|
|
1938
|
+
baseURL?: string;
|
|
1939
|
+
status: 'configured' | 'unknown' | 'no-key';
|
|
1940
|
+
keySource: string;
|
|
1941
|
+
hasKey: boolean;
|
|
1942
|
+
probed?: { ok: boolean; status?: number; reason?: string; modelCount?: number } | null;
|
|
1943
|
+
};
|
|
1944
|
+
|
|
1945
|
+
function AutoDetectBanner({
|
|
1946
|
+
onAdd,
|
|
1947
|
+
}: {
|
|
1948
|
+
onAdd: (id: string, name: string, baseURL: string, apiKey?: string) => void;
|
|
1949
|
+
}) {
|
|
1950
|
+
const toast = useToast();
|
|
1951
|
+
const [results, setResults] = useState<AutoDetectResult[] | null>(null);
|
|
1952
|
+
const [loading, setLoading] = useState(false);
|
|
1953
|
+
const [expanded, setExpanded] = useState(false);
|
|
1954
|
+
|
|
1955
|
+
const run = async (probe: boolean) => {
|
|
1956
|
+
setLoading(true);
|
|
1957
|
+
try {
|
|
1958
|
+
const r = await api.get<{ providers: AutoDetectResult[] }>(
|
|
1959
|
+
`/providers/auto-detect${probe ? '' : '?probe=0'}`,
|
|
1960
|
+
);
|
|
1961
|
+
setResults(r.providers || []);
|
|
1962
|
+
const configured = (r.providers || []).filter((p) => p.status === 'configured');
|
|
1963
|
+
if (configured.length > 0) {
|
|
1964
|
+
toast.success(`Detected ${configured.length} configured provider${configured.length === 1 ? '' : 's'}.`);
|
|
1965
|
+
} else {
|
|
1966
|
+
toast.info('No configured providers detected in env or config.');
|
|
1967
|
+
}
|
|
1968
|
+
} catch (err) {
|
|
1969
|
+
toast.error(`Auto-detect failed: ${(err as Error).message}`);
|
|
1970
|
+
} finally {
|
|
1971
|
+
setLoading(false);
|
|
1972
|
+
}
|
|
1973
|
+
};
|
|
1974
|
+
|
|
1975
|
+
const statusIcon = (s: string) => {
|
|
1976
|
+
if (s === 'configured') return <ShieldCheck size={12} style={{ color: 'var(--success)' }} />;
|
|
1977
|
+
if (s === 'unknown') return <AlertTriangle size={12} style={{ color: 'var(--warning)' }} />;
|
|
1978
|
+
return <X size={12} style={{ color: 'var(--text-dim)' }} />;
|
|
1979
|
+
};
|
|
1980
|
+
|
|
1981
|
+
const configured = (results || []).filter((p) => p.status === 'configured');
|
|
1982
|
+
const other = (results || []).filter((p) => p.status !== 'configured');
|
|
1983
|
+
|
|
1984
|
+
return (
|
|
1985
|
+
<Card className="autodetect-banner">
|
|
1986
|
+
<div className="autodetect-head" onClick={() => setExpanded((v) => !v)}>
|
|
1987
|
+
<ShieldCheck size={14} />
|
|
1988
|
+
<span className="autodetect-title">Auto-detect providers</span>
|
|
1989
|
+
<span className="muted" style={{ fontSize: 11 }}>
|
|
1990
|
+
{results
|
|
1991
|
+
? `${configured.length} configured · ${other.length} other`
|
|
1992
|
+
: 'scan env vars + opencode.json for known keys'}
|
|
1993
|
+
</span>
|
|
1994
|
+
<span className="autodetect-spacer" />
|
|
1995
|
+
<Button
|
|
1996
|
+
variant="ghost"
|
|
1997
|
+
size="sm"
|
|
1998
|
+
onClick={(e) => {
|
|
1999
|
+
e.stopPropagation();
|
|
2000
|
+
run(true);
|
|
2001
|
+
}}
|
|
2002
|
+
disabled={loading}
|
|
2003
|
+
title="Probe each provider's /models endpoint"
|
|
2004
|
+
>
|
|
2005
|
+
{loading ? <Spinner size="sm" /> : <RefreshCw size={12} />} Detect
|
|
2006
|
+
</Button>
|
|
2007
|
+
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
2008
|
+
</div>
|
|
2009
|
+
{expanded && (
|
|
2010
|
+
<div className="autodetect-body">
|
|
2011
|
+
{!results ? (
|
|
2012
|
+
<div className="muted" style={{ padding: 12, fontSize: 12 }}>
|
|
2013
|
+
Click <strong>Detect</strong> to scan env vars and opencode.json for known provider keys.
|
|
2014
|
+
Probes each provider's <code>/models</code> endpoint with a 1.5s timeout.
|
|
2015
|
+
</div>
|
|
2016
|
+
) : (
|
|
2017
|
+
<table className="autodetect-table">
|
|
2018
|
+
<thead>
|
|
2019
|
+
<tr>
|
|
2020
|
+
<th>Status</th>
|
|
2021
|
+
<th>Provider</th>
|
|
2022
|
+
<th>Source</th>
|
|
2023
|
+
<th>Probe</th>
|
|
2024
|
+
<th></th>
|
|
2025
|
+
</tr>
|
|
2026
|
+
</thead>
|
|
2027
|
+
<tbody>
|
|
2028
|
+
{results.map((r) => (
|
|
2029
|
+
<tr key={r.id}>
|
|
2030
|
+
<td>{statusIcon(r.status)} <span className="autodetect-status">{r.status}</span></td>
|
|
2031
|
+
<td><strong>{r.name}</strong> <span className="muted mono">{r.id}</span></td>
|
|
2032
|
+
<td className="muted">{r.keySource || '—'}</td>
|
|
2033
|
+
<td className="muted">
|
|
2034
|
+
{r.probed?.modelCount != null
|
|
2035
|
+
? `${r.probed.modelCount} models`
|
|
2036
|
+
: r.probed?.reason
|
|
2037
|
+
? r.probed.reason
|
|
2038
|
+
: r.probed?.ok
|
|
2039
|
+
? 'ok'
|
|
2040
|
+
: '—'}
|
|
2041
|
+
</td>
|
|
2042
|
+
<td>
|
|
2043
|
+
{r.status === 'configured' && (
|
|
2044
|
+
<Button
|
|
2045
|
+
variant="ghost"
|
|
2046
|
+
size="sm"
|
|
2047
|
+
onClick={() => onAdd(r.id, r.name, r.baseURL || '')}
|
|
2048
|
+
title="Add this provider to opencode.json"
|
|
2049
|
+
>
|
|
2050
|
+
<Plus size={10} /> Add
|
|
2051
|
+
</Button>
|
|
2052
|
+
)}
|
|
2053
|
+
</td>
|
|
2054
|
+
</tr>
|
|
2055
|
+
))}
|
|
2056
|
+
</tbody>
|
|
2057
|
+
</table>
|
|
2058
|
+
)}
|
|
2059
|
+
</div>
|
|
2060
|
+
)}
|
|
2061
|
+
</Card>
|
|
2062
|
+
);
|
|
2063
|
+
}
|