@geminixiang/mikan 0.3.1 → 0.4.0-beta.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/CHANGELOG.md +23 -0
- package/dist/adapter.d.ts +1 -138
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js.map +1 -1
- package/dist/adapters/discord/bot.d.ts +1 -4
- package/dist/adapters/discord/bot.d.ts.map +1 -1
- package/dist/adapters/discord/bot.js +25 -33
- package/dist/adapters/discord/bot.js.map +1 -1
- package/dist/adapters/discord/context.d.ts.map +1 -1
- package/dist/adapters/discord/context.js +28 -0
- package/dist/adapters/discord/context.js.map +1 -1
- package/dist/adapters/discord/types.d.ts +6 -0
- package/dist/adapters/discord/types.d.ts.map +1 -0
- package/dist/adapters/discord/types.js +2 -0
- package/dist/adapters/discord/types.js.map +1 -0
- package/dist/adapters/intake.d.ts +11 -0
- package/dist/adapters/intake.d.ts.map +1 -0
- package/dist/adapters/intake.js +42 -0
- package/dist/adapters/intake.js.map +1 -0
- package/dist/adapters/shared.d.ts +7 -31
- package/dist/adapters/shared.d.ts.map +1 -1
- package/dist/adapters/shared.js +18 -2
- package/dist/adapters/shared.js.map +1 -1
- package/dist/adapters/slack/bot.d.ts +14 -33
- package/dist/adapters/slack/bot.d.ts.map +1 -1
- package/dist/adapters/slack/bot.js +148 -116
- package/dist/adapters/slack/bot.js.map +1 -1
- package/dist/adapters/slack/context.d.ts +3 -4
- package/dist/adapters/slack/context.d.ts.map +1 -1
- package/dist/adapters/slack/context.js +97 -14
- package/dist/adapters/slack/context.js.map +1 -1
- package/dist/adapters/slack/session.d.ts +5 -20
- package/dist/adapters/slack/session.d.ts.map +1 -1
- package/dist/adapters/slack/session.js.map +1 -1
- package/dist/adapters/slack/types.d.ts +84 -0
- package/dist/adapters/slack/types.d.ts.map +1 -0
- package/dist/adapters/slack/types.js +2 -0
- package/dist/adapters/slack/types.js.map +1 -0
- package/dist/adapters/streaming.d.ts +18 -0
- package/dist/adapters/streaming.d.ts.map +1 -0
- package/dist/adapters/streaming.js +44 -0
- package/dist/adapters/streaming.js.map +1 -0
- package/dist/adapters/telegram/bot.d.ts +1 -4
- package/dist/adapters/telegram/bot.d.ts.map +1 -1
- package/dist/adapters/telegram/bot.js +32 -39
- package/dist/adapters/telegram/bot.js.map +1 -1
- package/dist/adapters/telegram/context.d.ts.map +1 -1
- package/dist/adapters/telegram/context.js +33 -0
- package/dist/adapters/telegram/context.js.map +1 -1
- package/dist/adapters/telegram/types.d.ts +6 -0
- package/dist/adapters/telegram/types.d.ts.map +1 -0
- package/dist/adapters/telegram/types.js +2 -0
- package/dist/adapters/telegram/types.js.map +1 -0
- package/dist/adapters/types.d.ts +58 -0
- package/dist/adapters/types.d.ts.map +1 -0
- package/dist/adapters/types.js +2 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/agent.d.ts +4 -16
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +31 -22
- package/dist/agent.js.map +1 -1
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +1 -1
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/auto-reply.d.ts.map +1 -1
- package/dist/commands/auto-reply.js +1 -8
- package/dist/commands/auto-reply.js.map +1 -1
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +3 -3
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/model.d.ts +5 -8
- package/dist/commands/model.d.ts.map +1 -1
- package/dist/commands/model.js +15 -20
- package/dist/commands/model.js.map +1 -1
- package/dist/commands/new.d.ts.map +1 -1
- package/dist/commands/new.js +5 -10
- package/dist/commands/new.js.map +1 -1
- package/dist/commands/parse.d.ts.map +1 -1
- package/dist/commands/parse.js +1 -4
- package/dist/commands/parse.js.map +1 -1
- package/dist/commands/registry.d.ts +1 -0
- package/dist/commands/registry.d.ts.map +1 -1
- package/dist/commands/registry.js +23 -0
- package/dist/commands/registry.js.map +1 -1
- package/dist/commands/sandbox.d.ts +2 -5
- package/dist/commands/sandbox.d.ts.map +1 -1
- package/dist/commands/sandbox.js +11 -16
- package/dist/commands/sandbox.js.map +1 -1
- package/dist/commands/session-view.d.ts.map +1 -1
- package/dist/commands/session-view.js +10 -15
- package/dist/commands/session-view.js.map +1 -1
- package/dist/commands/types.d.ts +11 -2
- package/dist/commands/types.d.ts.map +1 -1
- package/dist/commands/types.js.map +1 -1
- package/dist/config.d.ts +6 -28
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +43 -41
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +1 -15
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js.map +1 -1
- package/dist/events.d.ts +3 -44
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +2 -9
- package/dist/events.js.map +1 -1
- package/dist/execution-resolver.d.ts +3 -7
- package/dist/execution-resolver.d.ts.map +1 -1
- package/dist/execution-resolver.js +8 -8
- package/dist/execution-resolver.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/log.d.ts +2 -6
- package/dist/log.d.ts.map +1 -1
- package/dist/log.js +1 -37
- package/dist/log.js.map +1 -1
- package/dist/main.d.ts +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +16 -16
- package/dist/main.js.map +1 -1
- package/dist/observability/instrument.d.ts.map +1 -0
- package/dist/{instrument.js → observability/instrument.js} +2 -2
- package/dist/observability/instrument.js.map +1 -0
- package/dist/{sentry.d.ts → observability/sentry.d.ts} +2 -30
- package/dist/observability/sentry.d.ts.map +1 -0
- package/dist/observability/sentry.js.map +1 -0
- package/dist/observability/types.d.ts +31 -0
- package/dist/observability/types.d.ts.map +1 -0
- package/dist/observability/types.js +2 -0
- package/dist/observability/types.js.map +1 -0
- package/dist/{ui-copy.d.ts → platform-messages.d.ts} +1 -1
- package/dist/platform-messages.d.ts.map +1 -0
- package/dist/{ui-copy.js → platform-messages.js} +1 -1
- package/dist/platform-messages.js.map +1 -0
- package/dist/portal-shell.d.ts +2 -28
- package/dist/portal-shell.d.ts.map +1 -1
- package/dist/portal-shell.js +2 -2
- package/dist/portal-shell.js.map +1 -1
- package/dist/provisioner.d.ts +2 -23
- package/dist/provisioner.d.ts.map +1 -1
- package/dist/provisioner.js +1 -1
- package/dist/provisioner.js.map +1 -1
- package/dist/runtime/conversation-orchestrator.d.ts +4 -19
- package/dist/runtime/conversation-orchestrator.d.ts.map +1 -1
- package/dist/runtime/conversation-orchestrator.js +3 -3
- package/dist/runtime/conversation-orchestrator.js.map +1 -1
- package/dist/runtime/session-runtime.d.ts +2 -23
- package/dist/runtime/session-runtime.d.ts.map +1 -1
- package/dist/runtime/session-runtime.js +7 -9
- package/dist/runtime/session-runtime.js.map +1 -1
- package/dist/runtime/types.d.ts +35 -0
- package/dist/runtime/types.d.ts.map +1 -0
- package/dist/runtime/types.js +2 -0
- package/dist/runtime/types.js.map +1 -0
- package/dist/sandbox/cloudflare.d.ts.map +1 -1
- package/dist/sandbox/cloudflare.js +1 -1
- package/dist/sandbox/cloudflare.js.map +1 -1
- package/dist/sandbox/container.d.ts.map +1 -1
- package/dist/sandbox/container.js +1 -4
- package/dist/sandbox/container.js.map +1 -1
- package/dist/sessions/chat-session-manager.d.ts +2 -46
- package/dist/sessions/chat-session-manager.d.ts.map +1 -1
- package/dist/sessions/chat-session-manager.js +12 -40
- package/dist/sessions/chat-session-manager.js.map +1 -1
- package/dist/sessions/metadata.d.ts +1 -13
- package/dist/sessions/metadata.d.ts.map +1 -1
- package/dist/sessions/metadata.js.map +1 -1
- package/dist/sessions/policy.d.ts +3 -10
- package/dist/sessions/policy.d.ts.map +1 -1
- package/dist/sessions/policy.js.map +1 -1
- package/dist/sessions/store.d.ts +1 -12
- package/dist/sessions/store.d.ts.map +1 -1
- package/dist/sessions/store.js +4 -7
- package/dist/sessions/store.js.map +1 -1
- package/dist/sessions/types.d.ts +76 -0
- package/dist/sessions/types.d.ts.map +1 -0
- package/dist/sessions/types.js +2 -0
- package/dist/sessions/types.js.map +1 -0
- package/dist/store.d.ts +2 -19
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1 -1
- package/dist/store.js.map +1 -1
- package/dist/tools/event.d.ts +30 -36
- package/dist/tools/event.d.ts.map +1 -1
- package/dist/tools/event.js +207 -26
- package/dist/tools/event.js.map +1 -1
- package/dist/tools/index.d.ts +2 -2
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/sandbox.d.ts.map +1 -1
- package/dist/tools/sandbox.js +1 -1
- package/dist/tools/sandbox.js.map +1 -1
- package/dist/tools/truncate.d.ts +2 -26
- package/dist/tools/truncate.d.ts.map +1 -1
- package/dist/tools/truncate.js.map +1 -1
- package/dist/tools/types.d.ts +54 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/trigger.d.ts +2 -13
- package/dist/trigger.d.ts.map +1 -1
- package/dist/trigger.js.map +1 -1
- package/dist/types.d.ts +307 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/date.d.ts +10 -0
- package/dist/utils/date.d.ts.map +1 -0
- package/dist/utils/date.js +23 -0
- package/dist/utils/date.js.map +1 -0
- package/dist/utils/env.d.ts.map +1 -0
- package/dist/utils/env.js.map +1 -0
- package/dist/utils/file-guards.d.ts.map +1 -0
- package/dist/utils/file-guards.js.map +1 -0
- package/dist/utils/fs-atomic.d.ts.map +1 -0
- package/dist/utils/fs-atomic.js.map +1 -0
- package/dist/utils/html.d.ts.map +1 -0
- package/dist/utils/html.js.map +1 -0
- package/dist/utils/http-body.d.ts +10 -0
- package/dist/utils/http-body.d.ts.map +1 -0
- package/dist/utils/http-body.js +34 -0
- package/dist/utils/http-body.js.map +1 -0
- package/dist/vault/index.d.ts +34 -0
- package/dist/vault/index.d.ts.map +1 -0
- package/dist/{vault.js → vault/index.js} +4 -4
- package/dist/vault/index.js.map +1 -0
- package/dist/{vault-routing.d.ts → vault/routing.d.ts} +2 -2
- package/dist/vault/routing.d.ts.map +1 -0
- package/dist/{vault-routing.js → vault/routing.js} +2 -2
- package/dist/vault/routing.js.map +1 -0
- package/dist/{vault.d.ts → vault/types.d.ts} +3 -34
- package/dist/vault/types.d.ts.map +1 -0
- package/dist/vault/types.js +2 -0
- package/dist/vault/types.js.map +1 -0
- package/dist/web/admin/portal.d.ts +5 -0
- package/dist/web/admin/portal.d.ts.map +1 -0
- package/dist/{admin → web/admin}/portal.js +140 -52
- package/dist/web/admin/portal.js.map +1 -0
- package/dist/web/admin/store.d.ts +13 -0
- package/dist/web/admin/store.d.ts.map +1 -0
- package/dist/web/admin/store.js +23 -0
- package/dist/web/admin/store.js.map +1 -0
- package/dist/web/admin/types.d.ts +28 -0
- package/dist/web/admin/types.d.ts.map +1 -0
- package/dist/web/admin/types.js +2 -0
- package/dist/web/admin/types.js.map +1 -0
- package/dist/web/login/oauth.d.ts +6 -0
- package/dist/web/login/oauth.d.ts.map +1 -0
- package/dist/{login/index.js → web/login/oauth.js} +33 -30
- package/dist/web/login/oauth.js.map +1 -0
- package/dist/{login → web/login}/portal.d.ts +5 -5
- package/dist/web/login/portal.d.ts.map +1 -0
- package/dist/{login → web/login}/portal.js +16 -35
- package/dist/web/login/portal.js.map +1 -0
- package/dist/web/login/store.d.ts +12 -0
- package/dist/web/login/store.d.ts.map +1 -0
- package/dist/web/login/store.js +28 -0
- package/dist/web/login/store.js.map +1 -0
- package/dist/web/login/types.d.ts +50 -0
- package/dist/web/login/types.d.ts.map +1 -0
- package/dist/web/login/types.js +2 -0
- package/dist/web/login/types.js.map +1 -0
- package/dist/web/session-view/command.d.ts +4 -0
- package/dist/web/session-view/command.d.ts.map +1 -0
- package/dist/{session-view → web/session-view}/command.js +1 -1
- package/dist/web/session-view/command.js.map +1 -0
- package/dist/{session-view → web/session-view}/portal.d.ts +2 -5
- package/dist/web/session-view/portal.d.ts.map +1 -0
- package/dist/{session-view → web/session-view}/portal.js +5 -5
- package/dist/web/session-view/portal.js.map +1 -0
- package/dist/web/session-view/service.d.ts +6 -0
- package/dist/web/session-view/service.d.ts.map +1 -0
- package/dist/{session-view → web/session-view}/service.js +6 -36
- package/dist/web/session-view/service.js.map +1 -0
- package/dist/web/session-view/store.d.ts +8 -0
- package/dist/web/session-view/store.d.ts.map +1 -0
- package/dist/web/session-view/store.js +20 -0
- package/dist/web/session-view/store.js.map +1 -0
- package/dist/{session-view/service.d.ts → web/session-view/types.d.ts} +20 -4
- package/dist/web/session-view/types.d.ts.map +1 -0
- package/dist/web/session-view/types.js +2 -0
- package/dist/web/session-view/types.js.map +1 -0
- package/dist/web/token-store.d.ts +19 -0
- package/dist/web/token-store.d.ts.map +1 -0
- package/dist/web/token-store.js +45 -0
- package/dist/web/token-store.js.map +1 -0
- package/dist/web/types.d.ts +5 -0
- package/dist/web/types.d.ts.map +1 -0
- package/dist/web/types.js +2 -0
- package/dist/web/types.js.map +1 -0
- package/package.json +1 -1
- package/dist/adapters/discord/index.d.ts +0 -3
- package/dist/adapters/discord/index.d.ts.map +0 -1
- package/dist/adapters/discord/index.js +0 -3
- package/dist/adapters/discord/index.js.map +0 -1
- package/dist/adapters/slack/index.d.ts +0 -3
- package/dist/adapters/slack/index.d.ts.map +0 -1
- package/dist/adapters/slack/index.js +0 -3
- package/dist/adapters/slack/index.js.map +0 -1
- package/dist/adapters/slack/thread-manager.d.ts +0 -19
- package/dist/adapters/slack/thread-manager.d.ts.map +0 -1
- package/dist/adapters/slack/thread-manager.js +0 -11
- package/dist/adapters/slack/thread-manager.js.map +0 -1
- package/dist/adapters/telegram/index.d.ts +0 -3
- package/dist/adapters/telegram/index.d.ts.map +0 -1
- package/dist/adapters/telegram/index.js +0 -3
- package/dist/adapters/telegram/index.js.map +0 -1
- package/dist/admin/portal.d.ts +0 -27
- package/dist/admin/portal.d.ts.map +0 -1
- package/dist/admin/portal.js.map +0 -1
- package/dist/admin/store.d.ts +0 -22
- package/dist/admin/store.d.ts.map +0 -1
- package/dist/admin/store.js +0 -39
- package/dist/admin/store.js.map +0 -1
- package/dist/commands/index.d.ts +0 -5
- package/dist/commands/index.d.ts.map +0 -1
- package/dist/commands/index.js +0 -20
- package/dist/commands/index.js.map +0 -1
- package/dist/env.d.ts.map +0 -1
- package/dist/env.js.map +0 -1
- package/dist/file-guards.d.ts.map +0 -1
- package/dist/file-guards.js.map +0 -1
- package/dist/fs-atomic.d.ts.map +0 -1
- package/dist/fs-atomic.js.map +0 -1
- package/dist/html.d.ts.map +0 -1
- package/dist/html.js.map +0 -1
- package/dist/instrument.d.ts.map +0 -1
- package/dist/instrument.js.map +0 -1
- package/dist/login/index.d.ts +0 -43
- package/dist/login/index.d.ts.map +0 -1
- package/dist/login/index.js.map +0 -1
- package/dist/login/portal.d.ts.map +0 -1
- package/dist/login/portal.js.map +0 -1
- package/dist/login/store.d.ts +0 -26
- package/dist/login/store.d.ts.map +0 -1
- package/dist/login/store.js +0 -56
- package/dist/login/store.js.map +0 -1
- package/dist/runtime/index.d.ts +0 -2
- package/dist/runtime/index.d.ts.map +0 -1
- package/dist/runtime/index.js +0 -2
- package/dist/runtime/index.js.map +0 -1
- package/dist/sentry.d.ts.map +0 -1
- package/dist/sentry.js.map +0 -1
- package/dist/session-view/command.d.ts +0 -5
- package/dist/session-view/command.d.ts.map +0 -1
- package/dist/session-view/command.js.map +0 -1
- package/dist/session-view/portal.d.ts.map +0 -1
- package/dist/session-view/portal.js.map +0 -1
- package/dist/session-view/service.d.ts.map +0 -1
- package/dist/session-view/service.js.map +0 -1
- package/dist/session-view/store.d.ts +0 -18
- package/dist/session-view/store.d.ts.map +0 -1
- package/dist/session-view/store.js +0 -36
- package/dist/session-view/store.js.map +0 -1
- package/dist/ui-copy.d.ts.map +0 -1
- package/dist/ui-copy.js.map +0 -1
- package/dist/vault-routing.d.ts.map +0 -1
- package/dist/vault-routing.js.map +0 -1
- package/dist/vault.d.ts.map +0 -1
- package/dist/vault.js.map +0 -1
- /package/dist/{instrument.d.ts → observability/instrument.d.ts} +0 -0
- /package/dist/{sentry.js → observability/sentry.js} +0 -0
- /package/dist/{env.d.ts → utils/env.d.ts} +0 -0
- /package/dist/{env.js → utils/env.js} +0 -0
- /package/dist/{file-guards.d.ts → utils/file-guards.d.ts} +0 -0
- /package/dist/{file-guards.js → utils/file-guards.js} +0 -0
- /package/dist/{fs-atomic.d.ts → utils/fs-atomic.d.ts} +0 -0
- /package/dist/{fs-atomic.js → utils/fs-atomic.js} +0 -0
- /package/dist/{html.d.ts → utils/html.d.ts} +0 -0
- /package/dist/{html.js → utils/html.js} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"portal.d.ts","sourceRoot":"","sources":["../../../src/web/admin/portal.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,MAAM,CAAC;AAuB5D,YAAY,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AACpE,OAAO,KAAK,EAAsB,aAAa,EAAE,MAAM,YAAY,CAAC;AAIpE,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,GAAG,EACR,QAAQ,EAAE,aAAa,GACtB,OAAO,CA6BT","sourcesContent":["import { existsSync, readdirSync, readFileSync, rmSync, statSync } from \"fs\";\nimport type { IncomingMessage, ServerResponse } from \"http\";\nimport { homedir } from \"os\";\nimport { join, resolve as pathResolve, sep as pathSep } from \"path\";\nimport { AuthStorage, ModelRegistry } from \"@earendil-works/pi-coding-agent\";\n\nimport {\n loadConversationAutoReplyConfig,\n loadGlobalSettings,\n resolveConversationSettings,\n saveConversationAutoReplyConfig,\n updateConversationSettings,\n updateGlobalSettings,\n type AgentConfig,\n} from \"../../config.js\";\nimport { escapeHtml } from \"../../utils/html.js\";\nimport { readRawBody } from \"../../utils/http-body.js\";\nimport { renderPortalShell } from \"../../portal-shell.js\";\nimport { resolveExistingSessionFile } from \"../session-view/service.js\";\nimport { PRODUCT_NAME } from \"../../platform-messages.js\";\nimport { resolveActorVaultKey } from \"../../vault/routing.js\";\nimport { sharedVaultKey } from \"../../vault/index.js\";\nimport type { AdminToken } from \"./store.js\";\n\nexport type { AdminRuntimeBridge, AdminServices } from \"./types.js\";\nimport type { AdminRuntimeBridge, AdminServices } from \"./types.js\";\n\n// ── Handler ────────────────────────────────────────────────────────────────────\n\nexport function handleAdminRequest(\n req: IncomingMessage,\n res: ServerResponse,\n url: URL,\n services: AdminServices,\n): boolean {\n if (!url.pathname.startsWith(\"/admin\")) return false;\n\n if (req.method === \"GET\" && url.pathname === \"/admin\") {\n const provided = url.searchParams.get(\"token\") ?? \"\";\n const token = services.adminTokenStore.peek(provided);\n if (!token) {\n res.writeHead(403, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n renderAdminErrorPage(\n \"Admin link is missing, invalid, or expired. Send `/admin` to the bot to get a fresh link.\",\n ),\n );\n return true;\n }\n res.writeHead(200, {\n \"Content-Type\": \"text/html; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n });\n res.end(renderAdminPage(token));\n return true;\n }\n\n if (url.pathname.startsWith(\"/admin/api/\")) {\n routeApiRequest(req, res, url, services);\n return true;\n }\n\n return false;\n}\n\n// ── API routing ────────────────────────────────────────────────────────────────\n\nfunction routeApiRequest(\n req: IncomingMessage,\n res: ServerResponse,\n url: URL,\n services: AdminServices,\n): void {\n if (req.method === \"GET\") {\n const token = services.adminTokenStore.peek(url.searchParams.get(\"token\") ?? \"\");\n if (!token) {\n jsonRes(res, 403, { error: \"Unauthorized\" });\n return;\n }\n if (url.pathname === \"/admin/api/me\") {\n serveMe(res, token);\n return;\n }\n if (url.pathname === \"/admin/api/conversations\") {\n serveConversationsList(res, services);\n return;\n }\n if (url.pathname === \"/admin/api/conversation-state\") {\n serveConversationState(res, url, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/settings/global\") {\n serveGlobalSettings(res);\n return;\n }\n if (url.pathname === \"/admin/api/models\") {\n void serveModelsList(res);\n return;\n }\n if (url.pathname === \"/admin/api/workspace/tree\") {\n serveWorkspaceTree(res, url, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/workspace/file\") {\n serveWorkspaceFile(res, url, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/skills\") {\n serveSkillsList(res, url, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/skills/file\") {\n serveSkillFile(res, url, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/events\") {\n serveEventsList(res, services);\n return;\n }\n if (url.pathname === \"/admin/api/events/file\") {\n serveEventsFile(res, url, services);\n return;\n }\n if (url.pathname === \"/admin/api/conversations/events\") {\n serveConversationEventsList(res, url, services, token);\n return;\n }\n jsonRes(res, 404, { error: \"Not found\" });\n return;\n }\n\n if (req.method === \"POST\") {\n void readJsonBody(req, res, (body) => {\n const rawToken = typeof body.token === \"string\" ? body.token : \"\";\n const token = services.adminTokenStore.peek(rawToken);\n if (!token) {\n jsonRes(res, 403, { error: \"Unauthorized\" });\n return;\n }\n if (url.pathname === \"/admin/api/conversations/model\") {\n serveConversationModelUpdate(res, body, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/conversations/sandbox\") {\n serveConversationSandboxUpdate(res, body, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/conversations/auto-reply\") {\n serveConversationAutoReplyUpdate(res, body, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/conversations/slack\") {\n serveConversationSlackUpdate(res, body, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/conversations/session-link\") {\n serveConversationSessionLink(res, body, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/conversations/login-link\") {\n serveConversationLoginLink(res, body, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/conversations/events/delete\") {\n serveConversationEventDelete(res, body, services, token);\n return;\n }\n if (url.pathname === \"/admin/api/settings/model\") {\n serveGlobalModelUpdate(res, body);\n return;\n }\n if (url.pathname === \"/admin/api/settings/sandbox\") {\n serveGlobalSandboxUpdate(res, body);\n return;\n }\n if (url.pathname === \"/admin/api/settings/slack\") {\n serveGlobalSlackUpdate(res, body);\n return;\n }\n jsonRes(res, 404, { error: \"Not found\" });\n });\n return;\n }\n\n jsonRes(res, 405, { error: \"Method not allowed\" });\n}\n\n// ── Scope helpers ──────────────────────────────────────────────────────────────\n\nfunction resolveConversationId(\n requested: string,\n token: AdminToken,\n): { conversationId: string; error?: string } {\n if (!requested) return { conversationId: token.conversationId };\n if (requested === token.conversationId) return { conversationId: requested };\n if (requested.includes(\"/\") || requested.includes(\"..\")) {\n return { conversationId: requested, error: \"Invalid conversationId.\" };\n }\n return { conversationId: requested };\n}\n\nfunction resolveTargetConversation(\n body: Record<string, unknown>,\n token: AdminToken,\n): { conversationId: string; error?: string } {\n const requested = typeof body.conversationId === \"string\" ? body.conversationId.trim() : \"\";\n return resolveConversationId(requested, token);\n}\n\nfunction requireAdminWorkingDir(res: ServerResponse, services: AdminServices): string | null {\n if (!services.workingDir) {\n jsonRes(res, 503, { error: \"Working directory not available\" });\n return null;\n }\n return services.workingDir;\n}\n\n// ── API handlers ───────────────────────────────────────────────────────────────\n\nfunction serveMe(res: ServerResponse, token: AdminToken): void {\n jsonRes(res, 200, {\n platform: token.platform,\n platformUserId: token.platformUserId,\n platformUserName: token.platformUserName ?? null,\n conversationId: token.conversationId,\n expiresAt: token.expiresAt,\n });\n}\n\nconst SETTINGS_FILES = new Set([\"settings.json\", \"auto-reply\", \"auto-reply.disabled\"]);\n\nfunction listConversationDirs(workingDir: string): string[] {\n if (!existsSync(workingDir)) return [];\n const skip = new Set([\"vaults\", \"skills\", \"events\", \"node_modules\", \".git\"]);\n return readdirSync(workingDir, { withFileTypes: true })\n .filter((entry) => entry.isDirectory() && !entry.name.startsWith(\".\") && !skip.has(entry.name))\n .map((entry) => entry.name)\n .filter((name) => {\n const dir = join(workingDir, name);\n try {\n const items = readdirSync(dir);\n return items.some((item) => SETTINGS_FILES.has(item) || item.endsWith(\".jsonl\"));\n } catch {\n return false;\n }\n })\n .toSorted((a, b) => a.localeCompare(b));\n}\n\nfunction conversationLastActivity(workingDir: string, conversationId: string): number | null {\n const dir = join(workingDir, conversationId);\n if (!existsSync(dir)) return null;\n let latest = 0;\n const visit = (path: string, depth: number): void => {\n if (depth > 3) return;\n let entries;\n try {\n entries = readdirSync(path, { withFileTypes: true });\n } catch {\n return;\n }\n for (const entry of entries) {\n const full = join(path, entry.name);\n if (entry.isDirectory()) {\n visit(full, depth + 1);\n continue;\n }\n try {\n const stats = statSync(full);\n if (stats.mtimeMs > latest) latest = stats.mtimeMs;\n } catch {\n // ignore\n }\n }\n };\n visit(dir, 0);\n return latest > 0 ? latest : null;\n}\n\nfunction conversationDisplayLabel(services: AdminServices, conversationId: string): string {\n for (const [platform, bot] of Object.entries(services.botsByPlatform ?? {})) {\n const channel = bot?.getPlatformInfo().channels.find((c) => c.id === conversationId);\n if (channel) return `${platform}:#${channel.name}:${conversationId}`;\n }\n return conversationId;\n}\n\nfunction serveConversationsList(res: ServerResponse, services: AdminServices): void {\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n\n const ids = listConversationDirs(workingDir);\n\n const runningKeys = new Set<string>(\n services.runtime?.getRunningSessions().map((s) => s.sessionKey) ?? [],\n );\n\n const conversations = ids.map((conversationId) => {\n const lastActivity = conversationLastActivity(workingDir, conversationId);\n const running = Array.from(runningKeys).some(\n (key) => key === conversationId || key.startsWith(`${conversationId}:`),\n );\n return {\n conversationId,\n label: conversationDisplayLabel(services, conversationId),\n running,\n lastActivityAt: lastActivity,\n };\n });\n\n jsonRes(res, 200, { conversations });\n}\n\nfunction serveConversationState(\n res: ServerResponse,\n url: URL,\n services: AdminServices,\n token: AdminToken,\n): void {\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n\n const requested = url.searchParams.get(\"conversationId\")?.trim() ?? \"\";\n const conversationId = requested || token.conversationId;\n if (conversationId.includes(\"/\") || conversationId.includes(\"..\")) {\n jsonRes(res, 400, { error: \"Invalid conversationId\" });\n return;\n }\n\n const dir = join(workingDir, conversationId);\n const globalConfig = loadGlobalSettings();\n const conversationConfig = resolveConversationSettings(dir);\n const autoReply = loadConversationAutoReplyConfig(dir);\n\n jsonRes(res, 200, {\n conversationId,\n provider: conversationConfig.provider,\n model: conversationConfig.model,\n thinkingLevel: conversationConfig.thinkingLevel,\n globalProvider: globalConfig.provider,\n globalModel: globalConfig.model,\n globalThinkingLevel: globalConfig.thinkingLevel,\n sandboxImageWorkspaceMount: conversationConfig.sandboxImageWorkspaceMount ?? null,\n globalSandboxImageWorkspaceMount: globalConfig.sandboxImageWorkspaceMount ?? null,\n autoReplyEnabled: autoReply.enabled,\n autoReplyRules: autoReply.rules,\n slack: {\n replyMode:\n conversationConfig.slack?.replyMode ?? globalConfig.slack?.replyMode ?? \"top-level\",\n globalReplyMode: globalConfig.slack?.replyMode ?? \"top-level\",\n },\n });\n}\n\nfunction serveGlobalSettings(res: ServerResponse): void {\n try {\n const config = loadGlobalSettings();\n jsonRes(res, 200, {\n provider: config.provider,\n model: config.model,\n thinkingLevel: config.thinkingLevel,\n sandboxCpus: config.sandboxCpus ?? null,\n sandboxMemory: config.sandboxMemory ?? null,\n sandboxBoostCpus: config.sandboxBoostCpus ?? null,\n sandboxBoostMemory: config.sandboxBoostMemory ?? null,\n sandboxImageWorkspaceMount: config.sandboxImageWorkspaceMount ?? null,\n defaultSharedVault: config.defaultSharedVault ?? null,\n slack: {\n replyMode: config.slack?.replyMode ?? \"top-level\",\n },\n });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nasync function serveModelsList(res: ServerResponse): Promise<void> {\n try {\n const authStorage = AuthStorage.create(join(homedir(), \".pi\", \"mikan\", \"auth.json\"));\n const registry = ModelRegistry.create(authStorage);\n const models = (await registry.getAvailable()).map((model) => ({\n provider: model.provider,\n id: model.id,\n name: model.name ?? model.id,\n reasoning: model.reasoning,\n input: model.input,\n contextWindow: model.contextWindow,\n maxTokens: model.maxTokens,\n }));\n jsonRes(res, 200, { models });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nconst VALID_THINKING_LEVELS = new Set([\"off\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\"]);\n\nfunction serveConversationModelUpdate(\n res: ServerResponse,\n body: Record<string, unknown>,\n services: AdminServices,\n token: AdminToken,\n): void {\n const provider = typeof body.provider === \"string\" ? body.provider.trim() : \"\";\n const model = typeof body.model === \"string\" ? body.model.trim() : \"\";\n const thinkingLevel =\n typeof body.thinkingLevel === \"string\" && VALID_THINKING_LEVELS.has(body.thinkingLevel)\n ? (body.thinkingLevel as AgentConfig[\"thinkingLevel\"])\n : undefined;\n\n if (!provider || !model) {\n jsonRes(res, 400, { error: \"Missing provider or model\" });\n return;\n }\n const scope = resolveTargetConversation(body, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const dir = join(workingDir, scope.conversationId);\n\n try {\n updateConversationSettings(dir, {\n provider,\n model,\n ...(thinkingLevel ? { thinkingLevel } : {}),\n });\n let runtimeSwitched: boolean | null = null;\n if (services.runtime) {\n runtimeSwitched = services.runtime.switchConversationModel(\n scope.conversationId,\n provider,\n model,\n );\n }\n jsonRes(res, 200, { ok: true, runtimeSwitched });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nfunction serveConversationSandboxUpdate(\n res: ServerResponse,\n body: Record<string, unknown>,\n services: AdminServices,\n token: AdminToken,\n): void {\n const workspaceMount = body.workspaceMount;\n if (workspaceMount !== \"private\" && workspaceMount !== \"full\") {\n jsonRes(res, 400, { error: \"workspaceMount must be 'private' or 'full'\" });\n return;\n }\n const scope = resolveTargetConversation(body, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const dir = join(workingDir, scope.conversationId);\n try {\n updateConversationSettings(dir, { sandboxImageWorkspaceMount: workspaceMount });\n jsonRes(res, 200, { ok: true });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nfunction serveConversationSlackUpdate(\n res: ServerResponse,\n body: Record<string, unknown>,\n services: AdminServices,\n token: AdminToken,\n): void {\n const replyMode = body.replyMode;\n if (replyMode !== \"top-level\" && replyMode !== \"thread\") {\n jsonRes(res, 400, { error: \"replyMode must be 'top-level' or 'thread'\" });\n return;\n }\n const scope = resolveTargetConversation(body, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const dir = join(workingDir, scope.conversationId);\n try {\n updateConversationSettings(dir, { slack: { replyMode } });\n jsonRes(res, 200, { ok: true });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nfunction serveConversationAutoReplyUpdate(\n res: ServerResponse,\n body: Record<string, unknown>,\n services: AdminServices,\n token: AdminToken,\n): void {\n const enabled = body.enabled === true;\n const rules = Array.isArray(body.rules)\n ? body.rules.filter((r): r is string => typeof r === \"string\")\n : undefined;\n const scope = resolveTargetConversation(body, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const dir = join(workingDir, scope.conversationId);\n try {\n const existing = loadConversationAutoReplyConfig(dir);\n saveConversationAutoReplyConfig(dir, {\n enabled,\n rules: rules ?? existing.rules,\n });\n jsonRes(res, 200, { ok: true });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nfunction serveConversationSessionLink(\n res: ServerResponse,\n body: Record<string, unknown>,\n services: AdminServices,\n token: AdminToken,\n): void {\n const scope = resolveTargetConversation(body, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n if (!services.sessionViewTokenStore) {\n jsonRes(res, 503, { error: \"Session view token store not available\" });\n return;\n }\n if (!services.portalBaseUrl) {\n jsonRes(res, 503, {\n error: \"Portal URL not configured. Set MIKAN_LINK_URL to enable link generation.\",\n });\n return;\n }\n\n const sessionFile = resolveExistingSessionFile(\n workingDir,\n scope.conversationId,\n scope.conversationId,\n );\n if (!sessionFile) {\n jsonRes(res, 404, { error: \"No session file found for this conversation\" });\n return;\n }\n\n try {\n const { token: viewToken } = services.sessionViewTokenStore.create(\n token.platform,\n token.platformUserId,\n scope.conversationId,\n scope.conversationId,\n sessionFile,\n token.platformUserName,\n );\n const url = `${services.portalBaseUrl}/session?token=${encodeURIComponent(viewToken)}`;\n jsonRes(res, 200, { ok: true, url });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nfunction serveConversationLoginLink(\n res: ServerResponse,\n body: Record<string, unknown>,\n services: AdminServices,\n token: AdminToken,\n): void {\n const scope = resolveTargetConversation(body, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n if (!services.portalBaseUrl) {\n jsonRes(res, 503, { error: \"Portal URL not configured.\" });\n return;\n }\n if (!services.sandbox) {\n jsonRes(res, 503, { error: \"Sandbox config not available.\" });\n return;\n }\n const sharedName = typeof body.sharedVault === \"string\" ? body.sharedVault.trim() : \"\";\n let vaultId: string;\n if (sharedName) {\n const key = sharedVaultKey(sharedName);\n if (!key) {\n jsonRes(res, 400, { error: \"Invalid shared vault name\" });\n return;\n }\n vaultId = key;\n } else {\n try {\n vaultId = resolveActorVaultKey(services.sandbox, token.platformUserId, scope.conversationId);\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n return;\n }\n }\n try {\n const { token: linkToken } = services.linkTokenStore.create(\n token.platform,\n token.platformUserId,\n scope.conversationId,\n vaultId,\n \"\",\n );\n const url = `${services.portalBaseUrl}/link?token=${encodeURIComponent(linkToken)}`;\n jsonRes(res, 200, { ok: true, url, vaultId });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nfunction serveGlobalModelUpdate(res: ServerResponse, body: Record<string, unknown>): void {\n const provider = typeof body.provider === \"string\" ? body.provider.trim() : \"\";\n const model = typeof body.model === \"string\" ? body.model.trim() : \"\";\n const thinkingLevel =\n typeof body.thinkingLevel === \"string\" && VALID_THINKING_LEVELS.has(body.thinkingLevel)\n ? (body.thinkingLevel as AgentConfig[\"thinkingLevel\"])\n : undefined;\n\n if (!provider || !model) {\n jsonRes(res, 400, { error: \"Missing provider or model\" });\n return;\n }\n\n try {\n updateGlobalSettings({\n provider,\n model,\n ...(thinkingLevel ? { thinkingLevel } : {}),\n });\n jsonRes(res, 200, { ok: true });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nfunction serveGlobalSlackUpdate(res: ServerResponse, body: Record<string, unknown>): void {\n const replyMode = body.replyMode;\n if (replyMode !== \"top-level\" && replyMode !== \"thread\") {\n jsonRes(res, 400, { error: \"replyMode must be 'top-level' or 'thread'\" });\n return;\n }\n\n try {\n updateGlobalSettings({ slack: { replyMode } });\n jsonRes(res, 200, { ok: true });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\nfunction serveGlobalSandboxUpdate(res: ServerResponse, body: Record<string, unknown>): void {\n const cpus = typeof body.cpus === \"string\" ? body.cpus.trim() : \"\";\n const memory = typeof body.memory === \"string\" ? body.memory.trim() : \"\";\n const boostCpus = typeof body.boostCpus === \"string\" ? body.boostCpus.trim() : \"\";\n const boostMemory = typeof body.boostMemory === \"string\" ? body.boostMemory.trim() : \"\";\n const workspaceMount = body.workspaceMount;\n const validMount = workspaceMount === \"private\" || workspaceMount === \"full\";\n\n const update: Partial<AgentConfig> = {};\n if (cpus) update.sandboxCpus = cpus;\n if (memory) update.sandboxMemory = memory;\n if (boostCpus) update.sandboxBoostCpus = boostCpus;\n if (boostMemory) update.sandboxBoostMemory = boostMemory;\n if (validMount) update.sandboxImageWorkspaceMount = workspaceMount as \"private\" | \"full\";\n\n if (Object.keys(update).length === 0) {\n jsonRes(res, 400, { error: \"No valid sandbox fields provided\" });\n return;\n }\n\n try {\n updateGlobalSettings(update);\n jsonRes(res, 200, { ok: true });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\n// ── Workspace ──────────────────────────────────────────────────────────────────\n\nconst WORKSPACE_TREE_MAX_DEPTH = 4;\nconst WORKSPACE_TREE_MAX_ENTRIES = 800;\nconst PREVIEW_FILE_MAX_BYTES = 256 * 1024;\n\nconst WORKSPACE_TOP_FILES = new Set([\"auto-reply\", \"auto-reply.disabled\"]);\nconst WORKSPACE_TOP_DIRS = new Set([\"scratch\"]);\n\n/**\n * Limit what the admin UI can browse under a conversation directory.\n * Allowed: top-level \"scratch/\" subtree, and the two auto-reply marker files.\n */\nfunction isWorkspacePathAllowed(rel: string): boolean {\n if (rel === \"\") return true;\n const segments = rel.split(\"/\").filter(Boolean);\n if (segments.length === 0) return true;\n const first = segments[0];\n if (segments.length === 1) {\n return WORKSPACE_TOP_DIRS.has(first) || WORKSPACE_TOP_FILES.has(first);\n }\n return WORKSPACE_TOP_DIRS.has(first);\n}\n\nfunction resolveConversationFromQuery(\n url: URL,\n token: AdminToken,\n): { conversationId: string; error?: string } {\n const requested = (url.searchParams.get(\"conversationId\") ?? \"\").trim();\n return resolveConversationId(requested, token);\n}\n\ninterface SafePathResult {\n absolute: string;\n error?: string;\n}\n\nfunction safeJoinUnderRoot(rootDir: string, relative: string): SafePathResult {\n if (relative.startsWith(\"/\") || relative.includes(\"\\0\")) {\n return { absolute: \"\", error: \"Invalid path\" };\n }\n if (relative.split(/[\\\\/]+/).some((part) => part === \"..\" || part === \"\")) {\n if (relative !== \"\") return { absolute: \"\", error: \"Invalid path\" };\n }\n const target = pathResolve(rootDir, relative);\n const rootAbs = pathResolve(rootDir);\n if (target !== rootAbs && !target.startsWith(rootAbs + pathSep)) {\n return { absolute: \"\", error: \"Path escapes conversation directory\" };\n }\n return { absolute: target };\n}\n\ninterface TreeNode {\n name: string;\n path: string;\n type: \"dir\" | \"file\";\n size?: number;\n mtimeMs?: number;\n children?: TreeNode[];\n truncated?: boolean;\n}\n\nfunction buildTree(startDir: string, relPrefix: string): TreeNode | null {\n let counter = { value: 0 };\n const walk = (dir: string, rel: string, depth: number): TreeNode | null => {\n if (counter.value >= WORKSPACE_TREE_MAX_ENTRIES) return null;\n let stats;\n try {\n stats = statSync(dir);\n } catch {\n return null;\n }\n const name = rel === \"\" ? \".\" : (rel.split(/[\\\\/]/).pop() ?? rel);\n if (!stats.isDirectory()) {\n counter.value += 1;\n return {\n name,\n path: rel,\n type: \"file\",\n size: stats.size,\n mtimeMs: stats.mtimeMs,\n };\n }\n counter.value += 1;\n if (depth >= WORKSPACE_TREE_MAX_DEPTH) {\n return { name, path: rel, type: \"dir\", truncated: true };\n }\n let entries;\n try {\n entries = readdirSync(dir, { withFileTypes: true });\n } catch {\n return { name, path: rel, type: \"dir\" };\n }\n const children: TreeNode[] = [];\n let truncated = false;\n for (const entry of entries.toSorted((a, b) => {\n if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;\n return a.name.localeCompare(b.name);\n })) {\n const childRel = rel === \"\" ? entry.name : `${rel}/${entry.name}`;\n if (!isWorkspacePathAllowed(childRel)) continue;\n if (counter.value >= WORKSPACE_TREE_MAX_ENTRIES) {\n truncated = true;\n break;\n }\n const node = walk(join(dir, entry.name), childRel, depth + 1);\n if (node) children.push(node);\n }\n return {\n name,\n path: rel,\n type: \"dir\",\n children,\n ...(truncated ? { truncated: true } : {}),\n };\n };\n const node = walk(startDir, relPrefix, 0);\n return node;\n}\n\nfunction serveWorkspaceTree(\n res: ServerResponse,\n url: URL,\n services: AdminServices,\n token: AdminToken,\n): void {\n const scope = resolveConversationFromQuery(url, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const convDir = join(workingDir, scope.conversationId);\n if (!existsSync(convDir)) {\n jsonRes(res, 200, { conversationId: scope.conversationId, tree: null });\n return;\n }\n const requestedSub = (url.searchParams.get(\"path\") ?? \"\").trim();\n if (!isWorkspacePathAllowed(requestedSub)) {\n jsonRes(res, 403, { error: \"Workspace path is not exposed\" });\n return;\n }\n const startSafe = safeJoinUnderRoot(convDir, requestedSub);\n if (startSafe.error) {\n jsonRes(res, 400, { error: startSafe.error });\n return;\n }\n const tree = buildTree(startSafe.absolute, requestedSub);\n jsonRes(res, 200, {\n conversationId: scope.conversationId,\n root: requestedSub || \".\",\n tree,\n });\n}\n\nconst BINARY_PROBE_BYTES = 4096;\n\nfunction looksTextual(buf: Buffer): boolean {\n const limit = Math.min(buf.length, BINARY_PROBE_BYTES);\n for (let i = 0; i < limit; i++) {\n const byte = buf[i];\n if (byte === 0) return false;\n if (byte < 9) return false;\n if (byte === 11 || byte === 12) return false;\n if (byte > 13 && byte < 32) return false;\n }\n return true;\n}\n\nfunction servePreviewFile(\n res: ServerResponse,\n absolutePath: string,\n metadata: Record<string, unknown>,\n notFoundMessage: string,\n): void {\n let stats;\n try {\n stats = statSync(absolutePath);\n } catch {\n jsonRes(res, 404, { error: notFoundMessage });\n return;\n }\n if (!stats.isFile()) {\n jsonRes(res, 400, { error: \"Not a file\" });\n return;\n }\n if (stats.size > PREVIEW_FILE_MAX_BYTES) {\n jsonRes(res, 413, {\n error: \"File too large to preview\",\n size: stats.size,\n limit: PREVIEW_FILE_MAX_BYTES,\n });\n return;\n }\n let buf: Buffer;\n try {\n buf = readFileSync(absolutePath);\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n return;\n }\n if (!looksTextual(buf)) {\n jsonRes(res, 200, {\n ...metadata,\n size: stats.size,\n mtimeMs: stats.mtimeMs,\n binary: true,\n content: null,\n });\n return;\n }\n jsonRes(res, 200, {\n ...metadata,\n size: stats.size,\n mtimeMs: stats.mtimeMs,\n binary: false,\n content: buf.toString(\"utf-8\"),\n });\n}\n\nfunction serveWorkspaceFile(\n res: ServerResponse,\n url: URL,\n services: AdminServices,\n token: AdminToken,\n): void {\n const scope = resolveConversationFromQuery(url, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const requestedPath = (url.searchParams.get(\"path\") ?? \"\").trim();\n if (!requestedPath) {\n jsonRes(res, 400, { error: \"Missing path\" });\n return;\n }\n if (!isWorkspacePathAllowed(requestedPath)) {\n jsonRes(res, 403, { error: \"Workspace path is not exposed\" });\n return;\n }\n const convDir = join(workingDir, scope.conversationId);\n const safe = safeJoinUnderRoot(convDir, requestedPath);\n if (safe.error) {\n jsonRes(res, 400, { error: safe.error });\n return;\n }\n servePreviewFile(res, safe.absolute, { path: requestedPath }, \"File not found\");\n}\n\n// ── Skills ─────────────────────────────────────────────────────────────────────\n\ninterface SkillEntry {\n name: string;\n description: string;\n source: \"global\" | \"conversation\";\n path: string;\n directory: string;\n}\n\nfunction parseSkillFrontmatter(filePath: string): { name?: string; description?: string } {\n let text: string;\n try {\n text = readFileSync(filePath, \"utf-8\");\n } catch {\n return {};\n }\n if (!text.startsWith(\"---\")) return {};\n const end = text.indexOf(\"\\n---\", 3);\n if (end < 0) return {};\n const block = text.slice(3, end);\n const out: { name?: string; description?: string } = {};\n for (const line of block.split(\"\\n\")) {\n const colon = line.indexOf(\":\");\n if (colon < 0) continue;\n const key = line.slice(0, colon).trim().toLowerCase();\n let value = line.slice(colon + 1).trim();\n if (\n (value.startsWith('\"') && value.endsWith('\"')) ||\n (value.startsWith(\"'\") && value.endsWith(\"'\"))\n ) {\n value = value.slice(1, -1);\n }\n if (key === \"name\") out.name = value;\n if (key === \"description\") out.description = value;\n }\n return out;\n}\n\nfunction readSkillsFromDir(skillsDir: string, source: SkillEntry[\"source\"]): SkillEntry[] {\n if (!existsSync(skillsDir)) return [];\n const out: SkillEntry[] = [];\n let entries;\n try {\n entries = readdirSync(skillsDir, { withFileTypes: true });\n } catch {\n return [];\n }\n for (const entry of entries) {\n if (!entry.isDirectory() || entry.name.startsWith(\".\")) continue;\n const skillMd = join(skillsDir, entry.name, \"SKILL.md\");\n if (!existsSync(skillMd)) continue;\n const meta = parseSkillFrontmatter(skillMd);\n out.push({\n name: meta.name ?? entry.name,\n description: meta.description ?? \"\",\n source,\n path: skillMd,\n directory: entry.name,\n });\n }\n return out.toSorted((a, b) => a.name.localeCompare(b.name));\n}\n\nfunction serveSkillsList(\n res: ServerResponse,\n url: URL,\n services: AdminServices,\n token: AdminToken,\n): void {\n const scope = resolveConversationFromQuery(url, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const global = readSkillsFromDir(join(workingDir, \"skills\"), \"global\");\n const conversation = readSkillsFromDir(\n join(workingDir, scope.conversationId, \"skills\"),\n \"conversation\",\n );\n jsonRes(res, 200, {\n conversationId: scope.conversationId,\n skills: [...global, ...conversation],\n });\n}\n\nfunction serveSkillFile(\n res: ServerResponse,\n url: URL,\n services: AdminServices,\n token: AdminToken,\n): void {\n const scope = resolveConversationFromQuery(url, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n\n const source = (url.searchParams.get(\"source\") ?? \"\").trim();\n const directory = (url.searchParams.get(\"directory\") ?? \"\").trim();\n if (source !== \"global\" && source !== \"conversation\") {\n jsonRes(res, 400, { error: \"Invalid skill source\" });\n return;\n }\n if (\n !directory ||\n directory.includes(\"/\") ||\n directory.includes(\"\\\\\") ||\n directory.includes(\"..\")\n ) {\n jsonRes(res, 400, { error: \"Invalid skill directory\" });\n return;\n }\n\n const skillsRoot =\n source === \"global\"\n ? join(workingDir, \"skills\")\n : join(workingDir, scope.conversationId, \"skills\");\n const safe = safeJoinUnderRoot(skillsRoot, join(directory, \"SKILL.md\"));\n if (safe.error) {\n jsonRes(res, 400, { error: safe.error });\n return;\n }\n\n servePreviewFile(res, safe.absolute, { source, directory }, \"Skill file not found\");\n}\n\n// ── Events ─────────────────────────────────────────────────────────────────────\n\nconst EVENTS_FILE_MAX_BYTES = 64 * 1024;\n\ninterface EventSummary {\n name: string;\n size: number;\n mtimeMs: number;\n type: string | null;\n platform: string | null;\n conversationId: string | null;\n text: string | null;\n at: string | null;\n schedule: string | null;\n timezone: string | null;\n}\n\nfunction listAllEvents(workingDir: string): EventSummary[] {\n const dir = join(workingDir, \"events\");\n if (!existsSync(dir)) return [];\n let entries;\n try {\n entries = readdirSync(dir, { withFileTypes: true });\n } catch {\n return [];\n }\n return entries\n .filter((e) => e.isFile() && e.name.endsWith(\".json\"))\n .map((e): EventSummary | null => {\n const filePath = join(dir, e.name);\n let stats;\n try {\n stats = statSync(filePath);\n } catch {\n return null;\n }\n let parsed: unknown = null;\n try {\n parsed = JSON.parse(readFileSync(filePath, \"utf-8\"));\n } catch {\n // Keep entry; just omit parsed fields.\n }\n const meta = parsed && typeof parsed === \"object\" ? (parsed as Record<string, unknown>) : {};\n // events.ts accepts `channelId` as a legacy alias for `conversationId`.\n const conversationId =\n typeof meta.conversationId === \"string\"\n ? meta.conversationId\n : typeof meta.channelId === \"string\"\n ? meta.channelId\n : null;\n return {\n name: e.name,\n size: stats.size,\n mtimeMs: stats.mtimeMs,\n type: typeof meta.type === \"string\" ? meta.type : null,\n platform: typeof meta.platform === \"string\" ? meta.platform : null,\n conversationId,\n text: typeof meta.text === \"string\" ? meta.text : null,\n at: typeof meta.at === \"string\" ? meta.at : null,\n schedule: typeof meta.schedule === \"string\" ? meta.schedule : null,\n timezone: typeof meta.timezone === \"string\" ? meta.timezone : null,\n };\n })\n .filter((e): e is EventSummary => e !== null)\n .toSorted((a, b) => a.name.localeCompare(b.name));\n}\n\nfunction serveEventsList(res: ServerResponse, services: AdminServices): void {\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n jsonRes(res, 200, { events: listAllEvents(workingDir) });\n}\n\n/** Per-conversation listing — filter all events by conversationId match. */\nfunction serveConversationEventsList(\n res: ServerResponse,\n url: URL,\n services: AdminServices,\n token: AdminToken,\n): void {\n const scope = resolveConversationFromQuery(url, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const events = listAllEvents(workingDir).filter((e) => e.conversationId === scope.conversationId);\n jsonRes(res, 200, { conversationId: scope.conversationId, events });\n}\n\nfunction serveEventsFile(res: ServerResponse, url: URL, services: AdminServices): void {\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const name = (url.searchParams.get(\"name\") ?? \"\").trim();\n if (!name || name.includes(\"/\") || name.includes(\"\\\\\") || name.includes(\"..\")) {\n jsonRes(res, 400, { error: \"Invalid name\" });\n return;\n }\n const filePath = join(workingDir, \"events\", name);\n let stats;\n try {\n stats = statSync(filePath);\n } catch {\n jsonRes(res, 404, { error: \"Not found\" });\n return;\n }\n if (!stats.isFile()) {\n jsonRes(res, 400, { error: \"Not a file\" });\n return;\n }\n if (stats.size > EVENTS_FILE_MAX_BYTES) {\n jsonRes(res, 413, { error: \"File too large\" });\n return;\n }\n let raw: string;\n try {\n raw = readFileSync(filePath, \"utf-8\");\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n return;\n }\n jsonRes(res, 200, { name, content: raw });\n}\n\n/** Delete a single event file scoped to the caller's conversation. */\nfunction serveConversationEventDelete(\n res: ServerResponse,\n body: Record<string, unknown>,\n services: AdminServices,\n token: AdminToken,\n): void {\n const scope = resolveTargetConversation(body, token);\n if (scope.error) {\n jsonRes(res, 403, { error: scope.error });\n return;\n }\n const name = typeof body.name === \"string\" ? body.name.trim() : \"\";\n if (!name || name.includes(\"/\") || name.includes(\"\\\\\") || name.includes(\"..\")) {\n jsonRes(res, 400, { error: \"Invalid name\" });\n return;\n }\n const workingDir = requireAdminWorkingDir(res, services);\n if (!workingDir) return;\n const filePath = join(workingDir, \"events\", name);\n let raw: string;\n try {\n raw = readFileSync(filePath, \"utf-8\");\n } catch {\n jsonRes(res, 404, { error: \"Event not found\" });\n return;\n }\n let parsed: Record<string, unknown> = {};\n try {\n const j = JSON.parse(raw);\n if (j && typeof j === \"object\") parsed = j as Record<string, unknown>;\n } catch {\n // Malformed events cannot be associated with a conversation below.\n }\n const eventConvId =\n typeof parsed.conversationId === \"string\"\n ? parsed.conversationId\n : typeof parsed.channelId === \"string\"\n ? parsed.channelId\n : null;\n if (eventConvId !== scope.conversationId) {\n jsonRes(res, 403, { error: \"Event does not belong to this conversation.\" });\n return;\n }\n try {\n rmSync(filePath, { force: true });\n jsonRes(res, 200, { ok: true });\n } catch (err) {\n jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });\n }\n}\n\n// ── Utilities ──────────────────────────────────────────────────────────────────\n\nfunction jsonRes(res: ServerResponse, status: number, body: unknown): void {\n res.writeHead(status, {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n });\n res.end(JSON.stringify(body));\n}\n\nasync function readJsonBody(\n req: IncomingMessage,\n res: ServerResponse,\n callback: (body: Record<string, unknown>) => void,\n): Promise<void> {\n const data = await readRawBody(req, res, 32 * 1024);\n if (data === null) return;\n\n let parsed: Record<string, unknown>;\n try {\n parsed = JSON.parse(data) as Record<string, unknown>;\n } catch {\n jsonRes(res, 400, { error: \"Invalid JSON\" });\n return;\n }\n\n callback(parsed);\n}\n\nconst esc = escapeHtml;\n\n// ── HTML ───────────────────────────────────────────────────────────────────────\n\nfunction renderAdminPage(token: AdminToken): string {\n const userLabel = token.platformUserName ?? token.platformUserId;\n const body = `<nav class=\"tab-nav\" role=\"tablist\" aria-label=\"Admin sections\">\n <button class=\"tab-btn active\" role=\"tab\" aria-selected=\"true\" aria-controls=\"panel-conversation\" data-tab=\"conversation\">Conversation</button>\n <button class=\"tab-btn\" role=\"tab\" aria-selected=\"false\" aria-controls=\"panel-global\" data-tab=\"global\">Global</button>\n </nav>\n\n <div class=\"tab-panel active\" id=\"panel-conversation\">\n <section class=\"card sect\" id=\"sect-settings\" data-section=\"settings\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Settings</p>\n <h2 class=\"card-title\">模型 / Thinking / Auto-reply / Workspace mount</h2>\n </div>\n <button class=\"refresh-btn\" onclick=\"loadSettings()\">↻</button>\n </header>\n <div id=\"settings-content\"><div class=\"loading-msg\">Loading…</div></div>\n </section>\n\n <section class=\"card sect\" id=\"sect-workspace\" data-section=\"workspace\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Workspace</p>\n <h2 class=\"card-title\">檔案瀏覽 (只讀)</h2>\n </div>\n <button class=\"refresh-btn\" onclick=\"loadWorkspace()\">↻</button>\n </header>\n <div class=\"workspace-split\">\n <div id=\"workspace-tree\" class=\"workspace-tree\"><div class=\"loading-msg\">Loading…</div></div>\n <div id=\"workspace-preview\" class=\"workspace-preview\"><div class=\"placeholder-msg\">Click a file to preview</div></div>\n </div>\n </section>\n\n <section class=\"card sect\" id=\"sect-skills\" data-section=\"skills\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Skills</p>\n <h2 class=\"card-title\">可用的 skills</h2>\n </div>\n <button class=\"refresh-btn\" onclick=\"loadSkills()\">↻</button>\n </header>\n <div class=\"workspace-split\">\n <div id=\"skills-content\" class=\"workspace-tree\"><div class=\"loading-msg\">Loading…</div></div>\n <div id=\"skills-preview\" class=\"workspace-preview\"><div class=\"placeholder-msg\">Click a skill to preview SKILL.md</div></div>\n </div>\n </section>\n\n <section class=\"card sect\" id=\"sect-vault\" data-section=\"vault\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Vault</p>\n <h2 class=\"card-title\">該對話的憑證</h2>\n </div>\n <button class=\"primary-action-btn\" onclick=\"openLogin()\">Open login form</button>\n </header>\n <div id=\"vault-link-result\" class=\"link-result\" style=\"display:none\"></div>\n <iframe id=\"login-frame\" class=\"portal-frame\" title=\"Login\" style=\"display:none\"></iframe>\n </section>\n\n <section class=\"card sect\" id=\"sect-events\" data-section=\"events\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Events</p>\n <h2 class=\"card-title\">關聯此對話的 events</h2>\n </div>\n <button class=\"refresh-btn\" onclick=\"loadConversationEvents()\">↻</button>\n </header>\n <div id=\"events-content\"><div class=\"loading-msg\">Loading…</div></div>\n </section>\n\n <section class=\"card sect\" id=\"sect-session\" data-section=\"session\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Session View</p>\n <h2 class=\"card-title\">對話歷史檢視</h2>\n </div>\n <button class=\"primary-action-btn\" onclick=\"openSessionView()\">Open session view</button>\n </header>\n <div id=\"session-link-result\" class=\"link-result\" style=\"display:none\"></div>\n <iframe id=\"session-frame\" class=\"portal-frame\" title=\"Session View\" style=\"display:none\"></iframe>\n </section>\n </div>\n\n <div class=\"tab-panel\" id=\"panel-global\">\n <section class=\"card sect\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">All Conversations</p>\n <h2 class=\"card-title\">所有對話</h2>\n </div>\n <button class=\"refresh-btn\" onclick=\"loadAllConversations()\">↻</button>\n </header>\n <div id=\"all-conv-content\"><div class=\"loading-msg\">Loading…</div></div>\n </section>\n\n <section class=\"card sect\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Global Settings</p>\n <h2 class=\"card-title\">全域預設</h2>\n </div>\n <button class=\"refresh-btn\" onclick=\"loadGlobalSettings()\">↻</button>\n </header>\n <div id=\"global-settings-content\"><div class=\"loading-msg\">Loading…</div></div>\n </section>\n\n <section class=\"card sect\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Global Skills</p>\n <h2 class=\"card-title\">全域 skills</h2>\n </div>\n <button class=\"refresh-btn\" onclick=\"loadGlobalSkills()\">↻</button>\n </header>\n <div id=\"global-skills-content\"><div class=\"loading-msg\">Loading…</div></div>\n </section>\n\n <section class=\"card sect\">\n <header class=\"sect-head\">\n <div>\n <p class=\"eyebrow\">Global Events</p>\n <h2 class=\"card-title\">全域 events.json</h2>\n </div>\n <button class=\"refresh-btn\" onclick=\"loadEvents()\">↻</button>\n </header>\n <div id=\"global-events-content\"><div class=\"loading-msg\">Loading…</div></div>\n </section>\n </div>`;\n\n const script = `\n const adminToken = ${JSON.stringify(token.token)};\n const defaultConversationId = ${JSON.stringify(token.conversationId)};\n let activeConversationId = defaultConversationId;\n let availableModels = [];\n let modelsLoaded = false;\n\n // ── Helpers ──────────────────────────────────────────────────────────────────\n\n function escHtml(str) {\n return String(str).replace(/[&<>\"']/g, (c) => (\n {'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]\n ));\n }\n function escAttr(str) {\n return String(str).replace(/[\"'&<>]/g, (c) => (\n {'\"':'"',\"'\":''','&':'&','<':'<','>':'>'}[c]\n ));\n }\n async function copyToClipboard(text) {\n try { await navigator.clipboard.writeText(text); } catch { prompt('Copy this link:', text); }\n }\n async function apiGet(path) {\n const url = path + (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(adminToken);\n const r = await fetch(url);\n const data = await r.json();\n if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));\n return data;\n }\n async function apiPost(path, body) {\n const r = await fetch(path, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ token: adminToken, ...body }),\n });\n const data = await r.json();\n if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));\n return data;\n }\n async function loadModels() {\n try {\n const data = await apiGet('/admin/api/models');\n availableModels = Array.isArray(data.models) ? data.models : [];\n } catch (err) {\n availableModels = [];\n } finally {\n modelsLoaded = true;\n }\n }\n function modelRef(provider, model) {\n return provider && model ? provider + '/' + model : '';\n }\n function parseModelRef(value) {\n const slash = value.indexOf('/');\n if (slash <= 0 || slash === value.length - 1) return { provider: '', model: '' };\n return { provider: value.slice(0, slash), model: value.slice(slash + 1) };\n }\n function renderModelOptions(currentProvider, currentModel) {\n const current = modelRef(currentProvider, currentModel);\n const seen = new Set();\n const options = [];\n if (current) {\n seen.add(current);\n options.push('<option value=\"' + escAttr(current) + '\">' + escHtml(current + ' (current)') + '</option>');\n }\n for (const model of availableModels) {\n const ref = modelRef(model.provider, model.id);\n if (!ref || seen.has(ref)) continue;\n seen.add(ref);\n const details = [model.name && model.name !== model.id ? model.name : '', model.reasoning ? 'thinking' : '', Array.isArray(model.input) && model.input.includes('image') ? 'image' : '']\n .filter(Boolean)\n .join(' · ');\n options.push('<option value=\"' + escAttr(ref) + '\">' + escHtml(details ? ref + ' — ' + details : ref) + '</option>');\n }\n if (options.length === 0) {\n return '<option value=\"\">No available models</option>';\n }\n return options.join('');\n }\n\n // ── Tab switching ────────────────────────────────────────────────────────────\n\n const tabBtns = document.querySelectorAll('.tab-btn');\n const tabPanels = document.querySelectorAll('.tab-panel');\n\n function switchTab(tabId) {\n tabBtns.forEach((btn) => {\n const active = btn.dataset.tab === tabId;\n btn.classList.toggle('active', active);\n btn.setAttribute('aria-selected', active ? 'true' : 'false');\n });\n tabPanels.forEach((panel) => panel.classList.toggle('active', panel.id === 'panel-' + tabId));\n if (tabId === 'global') initGlobal();\n }\n tabBtns.forEach((btn) => btn.addEventListener('click', () => switchTab(btn.dataset.tab)));\n\n // ── Conversation switcher ───────────────────────────────────────────────────\n\n async function initConvSwitcher() {\n const sel = document.getElementById('conv-switcher');\n try {\n const data = await apiGet('/admin/api/conversations');\n sel.innerHTML = data.conversations.map((c) => {\n const label = (c.label || c.conversationId) + (c.running ? ' (running)' : '');\n const selected = c.conversationId === defaultConversationId ? ' selected' : '';\n return '<option value=\"' + escAttr(c.conversationId) + '\"' + selected + '>' + escHtml(label) + '</option>';\n }).join('');\n sel.addEventListener('change', () => setActiveConversation(sel.value));\n } catch (err) {\n // ignore; conversation selector stays empty\n }\n }\n\n function setActiveConversation(id) {\n activeConversationId = id;\n const sel = document.getElementById('conv-switcher');\n if (sel && sel.value !== id) sel.value = id;\n // Reset all conversation sections.\n loadSettings();\n loadWorkspace();\n loadSkills();\n loadConversationEvents();\n openLogin(true);\n openSessionView(true);\n }\n\n // ── Settings ─────────────────────────────────────────────────────────────────\n\n async function loadSettings() {\n const container = document.getElementById('settings-content');\n container.innerHTML = '<div class=\"loading-msg\">Loading…</div>';\n if (!modelsLoaded) await loadModels();\n try {\n const data = await apiGet('/admin/api/conversation-state?conversationId=' + encodeURIComponent(activeConversationId));\n container.innerHTML = renderSettings(data);\n } catch (err) {\n container.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n function renderSettings(data) {\n const thinking = ['off','minimal','low','medium','high','xhigh'];\n const thinkingOpts = thinking.map((t) =>\n '<option value=\"' + t + '\"' + (data.thinkingLevel === t ? ' selected' : '') + '>' + t + '</option>'\n ).join('');\n const mounts = ['private','full'];\n const mountOpts = mounts.map((m) =>\n '<option value=\"' + m + '\"' + (data.sandboxImageWorkspaceMount === m ? ' selected' : '') + '>' + m + '</option>'\n ).join('');\n const rulesText = (data.autoReplyRules || []).join('\\\\n');\n const replyModes = ['top-level','thread'];\n const replyModeOpts = replyModes.map((m) =>\n '<option value=\"' + m + '\"' + (((data.slack && data.slack.replyMode) || 'top-level') === m ? ' selected' : '') + '>' + m + '</option>'\n ).join('');\n const globalReplyMode = (data.slack && data.slack.globalReplyMode) || 'top-level';\n const globalModel = [data.globalProvider, data.globalModel].filter(Boolean).join('/');\n const globalModelLabel = globalModel + (data.globalThinkingLevel ? ':' + data.globalThinkingLevel : '');\n const globalMount = data.globalSandboxImageWorkspaceMount || 'private';\n return [\n '<div class=\"config-grid\">',\n '<div class=\"config-block\">',\n '<h3 class=\"card-subtitle\">Model</h3>',\n '<div class=\"config-row config-row-stack\"><label>Model</label><select id=\"m-model-ref\">' + renderModelOptions(data.provider, data.model) + '</select></div>',\n '<div class=\"config-row\"><label>Thinking</label><select id=\"m-thinking\">' + thinkingOpts + '</select></div>',\n '<p class=\"muted-note\">Global default: ' + escHtml(globalModelLabel) + '</p>',\n '<button class=\"primary-action-btn\" onclick=\"saveModel(this)\">Save model</button>',\n '<div id=\"model-save-result\" class=\"inline-result\" style=\"display:none\"></div>',\n '</div>',\n '<div class=\"config-block\">',\n '<h3 class=\"card-subtitle\">Auto-reply</h3>',\n '<div class=\"config-row\"><label>Enabled</label><label class=\"toggle\"><input type=\"checkbox\" id=\"a-enabled\"' + (data.autoReplyEnabled ? ' checked' : '') + '> on</label></div>',\n '<div class=\"config-row config-row-stack\"><label>Rules</label><textarea id=\"a-rules\" rows=\"5\" placeholder=\"一行一條規則\">' + escHtml(rulesText) + '</textarea></div>',\n '<button class=\"primary-action-btn\" onclick=\"saveAutoReply(this)\">Save auto-reply</button>',\n '<div id=\"auto-save-result\" class=\"inline-result\" style=\"display:none\"></div>',\n '</div>',\n '<div class=\"config-block\">',\n '<h3 class=\"card-subtitle\">Workspace mount</h3>',\n '<div class=\"config-row\"><label>Mode</label><select id=\"m-mount\">' + mountOpts + '</select></div>',\n '<p class=\"muted-note\">Global default: ' + escHtml(globalMount) + '</p>',\n '<button class=\"primary-action-btn\" onclick=\"saveMount(this)\">Save mount</button>',\n '<div id=\"mount-save-result\" class=\"inline-result\" style=\"display:none\"></div>',\n '</div>',\n '<div class=\"config-block\">',\n '<h3 class=\"card-subtitle\">Slack</h3>',\n '<div class=\"config-row\"><label>Reply mode</label><select id=\"m-slack-reply-mode\">' + replyModeOpts + '</select></div>',\n '<p class=\"muted-note\">Global default: ' + escHtml(globalReplyMode) + '</p>',\n '<button class=\"primary-action-btn\" onclick=\"saveSlack(this)\">Save Slack</button>',\n '<div id=\"slack-save-result\" class=\"inline-result\" style=\"display:none\"></div>',\n '</div>',\n '</div>',\n ].join('');\n }\n\n async function saveModel(btn) {\n const selectedModel = parseModelRef(document.getElementById('m-model-ref').value.trim());\n const provider = selectedModel.provider;\n const model = selectedModel.model;\n const thinkingLevel = document.getElementById('m-thinking').value;\n const result = document.getElementById('model-save-result');\n if (!provider || !model) {\n result.style.display = 'block'; result.className = 'inline-result err';\n result.textContent = 'Provider and model are required';\n return;\n }\n btn.disabled = true; btn.textContent = 'Saving…'; result.style.display = 'none';\n try {\n const data = await apiPost('/admin/api/conversations/model', {\n conversationId: activeConversationId, provider, model, thinkingLevel,\n });\n result.style.display = 'block'; result.className = 'inline-result ok';\n result.textContent = data.runtimeSwitched === false\n ? 'Saved — running session pinned; new model applies on next start.'\n : 'Saved ✓';\n } catch (err) {\n result.style.display = 'block'; result.className = 'inline-result err';\n result.textContent = err.message;\n } finally {\n btn.disabled = false; btn.textContent = 'Save model';\n }\n }\n\n async function saveAutoReply(btn) {\n const enabled = document.getElementById('a-enabled').checked;\n const rules = document.getElementById('a-rules').value.split('\\\\n').map((s) => s.trim()).filter(Boolean);\n const result = document.getElementById('auto-save-result');\n btn.disabled = true; btn.textContent = 'Saving…'; result.style.display = 'none';\n try {\n await apiPost('/admin/api/conversations/auto-reply', {\n conversationId: activeConversationId, enabled, rules,\n });\n result.style.display = 'block'; result.className = 'inline-result ok'; result.textContent = 'Saved ✓';\n } catch (err) {\n result.style.display = 'block'; result.className = 'inline-result err'; result.textContent = err.message;\n } finally {\n btn.disabled = false; btn.textContent = 'Save auto-reply';\n }\n }\n\n async function saveMount(btn) {\n const workspaceMount = document.getElementById('m-mount').value;\n const result = document.getElementById('mount-save-result');\n btn.disabled = true; btn.textContent = 'Saving…'; result.style.display = 'none';\n try {\n await apiPost('/admin/api/conversations/sandbox', {\n conversationId: activeConversationId, workspaceMount,\n });\n result.style.display = 'block'; result.className = 'inline-result ok'; result.textContent = 'Saved ✓';\n } catch (err) {\n result.style.display = 'block'; result.className = 'inline-result err'; result.textContent = err.message;\n } finally {\n btn.disabled = false; btn.textContent = 'Save mount';\n }\n }\n\n async function saveSlack(btn) {\n const replyMode = document.getElementById('m-slack-reply-mode').value;\n const result = document.getElementById('slack-save-result');\n btn.disabled = true; btn.textContent = 'Saving…'; result.style.display = 'none';\n try {\n await apiPost('/admin/api/conversations/slack', {\n conversationId: activeConversationId, replyMode,\n });\n result.style.display = 'block'; result.className = 'inline-result ok'; result.textContent = 'Saved ✓';\n } catch (err) {\n result.style.display = 'block'; result.className = 'inline-result err'; result.textContent = err.message;\n } finally {\n btn.disabled = false; btn.textContent = 'Save Slack';\n }\n }\n\n // ── Workspace ────────────────────────────────────────────────────────────────\n\n async function loadWorkspace() {\n const treeEl = document.getElementById('workspace-tree');\n const previewEl = document.getElementById('workspace-preview');\n treeEl.innerHTML = '<div class=\"loading-msg\">Loading…</div>';\n previewEl.innerHTML = '<div class=\"placeholder-msg\">Click a file to preview</div>';\n try {\n const data = await apiGet('/admin/api/workspace/tree?conversationId=' + encodeURIComponent(activeConversationId));\n if (!data.tree) {\n treeEl.innerHTML = '<div class=\"empty-state\">No files</div>';\n return;\n }\n treeEl.innerHTML = '<ul class=\"tree-root\">' + renderTreeChildren(data.tree) + '</ul>';\n } catch (err) {\n treeEl.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n function renderTreeChildren(node) {\n if (node.type === 'file') {\n return '<li><button class=\"tree-file\" onclick=\"previewFile(\\\\'' + escAttr(node.path) + '\\\\')\">' + escHtml(node.name) + '</button></li>';\n }\n if (!node.children || node.children.length === 0) {\n return '<li><span class=\"tree-dir empty\">' + escHtml(node.name || '.') + '/</span></li>';\n }\n const inner = node.children.map((c) =>\n c.type === 'file'\n ? '<li><button class=\"tree-file\" onclick=\"previewFile(\\\\'' + escAttr(c.path) + '\\\\')\">' + escHtml(c.name) + '</button></li>'\n : '<li><details open><summary class=\"tree-dir\">' + escHtml(c.name) + '/</summary><ul>' + renderTreeChildren(c) + '</ul></details></li>'\n ).join('');\n return inner;\n }\n\n function renderPreviewFileResult(previewEl, label, data) {\n if (data.binary) {\n previewEl.innerHTML = '<div class=\"preview-meta\">' + escHtml(label) + ' · ' + data.size + ' bytes · binary</div><div class=\"placeholder-msg\">Binary file — preview not available</div>';\n return;\n }\n previewEl.innerHTML =\n '<div class=\"preview-meta\">' + escHtml(label) + ' · ' + data.size + ' bytes</div>' +\n '<pre class=\"preview-body\">' + escHtml(data.content || '') + '</pre>';\n }\n\n async function previewFile(path) {\n const previewEl = document.getElementById('workspace-preview');\n previewEl.innerHTML = '<div class=\"loading-msg\">Loading ' + escHtml(path) + '…</div>';\n try {\n const data = await apiGet('/admin/api/workspace/file?conversationId=' + encodeURIComponent(activeConversationId) + '&path=' + encodeURIComponent(path));\n renderPreviewFileResult(previewEl, path, data);\n } catch (err) {\n previewEl.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n // ── Skills ───────────────────────────────────────────────────────────────────\n\n async function loadSkills() {\n const container = document.getElementById('skills-content');\n const previewEl = document.getElementById('skills-preview');\n container.innerHTML = '<div class=\"loading-msg\">Loading…</div>';\n if (previewEl) previewEl.innerHTML = '<div class=\"placeholder-msg\">Click a skill to preview SKILL.md</div>';\n try {\n const data = await apiGet('/admin/api/skills?conversationId=' + encodeURIComponent(activeConversationId));\n if (data.skills.length === 0) {\n container.innerHTML = '<div class=\"empty-state\">No skills available</div>';\n return;\n }\n container.innerHTML = '<div class=\"skills-list\">' +\n data.skills.map((s) =>\n '<button class=\"skill-row skill-row-btn\" data-skill-source=\"' + escAttr(s.source) + '\" data-skill-directory=\"' + escAttr(s.directory) + '\" data-skill-name=\"' + escAttr(s.name) + '\">' +\n '<div class=\"skill-name\">' + escHtml(s.name) + '<span class=\"skill-source skill-source-' + s.source + '\">' + s.source + '</span></div>' +\n (s.description ? '<div class=\"skill-desc\">' + escHtml(s.description) + '</div>' : '') +\n '</button>'\n ).join('') + '</div>';\n\n } catch (err) {\n container.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n async function previewSkill(source, directory, name) {\n const previewEl = document.getElementById('skills-preview');\n if (!source || !directory) {\n previewEl.innerHTML = '<div class=\"err-msg\">Missing skill source or directory</div>';\n return;\n }\n previewEl.innerHTML = '<div class=\"loading-msg\">Loading ' + escHtml(name || directory) + '…</div>';\n try {\n const data = await apiGet('/admin/api/skills/file?conversationId=' + encodeURIComponent(activeConversationId) + '&source=' + encodeURIComponent(source) + '&directory=' + encodeURIComponent(directory));\n renderPreviewFileResult(previewEl, source + '/' + directory + '/SKILL.md', data);\n } catch (err) {\n previewEl.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n document.getElementById('skills-content').addEventListener('click', (event) => {\n const btn = event.target.closest('[data-skill-source]');\n if (!btn) return;\n previewSkill(btn.dataset.skillSource, btn.dataset.skillDirectory, btn.dataset.skillName);\n });\n\n // ── Vault (Login link) ───────────────────────────────────────────────────────\n\n async function openLogin(silent) {\n const result = document.getElementById('vault-link-result');\n const frame = document.getElementById('login-frame');\n if (silent) { frame.removeAttribute('src'); frame.style.display = 'none'; result.style.display = 'none'; return; }\n result.style.display = 'block'; result.className = 'link-result loading'; result.textContent = 'Generating link…';\n try {\n const data = await apiPost('/admin/api/conversations/login-link', { conversationId: activeConversationId });\n result.className = 'link-result ok';\n result.innerHTML =\n '<span class=\"link-vault\">vault: <code>' + escHtml(data.vaultId) + '</code></span>' +\n '<a href=\"' + escAttr(data.url) + '\" target=\"_blank\" rel=\"noopener\">' + escHtml(data.url) + '</a>' +\n '<button class=\"copy-link-btn\" onclick=\"copyToClipboard(' + JSON.stringify(data.url) + ')\">Copy</button>';\n frame.src = data.url; frame.style.display = 'block';\n } catch (err) {\n result.className = 'link-result err'; result.textContent = err.message;\n frame.removeAttribute('src'); frame.style.display = 'none';\n }\n }\n\n // ── Session View ─────────────────────────────────────────────────────────────\n\n async function openSessionView(silent) {\n const result = document.getElementById('session-link-result');\n const frame = document.getElementById('session-frame');\n if (silent) { frame.removeAttribute('src'); frame.style.display = 'none'; result.style.display = 'none'; return; }\n result.style.display = 'block'; result.className = 'link-result loading'; result.textContent = 'Generating link…';\n try {\n const data = await apiPost('/admin/api/conversations/session-link', { conversationId: activeConversationId });\n result.className = 'link-result ok';\n result.innerHTML =\n '<a href=\"' + escAttr(data.url) + '\" target=\"_blank\" rel=\"noopener\">' + escHtml(data.url) + '</a>' +\n '<button class=\"copy-link-btn\" onclick=\"copyToClipboard(' + JSON.stringify(data.url) + ')\">Copy</button>';\n frame.src = data.url; frame.style.display = 'block';\n } catch (err) {\n result.className = 'link-result err'; result.textContent = err.message;\n frame.removeAttribute('src'); frame.style.display = 'none';\n }\n }\n\n // ── Events ───────────────────────────────────────────────────────────────────\n\n async function loadConversationEvents() {\n const container = document.getElementById('events-content');\n if (!container) return;\n container.innerHTML = '<div class=\"loading-msg\">Loading…</div>';\n try {\n const data = await apiGet('/admin/api/conversations/events?conversationId=' + encodeURIComponent(activeConversationId));\n if (data.events.length === 0) {\n container.innerHTML = '<div class=\"empty-state\">沒有關聯此對話的 event</div>';\n return;\n }\n container.innerHTML = '<div class=\"events-list\">' +\n data.events.map((e) => renderEventRow(e, true)).join('') + '</div>';\n } catch (err) {\n container.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n async function loadEvents() {\n const container = document.getElementById('global-events-content');\n if (!container) return;\n container.innerHTML = '<div class=\"loading-msg\">Loading…</div>';\n try {\n const data = await apiGet('/admin/api/events');\n if (data.events.length === 0) {\n container.innerHTML = '<div class=\"empty-state\">No events scheduled</div>';\n return;\n }\n container.innerHTML = '<div class=\"events-list\">' +\n data.events.map((e) => renderEventRow(e, false)).join('') + '</div>';\n } catch (err) {\n container.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n function renderEventRow(e, allowDelete) {\n const meta = [e.type, e.platform, e.conversationId, e.schedule || e.at]\n .filter(Boolean).map(escHtml).join(' · ');\n const preview = e.text ? '<div class=\"event-text\">' + escHtml(e.text.length > 240 ? e.text.slice(0, 237) + '…' : e.text) + '</div>' : '';\n const deleteBtn = allowDelete\n ? '<button class=\"event-delete-btn\" onclick=\"deleteEvent(\\\\'' + escAttr(e.name) + '\\\\', this)\">Delete</button>'\n : '';\n return '<div class=\"event-row\">' +\n '<div class=\"event-row-top\">' +\n '<div class=\"event-name\"><code>' + escHtml(e.name) + '</code></div>' +\n deleteBtn +\n '</div>' +\n '<div class=\"event-meta\">' + meta + '</div>' +\n preview +\n '</div>';\n }\n\n async function deleteEvent(name, btn) {\n if (!confirm('Delete event \"' + name + '\"?')) return;\n btn.disabled = true; btn.textContent = 'Deleting…';\n try {\n await apiPost('/admin/api/conversations/events/delete', {\n conversationId: activeConversationId, name,\n });\n await loadConversationEvents();\n } catch (err) {\n btn.disabled = false; btn.textContent = 'Delete';\n alert(err.message);\n }\n }\n\n // ── Global section ──────────────────────────────────────────────────────────\n\n let globalLoaded = false;\n function initGlobal() {\n if (globalLoaded) return;\n globalLoaded = true;\n loadAllConversations();\n loadGlobalSettings();\n loadGlobalSkills();\n loadEvents();\n }\n\n async function loadAllConversations() {\n const container = document.getElementById('all-conv-content');\n container.innerHTML = '<div class=\"loading-msg\">Loading…</div>';\n try {\n const data = await apiGet('/admin/api/conversations');\n if (data.conversations.length === 0) {\n container.innerHTML = '<div class=\"empty-state\">No conversations found</div>';\n return;\n }\n container.innerHTML = '<div class=\"conv-list\">' + data.conversations.map((c) => {\n const last = c.lastActivityAt ? new Date(c.lastActivityAt).toLocaleString() : '—';\n return '<button class=\"conv-row-btn\" onclick=\"setActiveConversation(\\\\'' + escAttr(c.conversationId) + '\\\\'); switchTab(\\\\'conversation\\\\');\">' +\n '<span class=\"conv-id\">' + escHtml(c.label || c.conversationId) + '</span>' +\n (c.running ? '<span class=\"status-pill running\">running</span>' : '') +\n '<span class=\"conv-last\">' + escHtml(last) + '</span>' +\n '</button>';\n }).join('') + '</div>';\n } catch (err) {\n container.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n async function loadGlobalSettings() {\n const container = document.getElementById('global-settings-content');\n container.innerHTML = '<div class=\"loading-msg\">Loading…</div>';\n if (!modelsLoaded) await loadModels();\n try {\n const data = await apiGet('/admin/api/settings/global');\n container.innerHTML = renderGlobalSettings(data);\n } catch (err) {\n container.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n function renderGlobalSettings(data) {\n const thinking = ['off','minimal','low','medium','high','xhigh'];\n const thinkingOpts = thinking.map((t) =>\n '<option value=\"' + t + '\"' + (data.thinkingLevel === t ? ' selected' : '') + '>' + t + '</option>'\n ).join('');\n const mounts = ['private','full'];\n const mountOpts = mounts.map((m) =>\n '<option value=\"' + m + '\"' + (data.sandboxImageWorkspaceMount === m ? ' selected' : '') + '>' + m + '</option>'\n ).join('');\n const replyModes = ['top-level','thread'];\n const replyModeOpts = replyModes.map((m) =>\n '<option value=\"' + m + '\"' + (((data.slack && data.slack.replyMode) || 'top-level') === m ? ' selected' : '') + '>' + m + '</option>'\n ).join('');\n return [\n '<div class=\"config-grid\">',\n '<div class=\"config-block\">',\n '<h3 class=\"card-subtitle\">Default model</h3>',\n '<div class=\"config-row config-row-stack\"><label>Model</label><select id=\"g-model-ref\">' + renderModelOptions(data.provider, data.model) + '</select></div>',\n '<div class=\"config-row\"><label>Thinking</label><select id=\"g-thinking\">' + thinkingOpts + '</select></div>',\n '<button class=\"primary-action-btn\" onclick=\"saveGlobalModel(this)\">Save model</button>',\n '<div id=\"g-model-result\" class=\"inline-result\" style=\"display:none\"></div>',\n '</div>',\n '<div class=\"config-block\">',\n '<h3 class=\"card-subtitle\">Sandbox limits</h3>',\n '<div class=\"config-row\"><label>CPUs</label><input id=\"g-cpus\" placeholder=\"0.5\" value=\"' + escAttr(data.sandboxCpus || '') + '\"></div>',\n '<div class=\"config-row\"><label>Memory</label><input id=\"g-mem\" placeholder=\"1g\" value=\"' + escAttr(data.sandboxMemory || '') + '\"></div>',\n '<div class=\"config-row\"><label>Boost CPUs</label><input id=\"g-bcpus\" placeholder=\"2\" value=\"' + escAttr(data.sandboxBoostCpus || '') + '\"></div>',\n '<div class=\"config-row\"><label>Boost Mem</label><input id=\"g-bmem\" placeholder=\"4g\" value=\"' + escAttr(data.sandboxBoostMemory || '') + '\"></div>',\n '<div class=\"config-row\"><label>Mount</label><select id=\"g-mount\">' + mountOpts + '</select></div>',\n '<button class=\"primary-action-btn\" onclick=\"saveGlobalSandbox(this)\">Save sandbox</button>',\n '<div id=\"g-sandbox-result\" class=\"inline-result\" style=\"display:none\"></div>',\n '</div>',\n '<div class=\"config-block\">',\n '<h3 class=\"card-subtitle\">Slack</h3>',\n '<div class=\"config-row\"><label>Reply mode</label><select id=\"g-slack-reply-mode\">' + replyModeOpts + '</select></div>',\n '<button class=\"primary-action-btn\" onclick=\"saveGlobalSlack(this)\">Save Slack</button>',\n '<div id=\"g-slack-result\" class=\"inline-result\" style=\"display:none\"></div>',\n '</div>',\n '</div>',\n ].join('');\n }\n\n async function saveGlobalModel(btn) {\n const selectedModel = parseModelRef(document.getElementById('g-model-ref').value.trim());\n const provider = selectedModel.provider;\n const model = selectedModel.model;\n const thinkingLevel = document.getElementById('g-thinking').value;\n const result = document.getElementById('g-model-result');\n if (!provider || !model) {\n result.style.display = 'block'; result.className = 'inline-result err';\n result.textContent = 'Provider and model are required'; return;\n }\n btn.disabled = true; btn.textContent = 'Saving…'; result.style.display = 'none';\n try {\n await apiPost('/admin/api/settings/model', { provider, model, thinkingLevel });\n result.style.display = 'block'; result.className = 'inline-result ok'; result.textContent = 'Saved ✓';\n } catch (err) {\n result.style.display = 'block'; result.className = 'inline-result err'; result.textContent = err.message;\n } finally {\n btn.disabled = false; btn.textContent = 'Save model';\n }\n }\n\n async function saveGlobalSandbox(btn) {\n const cpus = document.getElementById('g-cpus').value.trim();\n const memory = document.getElementById('g-mem').value.trim();\n const boostCpus = document.getElementById('g-bcpus').value.trim();\n const boostMemory = document.getElementById('g-bmem').value.trim();\n const workspaceMount = document.getElementById('g-mount').value;\n const result = document.getElementById('g-sandbox-result');\n btn.disabled = true; btn.textContent = 'Saving…'; result.style.display = 'none';\n try {\n await apiPost('/admin/api/settings/sandbox', { cpus, memory, boostCpus, boostMemory, workspaceMount });\n result.style.display = 'block'; result.className = 'inline-result ok'; result.textContent = 'Saved ✓';\n } catch (err) {\n result.style.display = 'block'; result.className = 'inline-result err'; result.textContent = err.message;\n } finally {\n btn.disabled = false; btn.textContent = 'Save sandbox';\n }\n }\n\n async function saveGlobalSlack(btn) {\n const replyMode = document.getElementById('g-slack-reply-mode').value;\n const result = document.getElementById('g-slack-result');\n btn.disabled = true; btn.textContent = 'Saving…'; result.style.display = 'none';\n try {\n await apiPost('/admin/api/settings/slack', { replyMode });\n result.style.display = 'block'; result.className = 'inline-result ok'; result.textContent = 'Saved ✓';\n } catch (err) {\n result.style.display = 'block'; result.className = 'inline-result err'; result.textContent = err.message;\n } finally {\n btn.disabled = false; btn.textContent = 'Save Slack';\n }\n }\n\n async function loadGlobalSkills() {\n const container = document.getElementById('global-skills-content');\n container.innerHTML = '<div class=\"loading-msg\">Loading…</div>';\n try {\n // Reuse skills endpoint scoped to a conversation that doesn't have any of its own; the global half is what we want.\n const data = await apiGet('/admin/api/skills?conversationId=' + encodeURIComponent(activeConversationId));\n const globals = data.skills.filter((s) => s.source === 'global');\n if (globals.length === 0) {\n container.innerHTML = '<div class=\"empty-state\">No global skills</div>';\n return;\n }\n container.innerHTML = '<div class=\"skills-list\">' + globals.map((s) =>\n '<div class=\"skill-row\"><div class=\"skill-name\">' + escHtml(s.name) + '</div>' +\n (s.description ? '<div class=\"skill-desc\">' + escHtml(s.description) + '</div>' : '') + '</div>'\n ).join('') + '</div>';\n } catch (err) {\n container.innerHTML = '<div class=\"err-msg\">' + escHtml(err.message) + '</div>';\n }\n }\n\n // ── Init ─────────────────────────────────────────────────────────────────────\n\n initConvSwitcher();\n loadModels().finally(() => {\n loadSettings();\n loadWorkspace();\n loadSkills();\n loadConversationEvents();\n });\n `;\n\n return renderPortalShell({\n activeView: \"admin\",\n pageTitle: \"Admin\",\n identity: { primary: token.platform, secondary: userLabel },\n conversationSwitcher: { currentId: token.conversationId },\n body,\n extraStyles: adminViewStyles,\n inlineScript: script,\n });\n}\n\nfunction renderAdminErrorPage(message: string): string {\n return renderPortalShell({\n activeView: \"admin\",\n pageTitle: \"Admin\",\n body: `<section class=\"card\" style=\"text-align:center;padding:40px 32px\">\n <p class=\"eyebrow\">${PRODUCT_NAME} admin</p>\n <h1 class=\"page-title\" style=\"margin:12px 0 16px\">Access Denied</h1>\n <div class=\"err-msg\">${esc(message)}</div>\n </section>`,\n });\n}\n\n// ── Styles ─────────────────────────────────────────────────────────────────────\n\nconst adminViewStyles = `\n .tab-nav {\n display: flex; gap: 6px; padding: 6px;\n border: 1px solid var(--border); border-radius: 16px;\n background: rgba(255,255,255,0.72); backdrop-filter: blur(8px);\n overflow-x: auto; scrollbar-width: none;\n }\n .tab-nav::-webkit-scrollbar { display: none; }\n .tab-btn {\n flex: 1; min-width: 80px; padding: 10px 16px;\n border: none; border-radius: 10px; background: transparent;\n color: var(--muted);\n font: 500 0.88rem/1.2 'DM Sans', sans-serif;\n cursor: pointer; white-space: nowrap;\n transition: background 140ms, color 140ms;\n }\n .tab-btn:hover { background: rgba(0,0,0,0.04); color: var(--text); }\n .tab-btn.active { background: var(--text); color: #fafafa; font-weight: 600; }\n .tab-btn:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }\n\n .tab-panel { display: none; flex-direction: column; gap: 14px; }\n .tab-panel.active { display: flex; }\n\n .card-desc { color: var(--muted); font-size: 0.9rem; line-height: 1.55; margin-bottom: 12px; }\n\n .link-result {\n margin-top: 12px; padding: 10px 14px; border-radius: 10px;\n display: flex; gap: 10px; align-items: center; flex-wrap: wrap;\n font-size: 0.84rem;\n }\n .link-result.ok { background: var(--ok-bg); border: 1px solid var(--ok-border); }\n .link-result.err { background: var(--err-bg); border: 1px solid var(--err-border); color: var(--err-text); }\n .link-result.loading { background: rgba(0,0,0,0.025); border: 1px solid var(--border); color: var(--muted); }\n .link-result a {\n color: var(--ok-text);\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n font-size: 0.78rem; word-break: break-all; flex: 1; min-width: 0;\n }\n .link-vault { color: var(--muted); font-size: 0.78rem; flex-shrink: 0; }\n .copy-link-btn {\n padding: 5px 12px; border: 1px solid var(--ok-border); border-radius: 7px;\n background: rgba(255,255,255,0.7); color: var(--ok-text);\n font: 500 0.78rem/1.2 'DM Sans', sans-serif;\n cursor: pointer; flex-shrink: 0;\n }\n\n .portal-frame {\n width: 100%; min-height: 720px;\n border: 1px solid var(--border); border-radius: 14px; background: #fff;\n }\n\n .config-grid {\n display: grid; grid-template-columns: 1fr 1fr; gap: 18px;\n }\n .config-block { display: flex; flex-direction: column; gap: 10px; }\n .config-row { display: grid; grid-template-columns: 110px 1fr; gap: 10px; align-items: center; }\n .config-row.config-row-stack { grid-template-columns: 1fr; }\n .config-row label { font-size: 0.82rem; color: var(--muted); }\n .config-row input, .config-row select, .config-row textarea {\n padding: 7px 10px; border: 1px solid var(--border); border-radius: 8px;\n font-family: inherit; font-size: 0.84rem; width: 100%;\n }\n .config-row textarea {\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n resize: vertical;\n }\n .toggle { display: inline-flex; align-items: center; gap: 8px; font-size: 0.84rem; }\n\n .inline-result {\n padding: 8px 12px; border-radius: 8px; font-size: 0.82rem; margin-top: 4px;\n }\n .inline-result.ok { background: var(--ok-bg); color: var(--ok-text); border: 1px solid var(--ok-border); }\n .inline-result.err { background: var(--err-bg); color: var(--err-text); border: 1px solid var(--err-border); }\n\n /* ── Sections (Conversation page stack) ─────────────────────────────── */\n\n .sect-head {\n display: flex; align-items: flex-start; justify-content: space-between;\n gap: 12px; margin-bottom: 14px; flex-wrap: wrap;\n }\n .sect-head .card-title { margin-bottom: 0; }\n .sect-disabled { opacity: 0.7; }\n\n .refresh-btn {\n flex-shrink: 0; padding: 6px 12px;\n border: 1px solid var(--border); border-radius: 10px;\n background: rgba(0,0,0,0.025); color: var(--muted);\n font: 500 0.84rem/1.2 'DM Sans', sans-serif; cursor: pointer;\n }\n .refresh-btn:hover { background: rgba(0,0,0,0.06); color: var(--text); }\n\n /* ── Workspace ──────────────────────────────────────────────────────── */\n\n .workspace-split {\n display: grid; grid-template-columns: 260px 1fr; gap: 14px;\n min-height: 360px;\n }\n .workspace-tree {\n border: 1px solid var(--border); border-radius: 12px; padding: 10px;\n background: rgba(0,0,0,0.02); overflow: auto; max-height: 480px;\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n font-size: 0.78rem;\n }\n .workspace-tree ul { list-style: none; padding-left: 12px; margin: 0; }\n .workspace-tree .tree-root { padding-left: 0; }\n .workspace-tree details { margin: 1px 0; }\n .workspace-tree summary { cursor: pointer; padding: 2px 4px; border-radius: 4px; }\n .workspace-tree summary:hover { background: rgba(0,0,0,0.05); }\n .tree-dir { color: var(--text); font-weight: 600; }\n .tree-dir.empty { color: var(--subtle); font-weight: 400; }\n .tree-file {\n display: block; width: 100%; text-align: left;\n background: transparent; border: none; cursor: pointer;\n padding: 2px 4px; border-radius: 4px;\n font-family: inherit; font-size: inherit; color: var(--muted);\n }\n .tree-file:hover { background: rgba(0,0,0,0.05); color: var(--text); }\n\n .workspace-preview {\n border: 1px solid var(--border); border-radius: 12px;\n background: #fff; padding: 12px; overflow: auto; max-height: 480px;\n }\n .preview-meta {\n font-size: 0.74rem; color: var(--subtle);\n margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--border);\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n }\n .preview-body {\n margin: 0; white-space: pre-wrap; word-break: break-word;\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n font-size: 0.78rem; color: var(--text);\n }\n .placeholder-msg { color: var(--subtle); font-size: 0.86rem; padding: 24px 8px; text-align: center; }\n\n /* ── Skills ─────────────────────────────────────────────────────────── */\n\n .skills-list { display: flex; flex-direction: column; gap: 8px; }\n .skill-row {\n padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px;\n background: rgba(0,0,0,0.02);\n }\n .skill-row-btn {\n width: 100%; text-align: left; cursor: pointer; font-family: inherit;\n }\n .skill-row-btn:hover { background: rgba(0,0,0,0.05); }\n .skill-name {\n font-weight: 650; font-size: 0.9rem; color: var(--text);\n display: flex; align-items: center; gap: 8px; flex-wrap: wrap;\n }\n .skill-source {\n padding: 1px 8px; border-radius: 999px; font-size: 0.7rem;\n font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;\n }\n .skill-source-global { background: rgba(59,130,246,0.1); color: #1d4ed8; }\n .skill-source-conversation { background: rgba(217,119,6,0.1); color: var(--accent); }\n .skill-desc { color: var(--muted); font-size: 0.82rem; margin-top: 4px; line-height: 1.5; }\n\n /* ── Events ─────────────────────────────────────────────────────────── */\n\n .events-list { display: flex; flex-direction: column; gap: 8px; }\n .event-row {\n padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px;\n background: rgba(0,0,0,0.02);\n }\n .event-row-top {\n display: flex; align-items: center; justify-content: space-between;\n gap: 10px;\n }\n .event-name { min-width: 0; flex: 1; word-break: break-all; }\n .event-name code { font-size: 0.82rem; background: transparent; padding: 0; }\n .event-meta { font-size: 0.74rem; color: var(--muted); margin-top: 3px; }\n .event-text {\n font-size: 0.82rem; color: var(--text); margin-top: 6px;\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n white-space: pre-wrap; word-break: break-word;\n }\n .event-delete-btn {\n flex-shrink: 0; padding: 4px 10px;\n border-radius: 7px; border: 1px solid rgba(185, 28, 28, 0.18);\n background: rgba(0,0,0,0.03); color: var(--err-text);\n font: 500 0.76rem/1.2 'DM Sans', sans-serif; cursor: pointer;\n }\n .event-delete-btn:hover:not(:disabled) {\n background: var(--err-bg); border-color: rgba(185, 28, 28, 0.28);\n }\n .event-delete-btn:disabled { opacity: 0.5; cursor: wait; }\n\n /* ── All Conversations list ─────────────────────────────────────────── */\n\n .conv-list { display: flex; flex-direction: column; gap: 6px; }\n .conv-row-btn {\n display: flex; align-items: center; gap: 12px;\n padding: 10px 14px; border: 1px solid var(--border); border-radius: 10px;\n background: rgba(0,0,0,0.02); cursor: pointer; text-align: left;\n transition: background 120ms, border-color 120ms;\n }\n .conv-row-btn:hover { background: rgba(0,0,0,0.05); border-color: rgba(0,0,0,0.14); }\n .conv-id { flex: 1; font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 0.84rem; }\n .conv-last { color: var(--subtle); font-size: 0.78rem; }\n\n .status-pill {\n display: inline-flex; padding: 2px 9px; border-radius: 999px;\n font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;\n }\n .status-pill.running { background: var(--ok-bg); color: var(--ok-text); border: 1px solid var(--ok-border); }\n\n @media (max-width: 640px) {\n .tab-btn { padding: 9px 12px; font-size: 0.82rem; min-width: 60px; }\n .config-grid { grid-template-columns: 1fr; }\n .config-row { grid-template-columns: 1fr; gap: 4px; }\n .portal-frame { min-height: 520px; }\n .workspace-split { grid-template-columns: 1fr; }\n .workspace-tree, .workspace-preview { max-height: 260px; }\n }\n`;\n"]}
|
|
@@ -2,13 +2,14 @@ import { existsSync, readdirSync, readFileSync, rmSync, statSync } from "fs";
|
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { join, resolve as pathResolve, sep as pathSep } from "path";
|
|
4
4
|
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import {
|
|
6
|
-
import { escapeHtml } from "
|
|
7
|
-
import {
|
|
5
|
+
import { loadConversationAutoReplyConfig, loadGlobalSettings, resolveConversationSettings, saveConversationAutoReplyConfig, updateConversationSettings, updateGlobalSettings, } from "../../config.js";
|
|
6
|
+
import { escapeHtml } from "../../utils/html.js";
|
|
7
|
+
import { readRawBody } from "../../utils/http-body.js";
|
|
8
|
+
import { renderPortalShell } from "../../portal-shell.js";
|
|
8
9
|
import { resolveExistingSessionFile } from "../session-view/service.js";
|
|
9
|
-
import { PRODUCT_NAME } from "
|
|
10
|
-
import { resolveActorVaultKey } from "
|
|
11
|
-
import { sharedVaultKey } from "
|
|
10
|
+
import { PRODUCT_NAME } from "../../platform-messages.js";
|
|
11
|
+
import { resolveActorVaultKey } from "../../vault/routing.js";
|
|
12
|
+
import { sharedVaultKey } from "../../vault/index.js";
|
|
12
13
|
// ── Handler ────────────────────────────────────────────────────────────────────
|
|
13
14
|
export function handleAdminRequest(req, res, url, services) {
|
|
14
15
|
if (!url.pathname.startsWith("/admin"))
|
|
@@ -113,6 +114,10 @@ function routeApiRequest(req, res, url, services) {
|
|
|
113
114
|
serveConversationAutoReplyUpdate(res, body, services, token);
|
|
114
115
|
return;
|
|
115
116
|
}
|
|
117
|
+
if (url.pathname === "/admin/api/conversations/slack") {
|
|
118
|
+
serveConversationSlackUpdate(res, body, services, token);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
116
121
|
if (url.pathname === "/admin/api/conversations/session-link") {
|
|
117
122
|
serveConversationSessionLink(res, body, services, token);
|
|
118
123
|
return;
|
|
@@ -133,6 +138,10 @@ function routeApiRequest(req, res, url, services) {
|
|
|
133
138
|
serveGlobalSandboxUpdate(res, body);
|
|
134
139
|
return;
|
|
135
140
|
}
|
|
141
|
+
if (url.pathname === "/admin/api/settings/slack") {
|
|
142
|
+
serveGlobalSlackUpdate(res, body);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
136
145
|
jsonRes(res, 404, { error: "Not found" });
|
|
137
146
|
});
|
|
138
147
|
return;
|
|
@@ -140,8 +149,7 @@ function routeApiRequest(req, res, url, services) {
|
|
|
140
149
|
jsonRes(res, 405, { error: "Method not allowed" });
|
|
141
150
|
}
|
|
142
151
|
// ── Scope helpers ──────────────────────────────────────────────────────────────
|
|
143
|
-
function
|
|
144
|
-
const requested = typeof body.conversationId === "string" ? body.conversationId.trim() : "";
|
|
152
|
+
function resolveConversationId(requested, token) {
|
|
145
153
|
if (!requested)
|
|
146
154
|
return { conversationId: token.conversationId };
|
|
147
155
|
if (requested === token.conversationId)
|
|
@@ -151,6 +159,10 @@ function resolveTargetConversation(body, token) {
|
|
|
151
159
|
}
|
|
152
160
|
return { conversationId: requested };
|
|
153
161
|
}
|
|
162
|
+
function resolveTargetConversation(body, token) {
|
|
163
|
+
const requested = typeof body.conversationId === "string" ? body.conversationId.trim() : "";
|
|
164
|
+
return resolveConversationId(requested, token);
|
|
165
|
+
}
|
|
154
166
|
function requireAdminWorkingDir(res, services) {
|
|
155
167
|
if (!services.workingDir) {
|
|
156
168
|
jsonRes(res, 503, { error: "Working directory not available" });
|
|
@@ -259,27 +271,30 @@ function serveConversationState(res, url, services, token) {
|
|
|
259
271
|
return;
|
|
260
272
|
}
|
|
261
273
|
const dir = join(workingDir, conversationId);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
modelConfig = loadAgentConfigForConversation(dir);
|
|
265
|
-
}
|
|
266
|
-
catch {
|
|
267
|
-
modelConfig = null;
|
|
268
|
-
}
|
|
274
|
+
const globalConfig = loadGlobalSettings();
|
|
275
|
+
const conversationConfig = resolveConversationSettings(dir);
|
|
269
276
|
const autoReply = loadConversationAutoReplyConfig(dir);
|
|
270
277
|
jsonRes(res, 200, {
|
|
271
278
|
conversationId,
|
|
272
|
-
provider:
|
|
273
|
-
model:
|
|
274
|
-
thinkingLevel:
|
|
275
|
-
|
|
279
|
+
provider: conversationConfig.provider,
|
|
280
|
+
model: conversationConfig.model,
|
|
281
|
+
thinkingLevel: conversationConfig.thinkingLevel,
|
|
282
|
+
globalProvider: globalConfig.provider,
|
|
283
|
+
globalModel: globalConfig.model,
|
|
284
|
+
globalThinkingLevel: globalConfig.thinkingLevel,
|
|
285
|
+
sandboxImageWorkspaceMount: conversationConfig.sandboxImageWorkspaceMount ?? null,
|
|
286
|
+
globalSandboxImageWorkspaceMount: globalConfig.sandboxImageWorkspaceMount ?? null,
|
|
276
287
|
autoReplyEnabled: autoReply.enabled,
|
|
277
288
|
autoReplyRules: autoReply.rules,
|
|
289
|
+
slack: {
|
|
290
|
+
replyMode: conversationConfig.slack?.replyMode ?? globalConfig.slack?.replyMode ?? "top-level",
|
|
291
|
+
globalReplyMode: globalConfig.slack?.replyMode ?? "top-level",
|
|
292
|
+
},
|
|
278
293
|
});
|
|
279
294
|
}
|
|
280
295
|
function serveGlobalSettings(res) {
|
|
281
296
|
try {
|
|
282
|
-
const config =
|
|
297
|
+
const config = loadGlobalSettings();
|
|
283
298
|
jsonRes(res, 200, {
|
|
284
299
|
provider: config.provider,
|
|
285
300
|
model: config.model,
|
|
@@ -290,6 +305,9 @@ function serveGlobalSettings(res) {
|
|
|
290
305
|
sandboxBoostMemory: config.sandboxBoostMemory ?? null,
|
|
291
306
|
sandboxImageWorkspaceMount: config.sandboxImageWorkspaceMount ?? null,
|
|
292
307
|
defaultSharedVault: config.defaultSharedVault ?? null,
|
|
308
|
+
slack: {
|
|
309
|
+
replyMode: config.slack?.replyMode ?? "top-level",
|
|
310
|
+
},
|
|
293
311
|
});
|
|
294
312
|
}
|
|
295
313
|
catch (err) {
|
|
@@ -336,7 +354,7 @@ function serveConversationModelUpdate(res, body, services, token) {
|
|
|
336
354
|
return;
|
|
337
355
|
const dir = join(workingDir, scope.conversationId);
|
|
338
356
|
try {
|
|
339
|
-
|
|
357
|
+
updateConversationSettings(dir, {
|
|
340
358
|
provider,
|
|
341
359
|
model,
|
|
342
360
|
...(thinkingLevel ? { thinkingLevel } : {}),
|
|
@@ -367,7 +385,30 @@ function serveConversationSandboxUpdate(res, body, services, token) {
|
|
|
367
385
|
return;
|
|
368
386
|
const dir = join(workingDir, scope.conversationId);
|
|
369
387
|
try {
|
|
370
|
-
|
|
388
|
+
updateConversationSettings(dir, { sandboxImageWorkspaceMount: workspaceMount });
|
|
389
|
+
jsonRes(res, 200, { ok: true });
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function serveConversationSlackUpdate(res, body, services, token) {
|
|
396
|
+
const replyMode = body.replyMode;
|
|
397
|
+
if (replyMode !== "top-level" && replyMode !== "thread") {
|
|
398
|
+
jsonRes(res, 400, { error: "replyMode must be 'top-level' or 'thread'" });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const scope = resolveTargetConversation(body, token);
|
|
402
|
+
if (scope.error) {
|
|
403
|
+
jsonRes(res, 403, { error: scope.error });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const workingDir = requireAdminWorkingDir(res, services);
|
|
407
|
+
if (!workingDir)
|
|
408
|
+
return;
|
|
409
|
+
const dir = join(workingDir, scope.conversationId);
|
|
410
|
+
try {
|
|
411
|
+
updateConversationSettings(dir, { slack: { replyMode } });
|
|
371
412
|
jsonRes(res, 200, { ok: true });
|
|
372
413
|
}
|
|
373
414
|
catch (err) {
|
|
@@ -486,7 +527,7 @@ function serveGlobalModelUpdate(res, body) {
|
|
|
486
527
|
return;
|
|
487
528
|
}
|
|
488
529
|
try {
|
|
489
|
-
|
|
530
|
+
updateGlobalSettings({
|
|
490
531
|
provider,
|
|
491
532
|
model,
|
|
492
533
|
...(thinkingLevel ? { thinkingLevel } : {}),
|
|
@@ -497,6 +538,20 @@ function serveGlobalModelUpdate(res, body) {
|
|
|
497
538
|
jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
498
539
|
}
|
|
499
540
|
}
|
|
541
|
+
function serveGlobalSlackUpdate(res, body) {
|
|
542
|
+
const replyMode = body.replyMode;
|
|
543
|
+
if (replyMode !== "top-level" && replyMode !== "thread") {
|
|
544
|
+
jsonRes(res, 400, { error: "replyMode must be 'top-level' or 'thread'" });
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
updateGlobalSettings({ slack: { replyMode } });
|
|
549
|
+
jsonRes(res, 200, { ok: true });
|
|
550
|
+
}
|
|
551
|
+
catch (err) {
|
|
552
|
+
jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
500
555
|
function serveGlobalSandboxUpdate(res, body) {
|
|
501
556
|
const cpus = typeof body.cpus === "string" ? body.cpus.trim() : "";
|
|
502
557
|
const memory = typeof body.memory === "string" ? body.memory.trim() : "";
|
|
@@ -520,7 +575,7 @@ function serveGlobalSandboxUpdate(res, body) {
|
|
|
520
575
|
return;
|
|
521
576
|
}
|
|
522
577
|
try {
|
|
523
|
-
|
|
578
|
+
updateGlobalSettings(update);
|
|
524
579
|
jsonRes(res, 200, { ok: true });
|
|
525
580
|
}
|
|
526
581
|
catch (err) {
|
|
@@ -551,14 +606,7 @@ function isWorkspacePathAllowed(rel) {
|
|
|
551
606
|
}
|
|
552
607
|
function resolveConversationFromQuery(url, token) {
|
|
553
608
|
const requested = (url.searchParams.get("conversationId") ?? "").trim();
|
|
554
|
-
|
|
555
|
-
return { conversationId: token.conversationId };
|
|
556
|
-
if (requested === token.conversationId)
|
|
557
|
-
return { conversationId: requested };
|
|
558
|
-
if (requested.includes("/") || requested.includes("..")) {
|
|
559
|
-
return { conversationId: requested, error: "Invalid conversationId." };
|
|
560
|
-
}
|
|
561
|
-
return { conversationId: requested };
|
|
609
|
+
return resolveConversationId(requested, token);
|
|
562
610
|
}
|
|
563
611
|
function safeJoinUnderRoot(rootDir, relative) {
|
|
564
612
|
if (relative.startsWith("/") || relative.includes("\0")) {
|
|
@@ -1034,24 +1082,8 @@ function jsonRes(res, status, body) {
|
|
|
1034
1082
|
res.end(JSON.stringify(body));
|
|
1035
1083
|
}
|
|
1036
1084
|
async function readJsonBody(req, res, callback) {
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
await new Promise((resolve) => {
|
|
1040
|
-
req.on("data", (chunk) => {
|
|
1041
|
-
if (tooLarge)
|
|
1042
|
-
return;
|
|
1043
|
-
data += chunk.toString();
|
|
1044
|
-
if (data.length > 32 * 1024) {
|
|
1045
|
-
tooLarge = true;
|
|
1046
|
-
res.writeHead(413);
|
|
1047
|
-
res.end();
|
|
1048
|
-
req.destroy();
|
|
1049
|
-
}
|
|
1050
|
-
});
|
|
1051
|
-
req.on("end", resolve);
|
|
1052
|
-
req.on("error", resolve);
|
|
1053
|
-
});
|
|
1054
|
-
if (tooLarge)
|
|
1085
|
+
const data = await readRawBody(req, res, 32 * 1024);
|
|
1086
|
+
if (data === null)
|
|
1055
1087
|
return;
|
|
1056
1088
|
let parsed;
|
|
1057
1089
|
try {
|
|
@@ -1237,7 +1269,6 @@ function renderAdminPage(token) {
|
|
|
1237
1269
|
const data = await apiGet('/admin/api/models');
|
|
1238
1270
|
availableModels = Array.isArray(data.models) ? data.models : [];
|
|
1239
1271
|
} catch (err) {
|
|
1240
|
-
console.error('Failed to load models', err);
|
|
1241
1272
|
availableModels = [];
|
|
1242
1273
|
} finally {
|
|
1243
1274
|
modelsLoaded = true;
|
|
@@ -1303,7 +1334,7 @@ function renderAdminPage(token) {
|
|
|
1303
1334
|
}).join('');
|
|
1304
1335
|
sel.addEventListener('change', () => setActiveConversation(sel.value));
|
|
1305
1336
|
} catch (err) {
|
|
1306
|
-
|
|
1337
|
+
// ignore; conversation selector stays empty
|
|
1307
1338
|
}
|
|
1308
1339
|
}
|
|
1309
1340
|
|
|
@@ -1344,12 +1375,21 @@ function renderAdminPage(token) {
|
|
|
1344
1375
|
'<option value="' + m + '"' + (data.sandboxImageWorkspaceMount === m ? ' selected' : '') + '>' + m + '</option>'
|
|
1345
1376
|
).join('');
|
|
1346
1377
|
const rulesText = (data.autoReplyRules || []).join('\\n');
|
|
1378
|
+
const replyModes = ['top-level','thread'];
|
|
1379
|
+
const replyModeOpts = replyModes.map((m) =>
|
|
1380
|
+
'<option value="' + m + '"' + (((data.slack && data.slack.replyMode) || 'top-level') === m ? ' selected' : '') + '>' + m + '</option>'
|
|
1381
|
+
).join('');
|
|
1382
|
+
const globalReplyMode = (data.slack && data.slack.globalReplyMode) || 'top-level';
|
|
1383
|
+
const globalModel = [data.globalProvider, data.globalModel].filter(Boolean).join('/');
|
|
1384
|
+
const globalModelLabel = globalModel + (data.globalThinkingLevel ? ':' + data.globalThinkingLevel : '');
|
|
1385
|
+
const globalMount = data.globalSandboxImageWorkspaceMount || 'private';
|
|
1347
1386
|
return [
|
|
1348
1387
|
'<div class="config-grid">',
|
|
1349
1388
|
'<div class="config-block">',
|
|
1350
1389
|
'<h3 class="card-subtitle">Model</h3>',
|
|
1351
1390
|
'<div class="config-row config-row-stack"><label>Model</label><select id="m-model-ref">' + renderModelOptions(data.provider, data.model) + '</select></div>',
|
|
1352
1391
|
'<div class="config-row"><label>Thinking</label><select id="m-thinking">' + thinkingOpts + '</select></div>',
|
|
1392
|
+
'<p class="muted-note">Global default: ' + escHtml(globalModelLabel) + '</p>',
|
|
1353
1393
|
'<button class="primary-action-btn" onclick="saveModel(this)">Save model</button>',
|
|
1354
1394
|
'<div id="model-save-result" class="inline-result" style="display:none"></div>',
|
|
1355
1395
|
'</div>',
|
|
@@ -1363,9 +1403,17 @@ function renderAdminPage(token) {
|
|
|
1363
1403
|
'<div class="config-block">',
|
|
1364
1404
|
'<h3 class="card-subtitle">Workspace mount</h3>',
|
|
1365
1405
|
'<div class="config-row"><label>Mode</label><select id="m-mount">' + mountOpts + '</select></div>',
|
|
1406
|
+
'<p class="muted-note">Global default: ' + escHtml(globalMount) + '</p>',
|
|
1366
1407
|
'<button class="primary-action-btn" onclick="saveMount(this)">Save mount</button>',
|
|
1367
1408
|
'<div id="mount-save-result" class="inline-result" style="display:none"></div>',
|
|
1368
1409
|
'</div>',
|
|
1410
|
+
'<div class="config-block">',
|
|
1411
|
+
'<h3 class="card-subtitle">Slack</h3>',
|
|
1412
|
+
'<div class="config-row"><label>Reply mode</label><select id="m-slack-reply-mode">' + replyModeOpts + '</select></div>',
|
|
1413
|
+
'<p class="muted-note">Global default: ' + escHtml(globalReplyMode) + '</p>',
|
|
1414
|
+
'<button class="primary-action-btn" onclick="saveSlack(this)">Save Slack</button>',
|
|
1415
|
+
'<div id="slack-save-result" class="inline-result" style="display:none"></div>',
|
|
1416
|
+
'</div>',
|
|
1369
1417
|
'</div>',
|
|
1370
1418
|
].join('');
|
|
1371
1419
|
}
|
|
@@ -1431,6 +1479,22 @@ function renderAdminPage(token) {
|
|
|
1431
1479
|
}
|
|
1432
1480
|
}
|
|
1433
1481
|
|
|
1482
|
+
async function saveSlack(btn) {
|
|
1483
|
+
const replyMode = document.getElementById('m-slack-reply-mode').value;
|
|
1484
|
+
const result = document.getElementById('slack-save-result');
|
|
1485
|
+
btn.disabled = true; btn.textContent = 'Saving…'; result.style.display = 'none';
|
|
1486
|
+
try {
|
|
1487
|
+
await apiPost('/admin/api/conversations/slack', {
|
|
1488
|
+
conversationId: activeConversationId, replyMode,
|
|
1489
|
+
});
|
|
1490
|
+
result.style.display = 'block'; result.className = 'inline-result ok'; result.textContent = 'Saved ✓';
|
|
1491
|
+
} catch (err) {
|
|
1492
|
+
result.style.display = 'block'; result.className = 'inline-result err'; result.textContent = err.message;
|
|
1493
|
+
} finally {
|
|
1494
|
+
btn.disabled = false; btn.textContent = 'Save Slack';
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1434
1498
|
// ── Workspace ────────────────────────────────────────────────────────────────
|
|
1435
1499
|
|
|
1436
1500
|
async function loadWorkspace() {
|
|
@@ -1696,6 +1760,10 @@ function renderAdminPage(token) {
|
|
|
1696
1760
|
const mountOpts = mounts.map((m) =>
|
|
1697
1761
|
'<option value="' + m + '"' + (data.sandboxImageWorkspaceMount === m ? ' selected' : '') + '>' + m + '</option>'
|
|
1698
1762
|
).join('');
|
|
1763
|
+
const replyModes = ['top-level','thread'];
|
|
1764
|
+
const replyModeOpts = replyModes.map((m) =>
|
|
1765
|
+
'<option value="' + m + '"' + (((data.slack && data.slack.replyMode) || 'top-level') === m ? ' selected' : '') + '>' + m + '</option>'
|
|
1766
|
+
).join('');
|
|
1699
1767
|
return [
|
|
1700
1768
|
'<div class="config-grid">',
|
|
1701
1769
|
'<div class="config-block">',
|
|
@@ -1715,6 +1783,12 @@ function renderAdminPage(token) {
|
|
|
1715
1783
|
'<button class="primary-action-btn" onclick="saveGlobalSandbox(this)">Save sandbox</button>',
|
|
1716
1784
|
'<div id="g-sandbox-result" class="inline-result" style="display:none"></div>',
|
|
1717
1785
|
'</div>',
|
|
1786
|
+
'<div class="config-block">',
|
|
1787
|
+
'<h3 class="card-subtitle">Slack</h3>',
|
|
1788
|
+
'<div class="config-row"><label>Reply mode</label><select id="g-slack-reply-mode">' + replyModeOpts + '</select></div>',
|
|
1789
|
+
'<button class="primary-action-btn" onclick="saveGlobalSlack(this)">Save Slack</button>',
|
|
1790
|
+
'<div id="g-slack-result" class="inline-result" style="display:none"></div>',
|
|
1791
|
+
'</div>',
|
|
1718
1792
|
'</div>',
|
|
1719
1793
|
].join('');
|
|
1720
1794
|
}
|
|
@@ -1758,6 +1832,20 @@ function renderAdminPage(token) {
|
|
|
1758
1832
|
}
|
|
1759
1833
|
}
|
|
1760
1834
|
|
|
1835
|
+
async function saveGlobalSlack(btn) {
|
|
1836
|
+
const replyMode = document.getElementById('g-slack-reply-mode').value;
|
|
1837
|
+
const result = document.getElementById('g-slack-result');
|
|
1838
|
+
btn.disabled = true; btn.textContent = 'Saving…'; result.style.display = 'none';
|
|
1839
|
+
try {
|
|
1840
|
+
await apiPost('/admin/api/settings/slack', { replyMode });
|
|
1841
|
+
result.style.display = 'block'; result.className = 'inline-result ok'; result.textContent = 'Saved ✓';
|
|
1842
|
+
} catch (err) {
|
|
1843
|
+
result.style.display = 'block'; result.className = 'inline-result err'; result.textContent = err.message;
|
|
1844
|
+
} finally {
|
|
1845
|
+
btn.disabled = false; btn.textContent = 'Save Slack';
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1761
1849
|
async function loadGlobalSkills() {
|
|
1762
1850
|
const container = document.getElementById('global-skills-content');
|
|
1763
1851
|
container.innerHTML = '<div class="loading-msg">Loading…</div>';
|