From 3a4afea7f29643124075a93ad1ae5eefac1406d2 Mon Sep 17 00:00:00 2001 From: gobha Date: Tue, 24 Mar 2026 19:55:14 +0000 Subject: [PATCH] Changeset 0.37.18 (#230) Co-authored-by: gobha Co-committed-by: gobha --- CHANGELOG.md | 58 ++ docs/ICD/channels.md | 9 +- docs/ICD/workspaces.md | 10 + docs/ROADMAP.md | 17 +- .../sdk-test-runner/js/domains/channels.js | 10 + .../sdk-test-runner/js/domains/workspaces.js | 25 + packages/sdk-test-runner/js/fixtures.js | 13 + server/handlers/completion.go | 34 +- server/handlers/files.go | 3 +- server/handlers/tool_loop.go | 32 + server/handlers/workspaces.go | 49 ++ server/main.go | 1 + server/pages/templates/base.html | 51 +- server/static/openapi.yaml | 17 + src/css/modals.css | 25 +- src/css/sw-debug.css | 112 +++ src/js/debug.js | 700 +----------------- src/js/repl.js | 527 ------------- src/js/sw/components/chat-pane/code-block.js | 20 + .../components/chat-pane/message-actions.js | 19 +- .../sw/components/chat-pane/message-bubble.js | 35 +- .../sw/components/chat-pane/model-selector.js | 22 +- src/js/sw/components/debug/badge.js | 37 + src/js/sw/components/debug/console-tab.js | 73 ++ src/js/sw/components/debug/engine.js | 426 +++++++++++ src/js/sw/components/debug/index.js | 132 ++++ src/js/sw/components/debug/network-tab.js | 64 ++ src/js/sw/components/debug/repl-tab.js | 237 ++++++ src/js/sw/components/debug/state-tab.js | 28 + src/js/sw/sdk/api-domains.js | 5 +- src/js/sw/shell/user-menu.js | 3 +- src/js/sw/surfaces/admin/providers.js | 11 + src/js/sw/surfaces/settings/providers.js | 14 + src/sw.js | 5 +- 34 files changed, 1534 insertions(+), 1290 deletions(-) create mode 100644 src/css/sw-debug.css delete mode 100644 src/js/repl.js create mode 100644 src/js/sw/components/debug/badge.js create mode 100644 src/js/sw/components/debug/console-tab.js create mode 100644 src/js/sw/components/debug/engine.js create mode 100644 src/js/sw/components/debug/index.js create mode 100644 src/js/sw/components/debug/network-tab.js create mode 100644 src/js/sw/components/debug/repl-tab.js create mode 100644 src/js/sw/components/debug/state-tab.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7175214..bd23ba0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,63 @@ # Changelog +## [0.37.18.0] — 2026-03-24 + +### Summary + +Debug/Model surface rebuild + workspace bridge. Debug modal converted from +673 lines of imperative DOM JS to a Preact component tree (CR P2-5). User +default workspace auto-created on first access. Tool outputs auto-save file +refs linked to assistant messages. "Save to workspace" action on chat code +blocks. Model selector shows provider health dots. Provider "Test Connection" +buttons added to both BYOK and admin UIs. + +### Added + +- **Debug modal Preact rebuild:** Engine singleton (`debugEngine`) with + subscribe() pattern for reactive updates. Component tree: ConsoleTab, + NetworkTab, StateTab, ReplTab, DebugBadge. `sw-debug.css` extracted from + `modals.css`. `debug.js` reduced from 673 to ~40 lines (thin bootstrap). + `repl.js` absorbed into `repl-tab.js`. (`src/js/sw/components/debug/`) +- **Default user workspace:** `GET /workspaces/default` auto-creates a + "My Files" workspace per user on first access. Idempotent. Route wired + before `:id` param. SDK: `sw.api.workspaces.getDefault()`. + (`server/handlers/workspaces.go`, `server/main.go`) +- **tool_output auto-save:** When `workspace_write` or `workspace_patch` + tools succeed during completion, a file ref with `origin=tool_output` is + recorded in the `files` table, linked to the assistant message via + `message_id`. (`server/handlers/tool_loop.go`, `completion.go`) +- **Origin filter on channel files:** `GET /channels/:id/files?origin=` + query param. SDK: `sw.api.channels.files(id, {origin})`. + (`server/handlers/files.go`) +- **Save-to-Workspace bridge:** Save button on assistant messages with code + blocks. Saves to user's default workspace with auto-generated filenames. + `guessExtension()` utility (37 language mappings). (`message-actions.js`, + `message-bubble.js`, `code-block.js`) +- **Model selector health dots:** Provider health status (green/yellow/red) + shown next to model names in chat selector. Admin-only, graceful degrade + for non-admin. (`model-selector.js`) +- **Provider test buttons:** "Test Connection" in Settings BYOK and "Test" + in Admin providers. Calls `fetchModels()` and shows success/failure toast. + (`settings/providers.js`, `admin/providers.js`) + +### Changed + +- `modals.css`: Debug-specific rules moved to `sw-debug.css` (~22 rules). +- `base.html`: 46-line debug modal HTML block replaced by `
`. +- `sw.js`: Service worker cache list updated (repl.js removed, sw-debug.css added). +- `LoopResult` struct: new `FileRefs` field for tool file output tracking. +- `channels.files` SDK method: now accepts optional opts for query params. + +### Removed + +- `src/js/repl.js` (527 lines) — absorbed into Preact `repl-tab.js`. + +### ICD / OpenAPI / SDK Test Runner + +- ICD: `GET /workspaces/default` documented; tool_output creation behavior updated. +- OpenAPI: `/api/v1/workspaces/default` endpoint added. +- SDK runner: 2 workspace tests (getDefault + idempotent), 1 channel files origin filter test. + ## [0.37.17.0] — 2026-03-24 ### Summary diff --git a/docs/ICD/channels.md b/docs/ICD/channels.md index 895fafc..0501128 100644 --- a/docs/ICD/channels.md +++ b/docs/ICD/channels.md @@ -454,10 +454,11 @@ Content-Type: multipart/form-data Field: `file`. Sets `origin: "user_upload"`. Returns the file object. -**Create (tool output):** Tool-generated files are created internally -by the tool execution loop. There is no public REST endpoint for -tool output creation — the completion handler writes files directly -via the store layer. +**Create (tool output):** When `workspace_write` or `workspace_patch` +tools succeed during a completion, the handler auto-records a file +reference with `origin: "tool_output"` linked to the assistant message +(v0.37.18). No public endpoint — created internally by the completion +handler via the store layer. **List by channel:** diff --git a/docs/ICD/workspaces.md b/docs/ICD/workspaces.md index c414173..d7cae0e 100644 --- a/docs/ICD/workspaces.md +++ b/docs/ICD/workspaces.md @@ -16,6 +16,7 @@ owning team/project/channel). ``` GET /workspaces → { "data": [...] } POST /workspaces ← { "name", "owner_type", "owner_id" } +GET /workspaces/default → workspace object (v0.37.18) GET /workspaces/:id → workspace object PATCH /workspaces/:id ← partial update DELETE /workspaces/:id @@ -25,6 +26,15 @@ DELETE /workspaces/:id workspaces plus those owned by the user's teams. All `:id` endpoints verify the caller can access the workspace's owner entity. +#### `GET /workspaces/default` (v0.37.18) + +Returns the current user's personal workspace, creating it on first +access. The workspace is named "My Files" and owned by the current user. +Idempotent — safe to call on every page load. + +**Response:** 200 with workspace object (same shape as `GET /workspaces/:id`). +Includes `file_count` and `total_bytes` stats. + Workspace object: ```json diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0324110..e40638e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -49,7 +49,8 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅ │ v0.37.3 SDK ✅ │ │ v0.37.4 Shell ✅ │ │ v0.37.5–16 Surfaces ✅ │ - │ v0.37.17–19 Surfaces │ + │ v0.37.17–18 Surfaces ✅ │ + │ v0.37.19 Tag │ │ │ │ │ v0.38.x Workflow │ │ Product Maturity │ @@ -193,8 +194,8 @@ Each surface rebuilt as Preact component tree. Old JS deleted per surface. | v0.37.14 | SE IV + Pane audit ✅ | 4 JS deleted (~−1,672 lines); double-unwrap cleanup (93 sites); API envelope normalization (18 Go handlers); thinking tags persistence; deleteMessage endpoint; extension surface user menu fix; 7 bug fixes | | v0.37.15 | Workflow surfaces ✅ | Workflow ownership/lifecycle, stage CRUD, team-admin tabs, assignments queue | | v0.37.16 | Projects surface ✅ | Card grid + detail, inline ChatPane, context menu, sidebar pickers, deep-link | -| v0.37.17 | Workspaces | Workspace-first project file storage, tree browser UI, archive upload/download | -| v0.37.18 | Debug / model surface | Debug tooling, model configuration UI; debug modal Preact rebuild (CR P2-5) | +| v0.37.17 | Workspaces ✅ | Workspace-first project file storage, tree browser UI, archive upload/download | +| v0.37.18 | Debug / model surface ✅ | Debug modal Preact rebuild (CR P2-5), default workspace, tool_output auto-save, save-to-workspace bridge, model/provider polish | | v0.37.19 | Tag | Light mode CSS audit, dead code hunt, all features verified; `sw.can()` RBAC gates across surfaces (CR P2-1), `__USER__`/`__PAGE_DATA__` removal (CR P2-4), `_getScale` → `sw.shell.getScale()` (CR P3-3) | **v0.37.14 — Scorched Earth IV + Pane Audit ✅** (10 sessions, v0.37.14.0–.22): @@ -238,11 +239,13 @@ Git integration (clone, pull, branch tracking) is optional/post-MVP. - [x] Archive download: zip bundle of project files - [x] Storage quota enforcement (`max_bytes` on workspace) -**v0.37.18 — Debug + Model Surface:** +**v0.37.18 — Debug + Model Surface ✅:** -Debug modal Preact rebuild, model configuration UI, provider diagnostics. -Also: user default workspace, `origin=tool_output` auto-save in completion -handler, "Save to workspace" bridge action on chat files/artifacts. +Debug modal Preact rebuild (673-line imperative → component tree, 8 files). +User default workspace (auto-create "My Files" on first access). tool_output +auto-save (file refs linked to assistant messages). Save-to-workspace bridge +(save code blocks to workspace). Model selector health dots. Provider test +buttons (BYOK + admin). repl.js absorbed into Preact. sw-debug.css extracted. **v0.37.19 — Tag ("UI Complete"):** diff --git a/packages/sdk-test-runner/js/domains/channels.js b/packages/sdk-test-runner/js/domains/channels.js index 4341ec5..5c217c3 100644 --- a/packages/sdk-test-runner/js/domains/channels.js +++ b/packages/sdk-test-runner/js/domains/channels.js @@ -131,6 +131,16 @@ } }); + // ── Files with origin filter (v0.37.18) ── + + await T.test('channels', 'files', 'sw.api.channels.files(id, {origin})', { + sdk: function () { return sw.api.channels.files(channelId, { origin: 'tool_output' }); }, + raw: { method: 'GET', path: '/channels/' + channelId + '/files?origin=tool_output' }, + validate: function (r) { + T.assert(typeof r === 'object' || Array.isArray(r), 'expected object or array'); + } + }); + // ── Participants ── await T.test('channels', 'participants', 'sw.api.channels.participants(id)', { diff --git a/packages/sdk-test-runner/js/domains/workspaces.js b/packages/sdk-test-runner/js/domains/workspaces.js index 6f2afe2..87652d5 100644 --- a/packages/sdk-test-runner/js/domains/workspaces.js +++ b/packages/sdk-test-runner/js/domains/workspaces.js @@ -24,6 +24,31 @@ validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); } }); + // ── GetDefault (v0.37.18) ── + + var defaultWsId = null; + + await T.test('workspaces', 'getDefault', 'sw.api.workspaces.getDefault()', { + sdk: function () { return sw.api.workspaces.getDefault(); }, + raw: { method: 'GET', path: '/workspaces/default' }, + validate: function (r) { + T.assertShape(r, T.S.workspace, 'workspace'); + T.assert(r.owner_type === 'user', 'expected owner_type=user'); + T.assert(r.owner_id === userId, 'expected owner_id=current user'); + defaultWsId = r.id; + } + }); + + // ── GetDefault idempotent ── + + await T.test('workspaces', 'getDefault', 'sw.api.workspaces.getDefault() idempotent', { + sdk: function () { return sw.api.workspaces.getDefault(); }, + raw: { method: 'GET', path: '/workspaces/default' }, + validate: function (r) { + T.assert(r.id === defaultWsId, 'expected same workspace on second call'); + } + }); + // ── Create ── await T.test('workspaces', 'crud', 'sw.api.workspaces.create()', { diff --git a/packages/sdk-test-runner/js/fixtures.js b/packages/sdk-test-runner/js/fixtures.js index adba0e6..cafbb79 100644 --- a/packages/sdk-test-runner/js/fixtures.js +++ b/packages/sdk-test-runner/js/fixtures.js @@ -84,6 +84,17 @@ console.log('[SDKR:fixtures] ' + msg); } + // ── Resolve default endpoint for selected provider ── + var defaultEndpoint = ''; + if (selectedProvider) { + try { + var typesResp = await T.raw.get('/admin/provider-types'); + var types = typesResp.types || typesResp.data || typesResp || []; + var match = types.find ? types.find(function (t) { return t.id === selectedProvider; }) : null; + if (match) defaultEndpoint = match.default_endpoint || ''; + } catch (_) { /* best effort — endpoint will be required in payload */ } + } + // Check if any configs already exist try { var configs = await T.raw.get('/admin/configs'); @@ -103,6 +114,7 @@ var configData = { name: 'sdkr-fixture-' + selectedProvider, provider: selectedProvider, + endpoint: defaultEndpoint, api_key: apiKey, scope: 'global' }; @@ -129,6 +141,7 @@ var configData2 = { name: 'sdkr-fixture-' + selectedProvider, provider: selectedProvider, + endpoint: defaultEndpoint, scope: 'global' }; var created2 = await T.raw.post('/admin/configs', configData2); diff --git a/server/handlers/completion.go b/server/handlers/completion.go index 4581d81..f5c1ddf 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -963,6 +963,9 @@ func (h *CompletionHandler) streamCompletion( log.Printf("Failed to persist assistant message: %v", err) } + // Record tool_output file refs (v0.37.18) + h.recordToolFileRefs(c.Request.Context(), result.FileRefs, channelID, userID, asstMsgID) + // Broadcast assistant message to other channel participants if h.hub != nil && asstMsgID != "" { broadcastAssistantMessage(c.Request.Context(), h.hub, channelID, asstMsgID, result.Content, model, userID, personaID) @@ -1049,10 +1052,14 @@ func (h *CompletionHandler) syncCompletion( finalContent := result.Content // Persist assistant response - if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID); err != nil { + syncAsstMsgID, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID) + if err != nil { log.Printf("Failed to persist assistant message: %v", err) } + // Record tool_output file refs (v0.37.18) + h.recordToolFileRefs(c.Request.Context(), result.FileRefs, channelID, userID, syncAsstMsgID) + // AI-to-AI chaining go func() { defer func() { @@ -1770,6 +1777,31 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro // ── Message Persistence ───────────────────── +// recordToolFileRefs creates file records with origin=tool_output for workspace +// files produced by tools during a completion. Links each file to the assistant +// message so the UI can show "files generated by this response". +func (h *CompletionHandler) recordToolFileRefs(ctx context.Context, refs []FileRef, channelID, userID, asstMsgID string) { + if len(refs) == 0 || asstMsgID == "" { + return + } + for _, ref := range refs { + f := &models.File{ + ChannelID: channelID, + UserID: userID, + Origin: models.FileOriginToolOutput, + Filename: ref.Path, + ContentType: "application/octet-stream", + DisplayHint: "download", + } + if asstMsgID != "" { + f.MessageID = &asstMsgID + } + if err := h.stores.Files.Create(ctx, f); err != nil { + log.Printf("[file_ref] Failed to record tool_output file ref %s: %v", ref.Path, err) + } + } +} + // persistMessage inserts a message into the tree, updates the cursor, and // returns the new message's ID. // diff --git a/server/handlers/files.go b/server/handlers/files.go index 9109089..e78e263 100644 --- a/server/handlers/files.go +++ b/server/handlers/files.go @@ -271,7 +271,8 @@ func (h *FileHandler) ListByChannel(c *gin.Context) { return } - files, err := h.stores.Files.GetByChannel(c.Request.Context(), channelID, "") + origin := c.Query("origin") // v0.37.18: filter by origin (user_upload, tool_output, system) + files, err := h.stores.Files.GetByChannel(c.Request.Context(), channelID, origin) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"}) return diff --git a/server/handlers/tool_loop.go b/server/handlers/tool_loop.go index 05f65db..ac90ae5 100644 --- a/server/handlers/tool_loop.go +++ b/server/handlers/tool_loop.go @@ -42,9 +42,16 @@ const defaultMaxRounds = 10 // LoopResult holds the accumulated output from a completion with tool // execution. Callers are responsible for persistence. +// FileRef tracks a file created by a tool for auto-linking to the assistant message. +type FileRef struct { + WorkspaceID string `json:"workspace_id"` + Path string `json:"path"` +} + type LoopResult struct { Content string ToolActivity []map[string]interface{} + FileRefs []FileRef // v0.37.18: workspace files created by tools InputTokens int OutputTokens int CacheCreationTokens int @@ -210,6 +217,11 @@ func CoreToolLoop(ctx context.Context, lcfg LoopConfig, sink LoopSink) LoopResul // Emit workspace.file.changed for live editor updates (v0.21.5) emitWorkspaceEvent(lcfg, call, toolResult) + // Collect file refs for tool_output auto-save (v0.37.18) + if ref := collectFileRef(lcfg, call, toolResult); ref != nil { + result.FileRefs = append(result.FileRefs, *ref) + } + // Collect for persistence result.ToolActivity = append(result.ToolActivity, map[string]interface{}{ "id": tc.ID, @@ -574,6 +586,26 @@ func emitWorkspaceEvent(lcfg LoopConfig, call tools.ToolCall, result tools.ToolR } } +// collectFileRef returns a FileRef when workspace_write or workspace_patch succeeds. +func collectFileRef(lcfg LoopConfig, call tools.ToolCall, result tools.ToolResult) *FileRef { + if result.IsError || lcfg.ExecCtx.WorkspaceID == "" { + return nil + } + if call.Name != "workspace_write" && call.Name != "workspace_patch" { + return nil + } + var toolArgs struct { + Path string `json:"path"` + } + if json.Unmarshal([]byte(call.Arguments), &toolArgs) == nil && toolArgs.Path != "" { + return &FileRef{ + WorkspaceID: lcfg.ExecCtx.WorkspaceID, + Path: toolArgs.Path, + } + } + return nil +} + // ── Sink Implementations ─────────────────────── // sseSink writes SSE events to a gin.Context writer for interactive streaming. diff --git a/server/handlers/workspaces.go b/server/handlers/workspaces.go index 0918066..8cc1cfc 100644 --- a/server/handlers/workspaces.go +++ b/server/handlers/workspaces.go @@ -85,6 +85,55 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { c.JSON(http.StatusCreated, w) } +// GetDefault returns the current user's personal workspace, creating it on first access. +// The personal workspace is the first user-owned workspace (by created_at) or a new +// "My Files" workspace if none exists. Idempotent — safe to call on every page load. +func (h *WorkspaceHandler) GetDefault(c *gin.Context) { + userID := getUserID(c) + ctx := c.Request.Context() + + w, err := h.stores.Workspaces.GetByOwner(ctx, models.WorkspaceOwnerUser, userID) + if err == nil { + // Enrich with stats + stats, _ := h.stores.Workspaces.GetStats(ctx, w.ID) + if stats != nil { + w.FileCount = stats.FileCount + w.TotalBytes = stats.TotalBytes + } + c.JSON(http.StatusOK, w) + return + } + if err != sql.ErrNoRows { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch workspace"}) + log.Printf("workspace getDefault: %v", err) + return + } + + // Auto-create personal workspace + w = &models.Workspace{ + OwnerType: models.WorkspaceOwnerUser, + OwnerID: userID, + Name: "My Files", + Status: models.WorkspaceStatusActive, + } + w.ID = store.NewID() + w.RootPath = "workspaces/" + w.ID + + if err := h.stores.Workspaces.Create(ctx, w); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create default workspace"}) + log.Printf("workspace getDefault create: %v", err) + return + } + + if err := h.wfs.CreateDir(w); err != nil { + log.Printf("workspace getDefault mkdir: %v", err) + // Workspace record exists but directory failed — still return the workspace. + // Directory will be created on first file upload. + } + + c.JSON(http.StatusOK, w) +} + func (h *WorkspaceHandler) Get(c *gin.Context) { w, ok := h.loadAndAuthorize(c) if !ok { diff --git a/server/main.go b/server/main.go index 6c3e9ae..5fce905 100644 --- a/server/main.go +++ b/server/main.go @@ -919,6 +919,7 @@ func main() { wsH := handlers.NewWorkspaceHandler(stores, wfs) protected.POST("/workspaces", wsH.Create) protected.GET("/workspaces", wsH.List) + protected.GET("/workspaces/default", wsH.GetDefault) // v0.37.18: before :id param protected.GET("/workspaces/:id", wsH.Get) protected.PATCH("/workspaces/:id", wsH.Update) protected.DELETE("/workspaces/:id", wsH.Delete) diff --git a/server/pages/templates/base.html b/server/pages/templates/base.html index 3b46b4b..9d4706a 100644 --- a/server/pages/templates/base.html +++ b/server/pages/templates/base.html @@ -25,6 +25,7 @@ + {{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}} {{if eq .Surface "notes"}}{{template "css-notes" .}}{{end}} {{if eq .Surface "projects"}}{{template "css-projects" .}}{{end}} @@ -142,55 +143,9 @@ {{/* v0.37.13: Legacy UserMenu hydration removed — all surfaces use Preact UserMenu. */}} - {{/* ── Debug Log Modal (all surfaces) ───── */}} - + {{/* ── Debug Modal (Preact, v0.37.18) ───── */}} +
- {{end}} diff --git a/server/static/openapi.yaml b/server/static/openapi.yaml index 6e853c4..278d35e 100644 --- a/server/static/openapi.yaml +++ b/server/static/openapi.yaml @@ -4441,6 +4441,23 @@ paths: application/json: schema: $ref: '#/components/schemas/MessageResponse' + /api/v1/workspaces/default: + get: + tags: + - Workspaces + summary: Get or create user's default workspace + description: Returns the current user's personal "My Files" workspace, auto-creating it on first access. Idempotent. + security: + - bearerAuth: [] + responses: + '200': + description: Default workspace + content: + application/json: + schema: + $ref: '#/components/schemas/Workspace' + '401': + $ref: '#/components/responses/Unauthorized' /api/v1/workspaces/{id}: get: tags: diff --git a/src/css/modals.css b/src/css/modals.css index 29cae7a..381d3bb 100644 --- a/src/css/modals.css +++ b/src/css/modals.css @@ -303,30 +303,7 @@ .admin-user-row.user-inactive { opacity: 0.7; } -/* ── Debug Modal ─────────────────────────── */ - -.debug-modal { max-width: 900px; height: 100%; overflow: hidden; } -.debug-tabs { } -.debug-tab { padding: 8px 12px; background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent; white-space: nowrap; flex-shrink: 0; } -.debug-tab.active { color: var(--accent); border-bottom-color: var(--accent); } -.debug-modal-body { flex: 1; overflow: hidden; padding: 0 !important; display: flex; flex-direction: column; } -.debug-tab-content { flex: 1; display: flex; flex-direction: column; min-height: 0; } -.debug-toolbar { display: flex; gap: 1rem; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 11px; } -.debug-check { display: flex; align-items: center; gap: 4px; color: var(--text-3); cursor: pointer; } -.debug-content { flex: 1; overflow-y: auto; padding: 6px; font-family: var(--mono); font-size: 11px; line-height: 1.5; } -.debug-entry { padding: 1px 6px; border-bottom: 1px solid rgba(128,128,128,0.06); white-space: pre-wrap; word-break: break-all; } -.debug-time { color: var(--text-3); opacity: 0.6; font-size: 10px; } -.debug-msg { color: var(--text); } -.debug-empty { color: var(--text-3); text-align: center; padding: 2rem; } -.debug-net-entry { border-bottom: 1px solid var(--border); } -.debug-net-entry summary { padding: 6px; cursor: pointer; font-size: 11px; } -.debug-net-method { font-weight: bold; color: var(--accent); } -.debug-net-url { color: var(--text); word-break: break-all; } -.debug-net-detail { padding: 6px 12px; font-size: 11px; } -.debug-pre { white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow: auto; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; margin: 4px 0; } -.debug-state-pre { max-height: none; margin: 0; } -.debug-footer { display: flex; align-items: center; } - +/* ── Debug Modal — moved to sw-debug.css (v0.37.18) ── */ /* ── Command Palette ─────────────────────── */ diff --git a/src/css/sw-debug.css b/src/css/sw-debug.css new file mode 100644 index 0000000..a0232a4 --- /dev/null +++ b/src/css/sw-debug.css @@ -0,0 +1,112 @@ +/* ── sw-debug.css ──────────────────────────── + Debug modal styles — extracted from modals.css (v0.37.18). + Preact component tree: engine, console, network, state, REPL, badge. + ──────────────────────────────────────────── */ + +/* ── Debug Modal layout ──────────────────── */ + +.debug-modal { max-width: 900px; height: 100%; overflow: hidden; } +.debug-tab { + padding: 8px 12px; background: none; border: none; color: var(--text-3); + cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent; + white-space: nowrap; flex-shrink: 0; font-family: var(--font); + transition: color var(--transition); +} +.debug-tab:hover { color: var(--text-2); } +.debug-tab.active { color: var(--accent); border-bottom-color: var(--accent); } +.debug-tab--hidden { display: none; } +.debug-modal-body { + flex: 1; overflow: hidden; padding: 0 !important; + display: flex; flex-direction: column; min-height: 0; +} +.debug-tab-content { flex: 1; display: flex; flex-direction: column; min-height: 0; } + +/* ── Shared ──────────────────────────────── */ + +.debug-toolbar { + display: flex; align-items: center; gap: 1rem; + padding: 6px 12px; border-bottom: 1px solid var(--border); + font-size: 11px; flex-shrink: 0; +} +.debug-check { display: flex; align-items: center; gap: 4px; color: var(--text-3); cursor: pointer; } +.debug-badge { + font-size: 10px; background: var(--bg-raised); border: 1px solid var(--border); + border-radius: 10px; padding: 1px 6px; color: var(--text-3); margin-left: auto; +} +.debug-content { + flex: 1; overflow-y: auto; padding: 6px; + font-family: var(--mono); font-size: 11px; line-height: 1.5; min-height: 0; +} +.debug-empty { color: var(--text-3); text-align: center; padding: 2rem; } +.debug-time { color: var(--text-3); opacity: 0.6; font-size: 10px; } +.debug-pre { + white-space: pre-wrap; word-break: break-all; + max-height: 200px; overflow: auto; padding: 6px; + background: rgba(0,0,0,0.2); border-radius: 4px; margin: 4px 0; +} + +/* ── Console Tab ─────────────────────────── */ + +.debug-console-tab { display: flex; flex-direction: column; flex: 1; min-height: 0; } +.debug-entry { + padding: 1px 6px; border-bottom: 1px solid rgba(128,128,128,0.06); + white-space: pre-wrap; word-break: break-all; +} +.debug-msg { color: var(--text); } + +/* ── Network Tab ─────────────────────────── */ + +.debug-network-tab { display: flex; flex-direction: column; flex: 1; min-height: 0; } +.debug-net-entry { border-bottom: 1px solid var(--border); } +.debug-net-summary { padding: 6px; cursor: pointer; font-size: 11px; display: flex; align-items: baseline; gap: 4px; } +.debug-net-arrow { font-size: 10px; color: var(--text-3); width: 10px; text-align: center; flex-shrink: 0; } +.debug-net-method { font-weight: bold; color: var(--accent); } +.debug-net-url { color: var(--text); word-break: break-all; flex: 1; } +.debug-net-detail { padding: 6px 12px 6px 20px; font-size: 11px; } + +/* ── State Tab ───────────────────────────── */ + +.debug-state-tab { display: flex; flex-direction: column; flex: 1; min-height: 0; } +.debug-state-pre { max-height: none; margin: 0; } + +/* ── REPL Tab ────────────────────────────── */ + +.debug-repl-tab { display: flex; flex-direction: column; flex: 1; min-height: 0; } +.debug-repl-output { + flex: 1; overflow-y: auto; padding: 8px; + font-family: var(--mono); font-size: 12px; min-height: 0; +} +.debug-repl-input { + border-top: 1px solid var(--border); padding: 8px; flex-shrink: 0; +} +.debug-repl-input input { + width: 100%; font-family: var(--mono); font-size: 12px; + background: var(--bg); border: 1px solid var(--border); color: var(--text); + padding: 6px 8px; border-radius: var(--radius); outline: none; +} +.debug-repl-input input:focus { border-color: var(--accent); } + +.repl-entry { padding: 2px 0; } +.repl-entry pre { margin: 0; white-space: pre-wrap; word-break: break-all; } +.repl-entry-input pre { color: var(--accent); } +.repl-entry-result pre { color: var(--text); } +.repl-entry-info { color: var(--text-3); font-size: 11px; } +.repl-entry-error pre { color: var(--danger); } +.repl-entry-stack pre { color: var(--text-3); font-size: 10px; padding-left: 1rem; } + +/* ── Footer ──────────────────────────────── */ + +.debug-modal-footer { + display: flex; justify-content: space-between; align-items: center; +} +.debug-footer-left, .debug-footer-right { display: flex; gap: 8px; } + +/* ── Badge (sidebar bug icon) ────────────── */ + +.avatar-bug.has-errors { + animation: debug-pulse 2s infinite; +} +@keyframes debug-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} diff --git a/src/js/debug.js b/src/js/debug.js index 68aa2fc..d108077 100644 --- a/src/js/debug.js +++ b/src/js/debug.js @@ -1,672 +1,42 @@ // ========================================== -// Debug Logger & Modal +// Debug Bootstrap (v0.37.18) // ========================================== -// Console interceptor, fetch logger, state inspector. -// Adapted from ai-editor's error-logger.js + llm-debug-modal.js -// for no-devtools "hardmode" debugging. +// Thin entry point: initializes the debug engine (synchronous, +// captures early errors) then mounts the Preact debug modal +// after Preact globals are available. // -// Activate: Ctrl+K → "debug" or tap the 🐛 badge (appears after init) -// ========================================== -// -// Exports: window.DebugLog,window.openDebugModal,window.switchDebugTab,window.clearDebugLog,window.copyDebugLog,window.exportDebugLog,window.runDebugDiagnostics,window.purgeCache - - -const DebugLog = { - entries: [], - maxEntries: 500, - networkLog: [], - maxNetwork: 200, - _origConsole: {}, - _origFetch: null, - _initTime: Date.now(), - _errorCount: 0, - - // ── Bootstrap ──────────────────────────── - - init() { - this._interceptConsole(); - this._interceptFetch(); - this._interceptErrors(); - this._injectBadge(); - this.log('DEBUG', '🐛 Debug logger initialized'); - this.log('DEBUG', `Page: ${location.href}`); - this.log('DEBUG', `UA: ${navigator.userAgent.slice(0, 120)}`); - }, - - // ── Console Interception ──────────────── - - _interceptConsole() { - ['log', 'warn', 'error', 'info', 'debug'].forEach(method => { - this._origConsole[method] = console[method].bind(console); - console[method] = (...args) => { - const type = method.toUpperCase(); - const message = args.map(a => this._serialize(a)).join(' '); - this._addEntry(type, message); - this._origConsole[method](...args); - }; - }); - }, - - // ── Fetch Interception ────────────────── - - _interceptFetch() { - this._origFetch = window.fetch.bind(window); - const self = this; - - window.fetch = async function(input, init) { - const url = typeof input === 'string' ? input : input?.url || String(input); - const method = init?.method || 'GET'; - const entry = { - id: Date.now(), - ts: new Date().toISOString(), - method, - url: url.slice(0, 300), - status: null, - statusText: '', - contentType: null, - duration: null, - error: null, - responsePreview: null, - requestBodyPreview: null, - proxyDetected: false - }; - - // Capture request body preview (redact keys) - if (init?.body) { - try { - const bodyStr = typeof init.body === 'string' ? init.body : ''; - const parsed = bodyStr ? JSON.parse(bodyStr) : {}; - // Redact sensitive fields - const safe = { ...parsed }; - ['password', 'api_key', 'apiKey', 'refresh_token', 'access_token'].forEach(k => { - if (safe[k]) safe[k] = '***'; - }); - entry.requestBodyPreview = JSON.stringify(safe).slice(0, 300); - } catch (e) { - entry.requestBodyPreview = '(non-JSON body)'; - } - } - - self.networkLog.push(entry); - if (self.networkLog.length > self.maxNetwork) { - self.networkLog.shift(); - } - - const start = performance.now(); - try { - const resp = await self._origFetch(input, init); - entry.status = resp.status; - entry.statusText = resp.statusText; - entry.contentType = resp.headers.get('content-type') || null; - entry.duration = Math.round(performance.now() - start); - - // Detect proxy interception: API call returned HTML - const isApiCall = url.includes('/api/') || url.includes('/health'); - if (isApiCall && entry.contentType && entry.contentType.includes('text/html')) { - entry.proxyDetected = true; - self._addEntry('NET:PROXY', `${method} ${url.slice(0, 80)} → 🛡️ PROXY HTML (${entry.contentType}) (${entry.duration}ms)`); - try { - const clone = resp.clone(); - const text = await clone.text(); - entry.responsePreview = text.slice(0, 500); - } catch (e) { /* */ } - } else { - // Log non-ok responses at higher severity - const logType = resp.ok ? 'NET' : 'NET:ERR'; - self._addEntry(logType, `${method} ${url.slice(0, 80)} → ${resp.status} (${entry.duration}ms)`); - } - - // Clone and peek at error responses - if (!resp.ok && !entry.proxyDetected) { - try { - const clone = resp.clone(); - const text = await clone.text(); - entry.responsePreview = text.slice(0, 500); - } catch (e) { /* can't peek */ } - } - - return resp; - } catch (err) { - entry.duration = Math.round(performance.now() - start); - entry.error = err.message || String(err); - self._addEntry('NET:ERR', `${method} ${url.slice(0, 80)} → FAILED: ${entry.error} (${entry.duration}ms)`); - throw err; - } - }; - }, - - // ── Global Error Handlers ─────────────── - - _interceptErrors() { - window.addEventListener('error', (e) => { - this._addEntry('EXCEPTION', `${e.message} at ${e.filename || '?'}:${e.lineno || 0}:${e.colno || 0}`); - this._errorCount++; - this._updateBadge(); - }); - - window.addEventListener('unhandledrejection', (e) => { - const msg = e.reason?.message || e.reason || 'Unknown rejection'; - this._addEntry('REJECTION', String(msg)); - this._errorCount++; - this._updateBadge(); - }); - }, - - // ── Entry Management ──────────────────── - - _addEntry(type, message) { - const entry = { - ts: new Date().toISOString(), - elapsed: Date.now() - this._initTime, - type, - message - }; - this.entries.push(entry); - if (this.entries.length > this.maxEntries) { - this.entries.shift(); - } - - if (type === 'ERROR' || type === 'EXCEPTION' || type === 'REJECTION' || type === 'NET:ERR' || type === 'NET:PROXY') { - this._errorCount++; - this._updateBadge(); - } - - // Auto-refresh if modal is open - if (document.getElementById('debugModal')?.classList.contains('active')) { - this._scheduleRender(); - } - }, - - _renderTimer: null, - _scheduleRender() { - if (this._renderTimer) return; - this._renderTimer = setTimeout(() => { - this._renderTimer = null; - this.render(); - }, 250); - }, - - log(type, message) { - this._addEntry(type, message); - }, - - clear() { - this.entries = []; - this.networkLog = []; - this._errorCount = 0; - this._updateBadge(); - }, - - // ── Serialization ─────────────────────── - - _serialize(arg) { - if (arg === null) return 'null'; - if (arg === undefined) return 'undefined'; - if (arg instanceof Error) { - return `${arg.name}: ${arg.message}`; - } - if (typeof arg === 'object') { - try { return JSON.stringify(arg, null, 0); } - catch (e) { return String(arg); } - } - return String(arg); - }, - - // ── State Snapshot ────────────────────── - - getStateSnapshot() { - const snap = { - env: window.__ENV__ || 'unknown', - version: window.__VERSION__ || 'unknown', - basePath: window.__BASE__ || '(root)', - url: location.href, - }; - - // Preact SDK state (v0.37.14: replaces old API/App globals) - if (window.sw) { - snap.sdk = { - user: window.sw.auth?.user ? { - username: window.sw.auth.user.username, - role: window.sw.auth.user.role, - display_name: window.sw.auth.user.display_name - } : null, - isAuthed: !!window.sw.auth?.token, - wsConnected: window.sw.events?.connected ?? false, - theme: window.sw.theme?.resolved ?? 'unknown', - }; - } - - // localStorage keys - try { - snap.storageKeys = Object.keys(localStorage).filter(k => - k.startsWith('chatSwitchboard') || k.startsWith('switchboard') || k.startsWith('sb_') - ); - } catch (e) { - snap.storageKeys = '(error reading)'; - } - - return snap; - }, - - // ── Connection Diagnostics ────────────── - - async runDiagnostics() { - this.log('DIAG', '── Starting connection diagnostics ──'); - this.log('DIAG', `Environment: ${window.__ENV__ || 'unknown'} | Version: ${window.__VERSION__ || 'unknown'} | Base: ${window.__BASE__ || '(root)'}`); - - const baseUrl = window.location.origin + (window.__BASE__ || ''); - - // Test 1: Basic fetch to same origin - this.log('DIAG', `Test 1: Health check → ${baseUrl}/api/v1/health`); - try { - const start = performance.now(); - const resp = await this._origFetch(baseUrl + '/api/v1/health', { - signal: AbortSignal.timeout(10000) - }); - const dur = Math.round(performance.now() - start); - const contentType = resp.headers.get('content-type') || '(none)'; - const text = await resp.text(); - this.log('DIAG', ` Status: ${resp.status} (${dur}ms)`); - this.log('DIAG', ` Content-Type: ${contentType}`); - this.log('DIAG', ` Body: ${text.slice(0, 300)}`); - - if (contentType.includes('text/html')) { - this.log('DIAG', ' ⚠️ Got HTML instead of JSON — likely a PROXY interception page'); - } - if (!resp.ok) { - this.log('DIAG', ` ⚠️ Non-200 status: ${resp.status} ${resp.statusText}`); - } - try { - const json = JSON.parse(text); - this.log('DIAG', ` ✅ Valid JSON: database=${json.database}, registration=${json.registration_enabled}`); - } catch (e) { - this.log('DIAG', ` ❌ NOT valid JSON — proxy or wrong endpoint`); - } - } catch (err) { - this.log('DIAG', ` ❌ Fetch failed: ${err.message}`); - if (err.name === 'TimeoutError' || err.name === 'AbortError') { - this.log('DIAG', ' ⚠️ Request timed out — proxy may be blocking'); - } - } - - // Test 2: Check if we're getting CORS issues - this.log('DIAG', 'Test 2: OPTIONS preflight check'); - try { - const resp = await this._origFetch(baseUrl + '/api/v1/health', { - method: 'OPTIONS', - signal: AbortSignal.timeout(5000) - }); - this.log('DIAG', ` Status: ${resp.status}`); - this.log('DIAG', ` CORS headers: ${resp.headers.get('access-control-allow-origin') || '(none)'}`); - } catch (err) { - this.log('DIAG', ` ❌ OPTIONS failed: ${err.message}`); - } - - // Test 3: Token validity via Preact SDK - if (window.sw?.auth?.token) { - this.log('DIAG', 'Test 3: Token validation'); - try { - const resp = await this._origFetch(baseUrl + '/api/v1/profile', { - headers: { - 'Authorization': `Bearer ${window.sw.auth.token}`, - 'Content-Type': 'application/json' - }, - signal: AbortSignal.timeout(5000) - }); - this.log('DIAG', ` Profile endpoint: ${resp.status}`); - if (resp.status === 401) { - this.log('DIAG', ' ⚠️ Token expired or invalid'); - } - } catch (err) { - this.log('DIAG', ` ❌ Token check failed: ${err.message}`); - } - } else { - this.log('DIAG', 'Test 3: Skipped (no token)'); - } - - // Test 4: WebSocket connectivity via Preact SDK - this.log('DIAG', `Test 4: SDK WebSocket = ${window.sw?.events?.connected ? 'connected' : 'disconnected'}`); - - // Test 5: Service Worker & Cache - this.log('DIAG', 'Test 6: Service Worker cache'); - try { - if ('serviceWorker' in navigator) { - const reg = await navigator.serviceWorker.getRegistration(); - this.log('DIAG', ` SW registered: ${reg ? 'yes' : 'no'}${reg ? ` (scope: ${reg.scope})` : ''}`); - if (reg?.active) { - this.log('DIAG', ` SW state: ${reg.active.state}`); - } - } else { - this.log('DIAG', ' SW not supported'); - } - const keys = await caches.keys(); - this.log('DIAG', ` Caches (${keys.length}): ${keys.join(', ') || '(none)'}`); - for (const key of keys) { - const cache = await caches.open(key); - const entries = await cache.keys(); - this.log('DIAG', ` ${key}: ${entries.length} entries`); - } - } catch (e) { - this.log('DIAG', ` Cache check error: ${e.message}`); - } - - this.log('DIAG', '── Diagnostics complete ──'); - this.render(); - }, - - // ── Badge ─────────────────────────────── - - _injectBadge() { - // Keyboard shortcut only — visual indicator is the avatar 🐛 - document.addEventListener('keydown', (e) => { - if (e.ctrlKey && e.shiftKey && e.key === 'L') { - e.preventDefault(); - openDebugModal(); - } - }); - }, - - _updateBadge() { - // Show error count on avatar bug in sidebar - const bug = document.querySelector('.avatar-bug'); - if (!bug) return; - if (this._errorCount > 0) { - bug.textContent = '🐛'; - bug.title = `${this._errorCount} error${this._errorCount > 1 ? 's' : ''} — click for Debug Log`; - bug.classList.add('has-errors'); - } else { - bug.textContent = '🐛'; - bug.title = 'Debug available'; - bug.classList.remove('has-errors'); - } - }, - - // ── Modal Rendering ───────────────────── - - render() { - this._renderConsoleTab(); - this._renderNetworkTab(); - this._renderStateTab(); - }, - - _renderConsoleTab() { - const container = document.getElementById('debugConsoleContent'); - if (!container) return; - - const count = document.getElementById('debugConsoleCount'); - if (count) count.textContent = this.entries.length; - - if (this.entries.length === 0) { - container.innerHTML = '
No log entries yet
'; - return; - } - - const typeColors = { - 'ERROR': '#e74c3c', 'EXCEPTION': '#e74c3c', 'REJECTION': '#e74c3c', - 'NET:ERR': '#e74c3c', 'NET:PROXY': '#ff6b35', - 'WARN': '#f39c12', 'WARNING': '#f39c12', - 'LOG': '#95a5a6', 'INFO': '#3498db', 'DEBUG': '#7f8c8d', - 'NET': '#2ecc71', - 'DIAG': '#9b59b6' - }; - - let html = ''; - const entries = document.getElementById('debugFilterErrors')?.checked - ? this.entries.filter(e => ['ERROR', 'EXCEPTION', 'REJECTION', 'NET:ERR', 'NET:PROXY', 'WARN'].includes(e.type)) - : this.entries; - - for (const entry of entries) { - const color = typeColors[entry.type] || '#95a5a6'; - const time = entry.ts.slice(11, 23); - const elapsed = (entry.elapsed / 1000).toFixed(1); - html += `
`; - html += `${time} +${elapsed}s `; - html += `[${this._esc(entry.type)}] `; - html += `${this._esc(entry.message)}`; - html += `
`; - } - container.innerHTML = html; - - if (document.getElementById('debugAutoScroll')?.checked) { - container.scrollTop = container.scrollHeight; - } - }, - - _renderNetworkTab() { - const container = document.getElementById('debugNetworkContent'); - if (!container) return; - - const count = document.getElementById('debugNetworkCount'); - if (count) count.textContent = this.networkLog.length; - - if (this.networkLog.length === 0) { - container.innerHTML = '
No network requests logged
'; - return; - } - - let html = ''; - for (const req of [...this.networkLog].reverse()) { - const isErr = req.error || (req.status && req.status >= 400) || req.proxyDetected; - const statusColor = req.proxyDetected ? '#ff6b35' : (isErr ? '#e74c3c' : '#2ecc71'); - const statusText = req.proxyDetected - ? `🛡️ PROXY (${req.status})` - : req.error - ? `❌ ${req.error}` - : (req.status ? `${req.status} ${req.statusText}` : '⏳ pending'); - - html += `
`; - html += ``; - html += `${req.method} `; - html += `${this._esc(req.url)} `; - html += `${statusText}`; - if (req.duration != null) html += ` ${req.duration}ms`; - html += ``; - html += `
`; - if (req.contentType) { - html += `
Content-Type: ${this._esc(req.contentType)}
`; - } - if (req.requestBodyPreview) { - html += `
Request: ${this._esc(req.requestBodyPreview)}
`; - } - if (req.responsePreview) { - html += `
Response:
${this._esc(req.responsePreview)}
`; - } - html += `
${req.ts}
`; - html += `
`; - } - container.innerHTML = html; - }, - - _renderStateTab() { - const container = document.getElementById('debugStateContent'); - if (!container) return; - - const snap = this.getStateSnapshot(); - container.innerHTML = `
${this._esc(JSON.stringify(snap, null, 2))}
`; - }, - - _esc(s) { - if (!s) return ''; - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - }, - - // ── Export ─────────────────────────────── - - exportText() { - let text = `=== Chat Switchboard Debug Export ===\n`; - text += `Exported: ${new Date().toISOString()}\n`; - text += `URL: ${location.href}\n`; - text += `ENV: ${window.__ENV__ || 'unknown'} | VERSION: ${window.__VERSION__ || 'unknown'} | BASE: ${window.__BASE__ || '(root)'}\n`; - text += `UA: ${navigator.userAgent}\n\n`; - - text += `--- STATE ---\n`; - text += JSON.stringify(this.getStateSnapshot(), null, 2) + '\n\n'; - - text += `--- CONSOLE LOG (${this.entries.length}) ---\n`; - for (const e of this.entries) { - text += `[${e.ts}] [${e.type}] ${e.message}\n`; - } - text += '\n'; - - text += `--- NETWORK LOG (${this.networkLog.length}) ---\n`; - for (const r of this.networkLog) { - const proxy = r.proxyDetected ? ' [PROXY]' : ''; - text += `[${r.ts}] ${r.method} ${r.url} → ${r.status || 'FAILED'}${proxy} ${r.error || ''} (${r.duration || '?'}ms)`; - if (r.contentType) text += ` [${r.contentType}]`; - text += '\n'; - if (r.responsePreview) text += ` Response: ${r.responsePreview.slice(0, 200)}\n`; - } - - return text; +// Replaces the 673-line imperative debug.js from v0.37.14. +// Engine: src/js/sw/components/debug/engine.js +// Modal: src/js/sw/components/debug/index.js + +import { debugEngine } from './sw/components/debug/engine.js'; + +// Initialize ASAP — before SDK boot so we capture everything. +debugEngine.init(); + +// Backward-compat globals (used by badge click, command palette, etc.) +window.DebugLog = debugEngine; +window.openDebugModal = () => window.dispatchEvent(new CustomEvent('debug:toggle')); +window.copyDebugLog = () => debugEngine.copyLog(); +window.exportDebugLog = () => debugEngine.downloadLog(); +window.runDebugDiagnostics = () => debugEngine.runDiagnostics(); +window.purgeCache = () => debugEngine.purgeCache(); + +// Mount Preact modal once the DOM + Preact globals are ready. +// Preact globals are set by the surface template's script block, +// which loads before this module (type=module is deferred). +function mountWhenReady() { + if (!window.preact || !window.html || !window.hooks) { + // Surface script hasn't loaded yet — retry on next tick. + requestAnimationFrame(mountWhenReady); + return; } -}; - -// ── Inlined from ui-primitives.js (Scorched Earth III, v0.37.13) ── - -function _escHtml(s) { - const d = document.createElement('div'); - d.textContent = s; - return d.innerHTML; -} - -function openModal(id) { - const el = document.getElementById(id); - if (!el) return; - el.classList.add('active'); -} - -function closeModal(id) { - const el = document.getElementById(id); - if (el) el.classList.remove('active'); -} - -function showConfirm(message, opts = {}) { - return new Promise(resolve => { - const title = opts.title || 'Confirm'; - const okText = opts.ok || 'Confirm'; - const caText = opts.cancel || 'Cancel'; - const danger = opts.danger !== false; - const overlay = document.createElement('div'); - overlay.className = 'confirm-overlay'; - overlay.innerHTML = ` -
-
${_escHtml(title)}
-
${_escHtml(message)}
- -
`; - function close(result) { overlay.remove(); document.removeEventListener('keydown', onKey); resolve(result); } - function onKey(e) { if (e.key === 'Escape') close(false); if (e.key === 'Enter') close(true); } - overlay.querySelector('[data-action="ok"]').addEventListener('click', () => close(true)); - overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(false)); - overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); }); - document.addEventListener('keydown', onKey); - document.body.appendChild(overlay); - overlay.querySelector('[data-action="cancel"]').focus(); + import('./sw/components/debug/index.js').then(({ mountDebugModal }) => { + mountDebugModal(document.getElementById('debugMount')); }); } - -// ── Modal Controls ────────────────────────── - -function openDebugModal() { - DebugLog.render(); - openModal('debugModal'); +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', mountWhenReady); +} else { + mountWhenReady(); } - -function closeDebugModal() { - closeModal('debugModal'); -} - -function switchDebugTab(tab) { - document.querySelectorAll('.debug-tab').forEach(t => t.classList.remove('active')); - document.querySelectorAll('.debug-tab-content').forEach(c => c.style.display = 'none'); - document.querySelector(`.debug-tab[data-tab="${tab}"]`)?.classList.add('active'); - const panel = document.getElementById(`debug${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`); - if (panel) panel.style.display = 'flex'; - DebugLog.render(); -} - -async function clearDebugLog() { - if (await showConfirm('Clear all debug logs?')) { - DebugLog.clear(); - DebugLog.render(); - } -} - -function copyDebugLog() { - const text = DebugLog.exportText(); - navigator.clipboard.writeText(text) - .then(() => { DebugLog.log('DEBUG', '📋 Debug log copied to clipboard'); }) - .catch(() => { DebugLog.log('ERROR', 'Copy to clipboard failed'); }); -} - -function exportDebugLog() { - const text = DebugLog.exportText(); - const blob = new Blob([text], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `switchboard-debug-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`; - a.click(); - URL.revokeObjectURL(url); -} - -function runDebugDiagnostics() { - switchDebugTab('console'); - DebugLog.runDiagnostics(); -} - -async function purgeCache() { - if (!await showConfirm( - 'Purge all cached files and reload?\n\n' + - 'This clears the Service Worker cache and forces a fresh download of all assets. ' + - 'Useful when the UI feels stale after a deploy.', - { danger: true } - )) return; - - DebugLog.log('DIAG', '🧹 Purging Service Worker caches...'); - - try { - // Delete all SW caches - const keys = await caches.keys(); - const deleted = await Promise.all(keys.map(k => caches.delete(k))); - DebugLog.log('DIAG', ` Deleted ${deleted.filter(Boolean).length} cache(s): ${keys.join(', ') || '(none)'}`); - - // Unregister all service workers - if ('serviceWorker' in navigator) { - const registrations = await navigator.serviceWorker.getRegistrations(); - for (const reg of registrations) { - await reg.unregister(); - DebugLog.log('DIAG', ` Unregistered SW: ${reg.scope}`); - } - } - - DebugLog.log('DIAG', ' ✅ Cache purged — reloading...'); - - // Brief delay so the user sees the log, then hard reload - setTimeout(() => { - location.reload(); - }, 500); - } catch (e) { - DebugLog.log('DIAG', ` ❌ Purge failed: ${e.message}`); - if (typeof showToast === 'function') showToast('Cache purge failed: ' + e.message, 'error'); - } -} - -// Initialize ASAP — before app.js init() so we capture everything -DebugLog.init(); - -// ── Exports (v0.37.14: window.* replaces sb.register) ── -window.DebugLog = DebugLog; -window.openDebugModal = openDebugModal; -window.closeModal = closeModal; -window.openModal = openModal; -window.showConfirm = showConfirm; -window.switchDebugTab = switchDebugTab; -window.clearDebugLog = clearDebugLog; -window.copyDebugLog = copyDebugLog; -window.exportDebugLog = exportDebugLog; -window.runDebugDiagnostics = runDebugDiagnostics; -window.purgeCache = purgeCache; diff --git a/src/js/repl.js b/src/js/repl.js deleted file mode 100644 index 8ce5945..0000000 --- a/src/js/repl.js +++ /dev/null @@ -1,527 +0,0 @@ -// ========================================== -// Chat Switchboard – REPL Console -// ========================================== -// Fourth tab in the debug modal (Ctrl+Shift+L). -// Evaluates JavaScript with top-level await support -// via AsyncFunction. Injects app globals for introspection. -// -// Features: -// - AsyncFunction wrapper (top-level await) -// - Injected globals: sw, DebugLog (v0.37.14: old globals removed) -// - Pretty-print results (collapsible JSON, red errors with stack) -// - Command history: ↑/↓ with sessionStorage -// - Tab-completion on live object graphs -// - Event label hints on Events.on(' / Events.emit(' -// - Multi-line: Shift+Enter for newline, Enter to run -// - Admin-gated OR ?debug=1 URL param -// ========================================== -// -// Exports: window.REPL - - -const REPL = { - - // ── State ──────────────────────────────── - _history: [], - _historyIndex: -1, - _maxHistory: 100, - _storageKey: 'cs::repl::history', - _initialized: false, - - // ── Initialization ─────────────────────── - - init() { - if (this._initialized) return; - this._initialized = true; - - // Restore history from sessionStorage - try { - const saved = sessionStorage.getItem(this._storageKey); - if (saved) this._history = JSON.parse(saved); - } catch { /* ignore */ } - - const input = document.getElementById('replInput'); - if (!input) return; - - input.addEventListener('keydown', (e) => this._onKeyDown(e)); - input.addEventListener('input', () => this._autoResize(input)); - - // Gate: hide REPL tab if not admin and no ?debug=1 - this._applyGate(); - }, - - /** - * Hide the REPL tab unless user is admin or ?debug=1 is set. - */ - _applyGate() { - const tab = document.querySelector('.debug-tab[data-tab="repl"]'); - if (!tab) return; - - const isAdmin = window.sw?.auth?.user?.role === 'admin'; - const debugParam = new URLSearchParams(window.location.search).has('debug'); - - if (!isAdmin && !debugParam) { - tab.style.display = 'none'; - } else { - tab.style.display = ''; - } - }, - - // ── Execution ──────────────────────────── - - /** - * Evaluate an expression with top-level await support. - * Injects app globals as local variables. - */ - async run(code) { - if (!code.trim()) return; - - // Add to history - this._history.push(code); - if (this._history.length > this._maxHistory) this._history.shift(); - this._historyIndex = -1; - this._saveHistory(); - - // Render input - this._appendInput(code); - - const startTime = performance.now(); - - try { - // Build an AsyncFunction with injected globals. - // The last expression is auto-returned if no explicit return. - const wrappedCode = this._wrapCode(code); - - // Injected globals accessible inside the REPL - const globals = this._getGlobals(); - const argNames = Object.keys(globals); - const argValues = Object.values(globals); - - // eslint-disable-next-line no-new-func - const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; - const fn = new AsyncFunction(...argNames, wrappedCode); - - const result = await fn(...argValues); - const elapsed = (performance.now() - startTime).toFixed(1); - - if (result !== undefined) { - this._appendResult(result, elapsed); - } else { - this._appendInfo(`(undefined) [${elapsed}ms]`); - } - } catch (err) { - this._appendError(err); - } - - this._scrollToBottom(); - }, - - /** - * Wrap code for AsyncFunction evaluation. - * If the code is a single expression (no semicolons, no let/const/var/return), - * auto-return it. Otherwise, execute as-is. - */ - _wrapCode(code) { - const trimmed = code.trim(); - - // Multi-statement: check for declarations or multiple statements - if (/^(let |const |var |return |if |for |while |switch |try |class |function )/.test(trimmed)) { - return trimmed; - } - - // Single expression — auto-return - // Handle edge case: object literals starting with { need wrapping - if (trimmed.startsWith('{') && !trimmed.startsWith('{(')) { - return `return (${trimmed})`; - } - - return `return (${trimmed})`; - }, - - /** - * Get the set of globals injected into REPL scope. - */ - _getGlobals() { - const globals = {}; - - // Preact SDK (v0.37.14: replaces old API/Events/Extensions globals) - if (window.sw) globals.sw = window.sw; - if (typeof DebugLog !== 'undefined') globals.DebugLog = DebugLog; - - // Convenience aliases - globals.$ = (sel) => document.querySelector(sel); - globals.$$ = (sel) => [...document.querySelectorAll(sel)]; - globals.sleep = (ms) => new Promise(r => setTimeout(r, ms)); - - return globals; - }, - - // ── Key Handling ───────────────────────── - - _onKeyDown(e) { - const input = e.target; - - // Enter (without Shift) → execute - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - this.run(input.value); - input.value = ''; - this._autoResize(input); - return; - } - - // ↑ / ↓ → history navigation (only when cursor is on first/last line) - if (e.key === 'ArrowUp' && this._isCursorOnFirstLine(input)) { - e.preventDefault(); - this._navigateHistory(-1, input); - return; - } - if (e.key === 'ArrowDown' && this._isCursorOnLastLine(input)) { - e.preventDefault(); - this._navigateHistory(1, input); - return; - } - - // Tab → completion - if (e.key === 'Tab') { - e.preventDefault(); - this._tabComplete(input); - return; - } - }, - - _isCursorOnFirstLine(input) { - return input.value.substring(0, input.selectionStart).indexOf('\n') === -1; - }, - - _isCursorOnLastLine(input) { - return input.value.substring(input.selectionStart).indexOf('\n') === -1; - }, - - _autoResize(input) { - input.style.height = 'auto'; - input.style.height = Math.min(input.scrollHeight, 120) + 'px'; - }, - - // ── History ────────────────────────────── - - _navigateHistory(direction, input) { - if (this._history.length === 0) return; - - if (this._historyIndex === -1) { - // Starting navigation — save current input - this._historyScratch = input.value; - this._historyIndex = this._history.length; - } - - this._historyIndex += direction; - - if (this._historyIndex < 0) this._historyIndex = 0; - if (this._historyIndex >= this._history.length) { - // Past end → restore scratch - this._historyIndex = -1; - input.value = this._historyScratch || ''; - } else { - input.value = this._history[this._historyIndex]; - } - - this._autoResize(input); - // Move cursor to end - input.selectionStart = input.selectionEnd = input.value.length; - }, - - _saveHistory() { - try { - sessionStorage.setItem(this._storageKey, JSON.stringify(this._history)); - } catch { /* quota exceeded — ignore */ } - }, - - // ── Tab Completion ────────────────────── - - _tabComplete(input) { - const cursor = input.selectionStart; - const text = input.value.substring(0, cursor); - - // Object property completion: obj.prop or obj.prop.sub - const propMatch = text.match(/([\w$.]+)\.([\w]*)$/); - if (propMatch) { - this._completeProperty(input, cursor, propMatch[1], propMatch[2]); - return; - } - - // Top-level global completion - const wordMatch = text.match(/([\w$]+)$/); - if (wordMatch) { - this._completeGlobal(input, cursor, wordMatch[1]); - } - }, - - _completeProperty(input, cursor, objExpr, partial) { - try { - // Resolve the object in global scope - const globals = this._getGlobals(); - // eslint-disable-next-line no-new-func - const obj = new Function(...Object.keys(globals), `return ${objExpr}`)(...Object.values(globals)); - if (obj == null) return; - - const keys = []; - // Own + prototype properties - for (let o = obj; o && o !== Object.prototype; o = Object.getPrototypeOf(o)) { - for (const k of Object.getOwnPropertyNames(o)) { - if (k.startsWith(partial) && !k.startsWith('_')) keys.push(k); - } - } - const unique = [...new Set(keys)].sort(); - - if (unique.length === 0) return; - if (unique.length === 1) { - this._insertCompletion(input, cursor, partial, unique[0]); - } else { - this._appendInfo('Completions: ' + unique.slice(0, 30).join(', ') + (unique.length > 30 ? ' …' : '')); - const common = this._commonPrefix(unique); - if (common.length > partial.length) { - this._insertCompletion(input, cursor, partial, common); - } - } - } catch { /* can't resolve — ignore */ } - }, - - _completeGlobal(input, cursor, partial) { - const globals = Object.keys(this._getGlobals()); - // Add common window globals - const candidates = [...globals, 'document', 'window', 'console', 'fetch', 'JSON', 'Math', - 'Array', 'Object', 'String', 'Number', 'Boolean', 'Promise', 'Map', 'Set']; - const matches = [...new Set(candidates)].filter(c => c.startsWith(partial)).sort(); - - if (matches.length === 0) return; - if (matches.length === 1) { - this._insertCompletion(input, cursor, partial, matches[0]); - } else { - this._appendInfo('Completions: ' + matches.join(', ')); - const common = this._commonPrefix(matches); - if (common.length > partial.length) { - this._insertCompletion(input, cursor, partial, common); - } - } - }, - - _insertCompletion(input, cursor, partial, completion) { - const before = input.value.substring(0, cursor - partial.length); - const after = input.value.substring(cursor); - input.value = before + completion + after; - const newPos = before.length + completion.length; - input.selectionStart = input.selectionEnd = newPos; - }, - - _commonPrefix(strs) { - if (strs.length === 0) return ''; - let prefix = strs[0]; - for (let i = 1; i < strs.length; i++) { - while (!strs[i].startsWith(prefix)) { - prefix = prefix.slice(0, -1); - } - } - return prefix; - }, - - // ── Output Rendering ──────────────────── - - _appendInput(code) { - const output = document.getElementById('replOutput'); - if (!output) return; - - const div = document.createElement('div'); - div.className = 'repl-entry repl-entry-input'; - - const pre = document.createElement('pre'); - pre.textContent = '> ' + code; - div.appendChild(pre); - output.appendChild(div); - }, - - _appendResult(value, elapsed) { - const output = document.getElementById('replOutput'); - if (!output) return; - - const div = document.createElement('div'); - div.className = 'repl-entry repl-entry-result'; - - const formatted = this._formatValue(value); - div.appendChild(formatted); - - if (elapsed) { - const time = document.createElement('span'); - time.className = 'repl-elapsed'; - time.textContent = `[${elapsed}ms]`; - div.appendChild(time); - } - - output.appendChild(div); - }, - - _appendError(err) { - const output = document.getElementById('replOutput'); - if (!output) return; - - const div = document.createElement('div'); - div.className = 'repl-entry repl-entry-error'; - - const msg = document.createElement('pre'); - msg.className = 'repl-error-msg'; - msg.textContent = err.message || String(err); - div.appendChild(msg); - - if (err.stack) { - const stack = document.createElement('pre'); - stack.className = 'repl-error-stack'; - stack.textContent = err.stack.split('\n').slice(1, 5).join('\n'); - stack.style.display = 'none'; - - const toggle = document.createElement('button'); - toggle.className = 'repl-stack-toggle'; - toggle.textContent = '▸ stack'; - toggle.onclick = () => { - const shown = stack.style.display !== 'none'; - stack.style.display = shown ? 'none' : ''; - toggle.textContent = shown ? '▸ stack' : '▾ stack'; - }; - - div.appendChild(toggle); - div.appendChild(stack); - } - - output.appendChild(div); - }, - - _appendInfo(text) { - const output = document.getElementById('replOutput'); - if (!output) return; - - const div = document.createElement('div'); - div.className = 'repl-entry repl-entry-info'; - div.textContent = text; - output.appendChild(div); - }, - - /** - * Format a JS value for display. - * Objects/arrays get collapsible JSON; primitives get plain text. - */ - _formatValue(value) { - if (value === null) return this._textEl('null', 'repl-null'); - if (value === undefined) return this._textEl('undefined', 'repl-undefined'); - - const type = typeof value; - - if (type === 'string') return this._textEl(JSON.stringify(value), 'repl-string'); - if (type === 'number' || type === 'bigint') return this._textEl(String(value), 'repl-number'); - if (type === 'boolean') return this._textEl(String(value), 'repl-boolean'); - if (type === 'function') return this._textEl(`[Function: ${value.name || 'anonymous'}]`, 'repl-function'); - if (type === 'symbol') return this._textEl(String(value), 'repl-symbol'); - - if (value instanceof HTMLElement) { - return this._textEl(`<${value.tagName.toLowerCase()}${value.id ? '#' + value.id : ''}${value.className ? '.' + value.className.split(' ').join('.') : ''}>`, 'repl-dom'); - } - - if (value instanceof Error) { - return this._textEl(`${value.constructor.name}: ${value.message}`, 'repl-error-msg'); - } - - // Objects and arrays → collapsible JSON - try { - const json = JSON.stringify(value, null, 2); - if (json.length < 200) { - return this._textEl(json, 'repl-json'); - } - return this._collapsibleJson(json, value); - } catch { - return this._textEl(String(value), 'repl-object'); - } - }, - - _textEl(text, className) { - const pre = document.createElement('pre'); - pre.className = className || ''; - pre.textContent = text; - return pre; - }, - - _collapsibleJson(json, value) { - const container = document.createElement('div'); - container.className = 'repl-collapsible'; - - const summary = Array.isArray(value) - ? `Array(${value.length})` - : `Object {${Object.keys(value).slice(0, 3).join(', ')}${Object.keys(value).length > 3 ? ', …' : ''}}`; - - const toggle = document.createElement('button'); - toggle.className = 'repl-json-toggle'; - toggle.textContent = '▸ ' + summary; - - const pre = document.createElement('pre'); - pre.className = 'repl-json'; - pre.textContent = json; - pre.style.display = 'none'; - - toggle.onclick = () => { - const shown = pre.style.display !== 'none'; - pre.style.display = shown ? 'none' : ''; - toggle.textContent = (shown ? '▸ ' : '▾ ') + summary; - }; - - container.appendChild(toggle); - container.appendChild(pre); - return container; - }, - - _scrollToBottom() { - const output = document.getElementById('replOutput'); - if (output) output.scrollTop = output.scrollHeight; - }, - - // ── Toolbar Actions ────────────────────── - - clear() { - const output = document.getElementById('replOutput'); - if (output) output.innerHTML = ''; - }, - - copyOutput() { - const output = document.getElementById('replOutput'); - if (!output) return; - const text = output.innerText; - navigator.clipboard.writeText(text) - .then(() => { if (typeof showToast === 'function') showToast('📋 REPL output copied', 'success'); }) - .catch(() => {}); - }, - - showHelp() { - const globals = Object.keys(this._getGlobals()); - const help = [ - '╭─ REPL Help ─────────────────────────────╮', - '│ Enter Run expression │', - '│ Shift+Enter New line │', - '│ ↑ / ↓ Command history │', - '│ Tab Auto-complete │', - '╰──────────────────────────────────────────╯', - '', - 'Injected globals: ' + globals.join(', '), - '', - 'Top-level await is supported:', - ' const r = await sw.api.get("/api/v1/profile")', - '', - 'Shortcuts:', - ' $(sel) → document.querySelector(sel)', - ' $$(sel) → document.querySelectorAll(sel)', - ' sleep(ms) → await sleep(1000)', - ]; - help.forEach(line => this._appendInfo(line)); - this._scrollToBottom(); - }, -}; - -// Initialized from app.js startApp() after auth is confirmed, -// ensuring auth is available for admin gate check. - -// ── Exports (v0.37.14: window.* replaces sb.ns) ── -window.REPL = REPL; diff --git a/src/js/sw/components/chat-pane/code-block.js b/src/js/sw/components/chat-pane/code-block.js index 3bb19f6..1e8d2ab 100644 --- a/src/js/sw/components/chat-pane/code-block.js +++ b/src/js/sw/components/chat-pane/code-block.js @@ -112,6 +112,26 @@ export function extractCodeBlocks(htmlStr) { return segments; } +/** + * Map language tag to file extension. + * @param {string} lang + * @returns {string} + */ +export function guessExtension(lang) { + const map = { + javascript: 'js', typescript: 'ts', python: 'py', ruby: 'rb', + go: 'go', rust: 'rs', java: 'java', kotlin: 'kt', swift: 'swift', + c: 'c', cpp: 'cpp', csharp: 'cs', php: 'php', bash: 'sh', + shell: 'sh', zsh: 'sh', sql: 'sql', html: 'html', css: 'css', + json: 'json', yaml: 'yaml', yml: 'yml', xml: 'xml', toml: 'toml', + markdown: 'md', md: 'md', jsx: 'jsx', tsx: 'tsx', vue: 'vue', + svelte: 'svelte', scss: 'scss', less: 'less', lua: 'lua', + perl: 'pl', r: 'r', dart: 'dart', elixir: 'ex', erlang: 'erl', + haskell: 'hs', scala: 'scala', zig: 'zig', nim: 'nim', + }; + return map[(lang || '').toLowerCase()] || 'txt'; +} + function _unescapeHtml(text) { return text .replace(/&/g, '&') diff --git a/src/js/sw/components/chat-pane/message-actions.js b/src/js/sw/components/chat-pane/message-actions.js index 8b63a2f..498dd95 100644 --- a/src/js/sw/components/chat-pane/message-actions.js +++ b/src/js/sw/components/chat-pane/message-actions.js @@ -38,15 +38,25 @@ const DELETE_SVG = html` `; +const SAVE_SVG = html` + + + + + `; + /** * @param {{ * role: string, * content: string, * onRegenerate?: () => void, * onDelete?: () => void, + * onSave?: () => void, + * hasCodeBlocks?: boolean, * }} props */ -export function MessageActions({ role, content, onRegenerate, onDelete }) { +export function MessageActions({ role, content, onRegenerate, onDelete, onSave, hasCodeBlocks }) { const [copied, setCopied] = useState(false); const _copy = useCallback(async () => { @@ -80,6 +90,13 @@ export function MessageActions({ role, content, onRegenerate, onDelete }) { aria-label="Copy message text"> ${copied ? CHECK_SVG : COPY_SVG} + ${role === 'assistant' && hasCodeBlocks && onSave && html` + `} ${onDelete && html`
`} `; } diff --git a/src/js/sw/components/chat-pane/model-selector.js b/src/js/sw/components/chat-pane/model-selector.js index eb01433..0adb66c 100644 --- a/src/js/sw/components/chat-pane/model-selector.js +++ b/src/js/sw/components/chat-pane/model-selector.js @@ -10,11 +10,14 @@ const { useState, useEffect } = window.hooks; const html = window.html; +const HEALTH_DOTS = { healthy: '\u{1F7E2}', degraded: '\u{1F7E1}', inactive: '\u{1F534}' }; + /** * @param {{ value: string, onChange: (id: string) => void }} props */ export function ModelSelector({ value, onChange }) { const [models, setModels] = useState([]); + const [health, setHealth] = useState({}); useEffect(() => { if (!window.sw?.api?.models?.enabled) return; @@ -23,13 +26,25 @@ export function ModelSelector({ value, onChange }) { if (cancelled) return; const list = (resp.data || []).filter(m => !m.hidden); setModels(list); - // Auto-select first model if no value set if (!value && list.length && onChange) { const first = list[0]; const id = first.isPersona ? (first.personaId || first.id) : first.id; onChange(id); } }).catch(() => {}); + + // Fetch provider health for admin users (graceful degrade for non-admin) + if (window.sw?.api?.admin?.providers?.health) { + window.sw.api.admin.providers.health().then(resp => { + if (cancelled) return; + const map = {}; + for (const p of (resp.data || resp || [])) { + if (p.provider_name) map[p.provider_name] = p.status; + } + setHealth(map); + }).catch(() => {}); + } + return () => { cancelled = true; }; }, []); @@ -47,7 +62,10 @@ export function ModelSelector({ value, onChange }) { `} ${models.map(m => { const id = m.isPersona ? (m.personaId || m.id) : m.id; - const label = (m.isPersona ? '\ud83c\udfad ' : '') + (m.name || m.id); + const dot = !m.isPersona && m.provider_name && health[m.provider_name] + ? (HEALTH_DOTS[health[m.provider_name]] || '') + ' ' + : ''; + const label = (m.isPersona ? '\ud83c\udfad ' : dot) + (m.name || m.id); return html``; })} `; diff --git a/src/js/sw/components/debug/badge.js b/src/js/sw/components/debug/badge.js new file mode 100644 index 0000000..d6ca88e --- /dev/null +++ b/src/js/sw/components/debug/badge.js @@ -0,0 +1,37 @@ +// ========================================== +// Debug Modal — Badge Component +// ========================================== +// Bug badge that shows error count. Subscribes to engine +// for reactive updates. +// +// v0.37.18: Preact rebuild from debug.js _updateBadge(). + +const html = window.html; +const { useEffect } = window.hooks; + +/** + * Updates the sidebar .avatar-bug element with current error count. + * Not a visible Preact component — just a side-effect hook. + * + * @param {{ engine: object }} props + */ +export function DebugBadge({ engine }) { + useEffect(() => { + function update() { + const bug = document.querySelector('.avatar-bug'); + if (!bug) return; + if (engine.errorCount > 0) { + bug.title = `${engine.errorCount} error${engine.errorCount > 1 ? 's' : ''} \u2014 click for Debug Log`; + bug.classList.add('has-errors'); + } else { + bug.title = 'Debug available'; + bug.classList.remove('has-errors'); + } + } + + update(); // initial + return engine.subscribe(update); + }, [engine]); + + return null; // invisible — side-effect only +} diff --git a/src/js/sw/components/debug/console-tab.js b/src/js/sw/components/debug/console-tab.js new file mode 100644 index 0000000..1b5bd8d --- /dev/null +++ b/src/js/sw/components/debug/console-tab.js @@ -0,0 +1,73 @@ +// ========================================== +// Debug Modal — Console Tab +// ========================================== +// Filterable console log display with type coloring, +// elapsed timestamps, and auto-scroll. +// +// v0.37.18: Preact rebuild from debug.js _renderConsoleTab(). + +const html = window.html; +const { useState, useMemo, useRef, useEffect } = window.hooks; + +const TYPE_COLORS = { + 'ERROR': '#e74c3c', 'EXCEPTION': '#e74c3c', 'REJECTION': '#e74c3c', + 'NET:ERR': '#e74c3c', 'NET:PROXY': '#ff6b35', + 'WARN': '#f39c12', 'WARNING': '#f39c12', + 'LOG': '#95a5a6', 'INFO': '#3498db', 'DEBUG': '#7f8c8d', + 'NET': '#2ecc71', + 'DIAG': '#9b59b6' +}; + +const ERROR_TYPES = new Set(['ERROR', 'EXCEPTION', 'REJECTION', 'NET:ERR', 'NET:PROXY', 'WARN']); + +function _esc(s) { + if (!s) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>'); +} + +/** + * @param {{ entries: Array }} props + */ +export function ConsoleTab({ entries }) { + const [errorsOnly, setErrorsOnly] = useState(false); + const [autoScroll, setAutoScroll] = useState(true); + const contentRef = useRef(null); + + const filtered = useMemo(() => { + if (!errorsOnly) return entries; + return entries.filter(e => ERROR_TYPES.has(e.type)); + }, [entries, errorsOnly]); + + useEffect(() => { + if (autoScroll && contentRef.current) { + contentRef.current.scrollTop = contentRef.current.scrollHeight; + } + }, [filtered, autoScroll]); + + return html` +
+
+ + + ${filtered.length} +
+
+ ${filtered.length === 0 + ? html`
No log entries yet
` + : filtered.map((entry, i) => html` +
+ ${entry.ts.slice(11, 23)} +${(entry.elapsed / 1000).toFixed(1)}s${' '} + [${_esc(entry.type)}]${' '} + ${_esc(entry.message)} +
`)} +
+
`; +} diff --git a/src/js/sw/components/debug/engine.js b/src/js/sw/components/debug/engine.js new file mode 100644 index 0000000..91abf44 --- /dev/null +++ b/src/js/sw/components/debug/engine.js @@ -0,0 +1,426 @@ +// ========================================== +// Debug Engine — Preact-compatible data layer +// ========================================== +// Console interceptor, fetch logger, error tracker, diagnostics. +// Singleton — init() must run before SDK boot to capture early errors. +// UI-agnostic: Preact components subscribe via .subscribe(). +// +// v0.37.18: Extracted from debug.js (v0.37.14). +// +// Exports: debugEngine (singleton) + +export const debugEngine = { + entries: [], + maxEntries: 500, + networkLog: [], + maxNetwork: 200, + errorCount: 0, + + _origConsole: {}, + _origFetch: null, + _initTime: Date.now(), + _subscribers: new Set(), + + // ── Bootstrap ──────────────────────────── + + init() { + this._interceptConsole(); + this._interceptFetch(); + this._interceptErrors(); + this._registerShortcut(); + this.log('DEBUG', 'Debug engine initialized'); + this.log('DEBUG', `Page: ${location.href}`); + this.log('DEBUG', `UA: ${navigator.userAgent.slice(0, 120)}`); + }, + + // ── Subscription (reactive updates) ───── + + subscribe(fn) { + this._subscribers.add(fn); + return () => this._subscribers.delete(fn); + }, + + _notify() { + for (const fn of this._subscribers) { + try { fn(); } catch (_) { /* subscriber error */ } + } + }, + + // ── Console Interception ──────────────── + + _interceptConsole() { + ['log', 'warn', 'error', 'info', 'debug'].forEach(method => { + this._origConsole[method] = console[method].bind(console); + console[method] = (...args) => { + const type = method.toUpperCase(); + const message = args.map(a => this._serialize(a)).join(' '); + this._addEntry(type, message); + this._origConsole[method](...args); + }; + }); + }, + + // ── Fetch Interception ────────────────── + + _interceptFetch() { + this._origFetch = window.fetch.bind(window); + const self = this; + + window.fetch = async function(input, init) { + const url = typeof input === 'string' ? input : input?.url || String(input); + const method = init?.method || 'GET'; + const entry = { + id: Date.now(), + ts: new Date().toISOString(), + method, + url: url.slice(0, 300), + status: null, + statusText: '', + contentType: null, + duration: null, + error: null, + responsePreview: null, + requestBodyPreview: null, + proxyDetected: false + }; + + // Capture request body preview (redact keys) + if (init?.body) { + try { + const bodyStr = typeof init.body === 'string' ? init.body : ''; + const parsed = bodyStr ? JSON.parse(bodyStr) : {}; + const safe = { ...parsed }; + ['password', 'api_key', 'apiKey', 'refresh_token', 'access_token'].forEach(k => { + if (safe[k]) safe[k] = '***'; + }); + entry.requestBodyPreview = JSON.stringify(safe).slice(0, 300); + } catch (_) { + entry.requestBodyPreview = '(non-JSON body)'; + } + } + + self.networkLog.push(entry); + if (self.networkLog.length > self.maxNetwork) { + self.networkLog.shift(); + } + + const start = performance.now(); + try { + const resp = await self._origFetch(input, init); + entry.status = resp.status; + entry.statusText = resp.statusText; + entry.contentType = resp.headers.get('content-type') || null; + entry.duration = Math.round(performance.now() - start); + + const isApiCall = url.includes('/api/') || url.includes('/health'); + if (isApiCall && entry.contentType && entry.contentType.includes('text/html')) { + entry.proxyDetected = true; + self._addEntry('NET:PROXY', `${method} ${url.slice(0, 80)} \u2192 PROXY HTML (${entry.contentType}) (${entry.duration}ms)`); + try { + const clone = resp.clone(); + const text = await clone.text(); + entry.responsePreview = text.slice(0, 500); + } catch (_) { /* */ } + } else { + const logType = resp.ok ? 'NET' : 'NET:ERR'; + self._addEntry(logType, `${method} ${url.slice(0, 80)} \u2192 ${resp.status} (${entry.duration}ms)`); + } + + if (!resp.ok && !entry.proxyDetected) { + try { + const clone = resp.clone(); + const text = await clone.text(); + entry.responsePreview = text.slice(0, 500); + } catch (_) { /* can't peek */ } + } + + return resp; + } catch (err) { + entry.duration = Math.round(performance.now() - start); + entry.error = err.message || String(err); + self._addEntry('NET:ERR', `${method} ${url.slice(0, 80)} \u2192 FAILED: ${entry.error} (${entry.duration}ms)`); + throw err; + } + }; + }, + + // ── Global Error Handlers ─────────────── + + _interceptErrors() { + window.addEventListener('error', (e) => { + this._addEntry('EXCEPTION', `${e.message} at ${e.filename || '?'}:${e.lineno || 0}:${e.colno || 0}`); + }); + + window.addEventListener('unhandledrejection', (e) => { + const msg = e.reason?.message || e.reason || 'Unknown rejection'; + this._addEntry('REJECTION', String(msg)); + }); + }, + + // ── Keyboard Shortcut ─────────────────── + + _registerShortcut() { + document.addEventListener('keydown', (e) => { + if (e.ctrlKey && e.shiftKey && e.key === 'L') { + e.preventDefault(); + // Dispatched as custom event; DebugModal listens for it. + window.dispatchEvent(new CustomEvent('debug:toggle')); + } + }); + }, + + // ── Entry Management ──────────────────── + + _addEntry(type, message) { + const entry = { + ts: new Date().toISOString(), + elapsed: Date.now() - this._initTime, + type, + message + }; + this.entries.push(entry); + if (this.entries.length > this.maxEntries) { + this.entries.shift(); + } + + if (type === 'ERROR' || type === 'EXCEPTION' || type === 'REJECTION' || type === 'NET:ERR' || type === 'NET:PROXY') { + this.errorCount++; + } + + this._notify(); + }, + + log(type, message) { + this._addEntry(type, message); + }, + + clear() { + this.entries = []; + this.networkLog = []; + this.errorCount = 0; + this._notify(); + }, + + // ── Serialization ─────────────────────── + + _serialize(arg) { + if (arg === null) return 'null'; + if (arg === undefined) return 'undefined'; + if (arg instanceof Error) return `${arg.name}: ${arg.message}`; + if (typeof arg === 'object') { + try { return JSON.stringify(arg, null, 0); } + catch (_) { return String(arg); } + } + return String(arg); + }, + + // ── State Snapshot ────────────────────── + + getStateSnapshot() { + const snap = { + env: window.__ENV__ || 'unknown', + version: window.__VERSION__ || 'unknown', + basePath: window.__BASE__ || '(root)', + url: location.href, + }; + + if (window.sw) { + snap.sdk = { + user: window.sw.auth?.user ? { + username: window.sw.auth.user.username, + role: window.sw.auth.user.role, + display_name: window.sw.auth.user.display_name + } : null, + isAuthed: !!window.sw.auth?.token, + wsConnected: window.sw.events?.connected ?? false, + theme: window.sw.theme?.resolved ?? 'unknown', + }; + } + + try { + snap.storageKeys = Object.keys(localStorage).filter(k => + k.startsWith('chatSwitchboard') || k.startsWith('switchboard') || k.startsWith('sb_') + ); + } catch (_) { + snap.storageKeys = '(error reading)'; + } + + return snap; + }, + + // ── Connection Diagnostics ────────────── + + async runDiagnostics() { + this.log('DIAG', '\u2500\u2500 Starting connection diagnostics \u2500\u2500'); + this.log('DIAG', `Environment: ${window.__ENV__ || 'unknown'} | Version: ${window.__VERSION__ || 'unknown'} | Base: ${window.__BASE__ || '(root)'}`); + + const baseUrl = window.location.origin + (window.__BASE__ || ''); + + // Test 1: Health check + this.log('DIAG', `Test 1: Health check \u2192 ${baseUrl}/api/v1/health`); + try { + const start = performance.now(); + const resp = await this._origFetch(baseUrl + '/api/v1/health', { + signal: AbortSignal.timeout(10000) + }); + const dur = Math.round(performance.now() - start); + const contentType = resp.headers.get('content-type') || '(none)'; + const text = await resp.text(); + this.log('DIAG', ` Status: ${resp.status} (${dur}ms)`); + this.log('DIAG', ` Content-Type: ${contentType}`); + this.log('DIAG', ` Body: ${text.slice(0, 300)}`); + + if (contentType.includes('text/html')) { + this.log('DIAG', ' \u26a0\ufe0f Got HTML instead of JSON \u2014 likely a PROXY interception page'); + } + if (!resp.ok) { + this.log('DIAG', ` \u26a0\ufe0f Non-200 status: ${resp.status} ${resp.statusText}`); + } + try { + const json = JSON.parse(text); + this.log('DIAG', ` \u2705 Valid JSON: database=${json.database}, registration=${json.registration_enabled}`); + } catch (_) { + this.log('DIAG', ' \u274c NOT valid JSON \u2014 proxy or wrong endpoint'); + } + } catch (err) { + this.log('DIAG', ` \u274c Fetch failed: ${err.message}`); + if (err.name === 'TimeoutError' || err.name === 'AbortError') { + this.log('DIAG', ' \u26a0\ufe0f Request timed out \u2014 proxy may be blocking'); + } + } + + // Test 2: CORS preflight + this.log('DIAG', 'Test 2: OPTIONS preflight check'); + try { + const resp = await this._origFetch(baseUrl + '/api/v1/health', { + method: 'OPTIONS', + signal: AbortSignal.timeout(5000) + }); + this.log('DIAG', ` Status: ${resp.status}`); + this.log('DIAG', ` CORS headers: ${resp.headers.get('access-control-allow-origin') || '(none)'}`); + } catch (err) { + this.log('DIAG', ` \u274c OPTIONS failed: ${err.message}`); + } + + // Test 3: Token validation + if (window.sw?.auth?.token) { + this.log('DIAG', 'Test 3: Token validation'); + try { + const resp = await this._origFetch(baseUrl + '/api/v1/profile', { + headers: { + 'Authorization': `Bearer ${window.sw.auth.token}`, + 'Content-Type': 'application/json' + }, + signal: AbortSignal.timeout(5000) + }); + this.log('DIAG', ` Profile endpoint: ${resp.status}`); + if (resp.status === 401) { + this.log('DIAG', ' \u26a0\ufe0f Token expired or invalid'); + } + } catch (err) { + this.log('DIAG', ` \u274c Token check failed: ${err.message}`); + } + } else { + this.log('DIAG', 'Test 3: Skipped (no token)'); + } + + // Test 4: WebSocket + this.log('DIAG', `Test 4: SDK WebSocket = ${window.sw?.events?.connected ? 'connected' : 'disconnected'}`); + + // Test 5: Service Worker & Cache + this.log('DIAG', 'Test 5: Service Worker cache'); + try { + if ('serviceWorker' in navigator) { + const reg = await navigator.serviceWorker.getRegistration(); + this.log('DIAG', ` SW registered: ${reg ? 'yes' : 'no'}${reg ? ` (scope: ${reg.scope})` : ''}`); + if (reg?.active) { + this.log('DIAG', ` SW state: ${reg.active.state}`); + } + } else { + this.log('DIAG', ' SW not supported'); + } + const keys = await caches.keys(); + this.log('DIAG', ` Caches (${keys.length}): ${keys.join(', ') || '(none)'}`); + for (const key of keys) { + const cache = await caches.open(key); + const cacheEntries = await cache.keys(); + this.log('DIAG', ` ${key}: ${cacheEntries.length} entries`); + } + } catch (e) { + this.log('DIAG', ` Cache check error: ${e.message}`); + } + + this.log('DIAG', '\u2500\u2500 Diagnostics complete \u2500\u2500'); + }, + + // ── Export ─────────────────────────────── + + exportText() { + let text = `=== Chat Switchboard Debug Export ===\n`; + text += `Exported: ${new Date().toISOString()}\n`; + text += `URL: ${location.href}\n`; + text += `ENV: ${window.__ENV__ || 'unknown'} | VERSION: ${window.__VERSION__ || 'unknown'} | BASE: ${window.__BASE__ || '(root)'}\n`; + text += `UA: ${navigator.userAgent}\n\n`; + + text += `--- STATE ---\n`; + text += JSON.stringify(this.getStateSnapshot(), null, 2) + '\n\n'; + + text += `--- CONSOLE LOG (${this.entries.length}) ---\n`; + for (const e of this.entries) { + text += `[${e.ts}] [${e.type}] ${e.message}\n`; + } + text += '\n'; + + text += `--- NETWORK LOG (${this.networkLog.length}) ---\n`; + for (const r of this.networkLog) { + const proxy = r.proxyDetected ? ' [PROXY]' : ''; + text += `[${r.ts}] ${r.method} ${r.url} \u2192 ${r.status || 'FAILED'}${proxy} ${r.error || ''} (${r.duration || '?'}ms)`; + if (r.contentType) text += ` [${r.contentType}]`; + text += '\n'; + if (r.responsePreview) text += ` Response: ${r.responsePreview.slice(0, 200)}\n`; + } + + return text; + }, + + copyLog() { + const text = this.exportText(); + navigator.clipboard.writeText(text) + .then(() => this.log('DEBUG', 'Debug log copied to clipboard')) + .catch(() => this.log('ERROR', 'Copy to clipboard failed')); + }, + + downloadLog() { + const text = this.exportText(); + const blob = new Blob([text], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `switchboard-debug-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`; + a.click(); + URL.revokeObjectURL(url); + }, + + async purgeCache() { + this.log('DIAG', 'Purging Service Worker caches...'); + + try { + const keys = await caches.keys(); + const deleted = await Promise.all(keys.map(k => caches.delete(k))); + this.log('DIAG', ` Deleted ${deleted.filter(Boolean).length} cache(s): ${keys.join(', ') || '(none)'}`); + + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + for (const reg of registrations) { + await reg.unregister(); + this.log('DIAG', ` Unregistered SW: ${reg.scope}`); + } + } + + this.log('DIAG', ' Cache purged \u2014 reloading...'); + setTimeout(() => location.reload(), 500); + } catch (e) { + this.log('DIAG', ` Purge failed: ${e.message}`); + } + } +}; diff --git a/src/js/sw/components/debug/index.js b/src/js/sw/components/debug/index.js new file mode 100644 index 0000000..4b66509 --- /dev/null +++ b/src/js/sw/components/debug/index.js @@ -0,0 +1,132 @@ +// ========================================== +// Debug Modal — Root Component +// ========================================== +// Preact modal with tabs: Console, Network, State, REPL. +// Global overlay — not a routed surface. +// Mounts via mountDebugModal(el, engine). +// +// v0.37.18: Preact rebuild of debug modal (CR P2-5). + +import { debugEngine } from './engine.js'; +import { ConsoleTab } from './console-tab.js'; +import { NetworkTab } from './network-tab.js'; +import { StateTab } from './state-tab.js'; +import { ReplTab } from './repl-tab.js'; +import { DebugBadge } from './badge.js'; + +const { h, render } = window.preact; +const html = window.html; +const { useState, useEffect, useCallback, useRef } = window.hooks; + +const TABS = ['console', 'network', 'state', 'repl']; + +function DebugModal({ engine }) { + const [open, setOpen] = useState(false); + const [activeTab, setActiveTab] = useState('console'); + const [tick, setTick] = useState(0); + const overlayRef = useRef(null); + + // Subscribe to engine for reactive re-render + useEffect(() => { + return engine.subscribe(() => setTick(t => t + 1)); + }, [engine]); + + // Listen for toggle events (Ctrl+Shift+L, badge click) + useEffect(() => { + const toggle = () => setOpen(prev => !prev); + window.addEventListener('debug:toggle', toggle); + return () => window.removeEventListener('debug:toggle', toggle); + }, []); + + // Expose global open/close for backward compat + useEffect(() => { + window.openDebugModal = () => setOpen(true); + window.closeDebugModal = () => setOpen(false); + }, []); + + // Close on overlay click + const onOverlayClick = useCallback((e) => { + if (e.target === overlayRef.current) setOpen(false); + }, []); + + // Close on Escape + useEffect(() => { + if (!open) return; + const onKey = (e) => { if (e.key === 'Escape') setOpen(false); }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [open]); + + const onClear = useCallback(async () => { + if (window.sw?.confirm) { + const ok = await window.sw.confirm('Clear all debug logs?'); + if (!ok) return; + } + engine.clear(); + }, [engine]); + + const onPurge = useCallback(async () => { + if (window.sw?.confirm) { + const ok = await window.sw.confirm( + 'Purge all cached files and reload?\n\n' + + 'This clears the Service Worker cache and forces a fresh download of all assets.' + ); + if (!ok) return; + } + engine.purgeCache(); + }, [engine]); + + return html` + <${DebugBadge} engine=${engine} /> + ${open && html` + `}`; +} + +function _replVisible() { + const isAdmin = window.sw?.auth?.user?.role === 'admin'; + const debugParam = new URLSearchParams(window.location.search).has('debug'); + return isAdmin || debugParam; +} + +/** + * Mount the debug modal into a target element. + * Call after Preact globals are available. + */ +export function mountDebugModal(el) { + if (!el) return; + render(html`<${DebugModal} engine=${debugEngine} />`, el); +} diff --git a/src/js/sw/components/debug/network-tab.js b/src/js/sw/components/debug/network-tab.js new file mode 100644 index 0000000..1ab0ca5 --- /dev/null +++ b/src/js/sw/components/debug/network-tab.js @@ -0,0 +1,64 @@ +// ========================================== +// Debug Modal — Network Tab +// ========================================== +// Fetch log with expandable request/response details. +// Newest entries first. +// +// v0.37.18: Preact rebuild from debug.js _renderNetworkTab(). + +const html = window.html; +const { useState } = window.hooks; + +function _esc(s) { + if (!s) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>'); +} + +function NetEntry({ req }) { + const [open, setOpen] = useState(false); + + const isErr = req.error || (req.status && req.status >= 400) || req.proxyDetected; + const statusColor = req.proxyDetected ? '#ff6b35' : (isErr ? '#e74c3c' : '#2ecc71'); + const statusText = req.proxyDetected + ? `PROXY (${req.status})` + : req.error + ? `ERR ${req.error}` + : (req.status ? `${req.status} ${req.statusText}` : 'pending'); + + return html` +
+
setOpen(!open)}> + ${open ? '\u25be' : '\u25b8'} + ${req.method}${' '} + ${_esc(req.url)}${' '} + ${statusText} + ${req.duration != null && html`${' '}${req.duration}ms`} +
+ ${open && html` +
+ ${req.contentType && html`
Content-Type: ${_esc(req.contentType)}
`} + ${req.requestBodyPreview && html`
Request: ${_esc(req.requestBodyPreview)}
`} + ${req.responsePreview && html`
Response:
${_esc(req.responsePreview)}
`} +
${req.ts}
+
`} +
`; +} + +/** + * @param {{ networkLog: Array }} props + */ +export function NetworkTab({ networkLog }) { + const reversed = [...networkLog].reverse(); + + return html` +
+
+ ${networkLog.length} +
+
+ ${reversed.length === 0 + ? html`
No network requests logged
` + : reversed.map((req, i) => html`<${NetEntry} key=${req.id || i} req=${req} />`)} +
+
`; +} diff --git a/src/js/sw/components/debug/repl-tab.js b/src/js/sw/components/debug/repl-tab.js new file mode 100644 index 0000000..102ad67 --- /dev/null +++ b/src/js/sw/components/debug/repl-tab.js @@ -0,0 +1,237 @@ +// ========================================== +// Debug Modal — REPL Tab +// ========================================== +// JavaScript REPL with top-level await, tab-completion, +// command history, and collapsible JSON output. +// Admin-gated OR ?debug=1 URL param. +// +// v0.37.18: Preact rebuild from repl.js (v0.37.14). + +const html = window.html; +const { useState, useRef, useEffect, useCallback } = window.hooks; + +function _esc(s) { + if (!s) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>'); +} + +function _getGlobals() { + const globals = {}; + if (window.sw) globals.sw = window.sw; + if (window.DebugLog) globals.DebugLog = window.DebugLog; + globals.$ = (sel) => document.querySelector(sel); + globals.$$ = (sel) => [...document.querySelectorAll(sel)]; + globals.sleep = (ms) => new Promise(r => setTimeout(r, ms)); + return globals; +} + +function _wrapCode(code) { + const trimmed = code.trim(); + if (/^(let |const |var |return |if |for |while |switch |try |class |function )/.test(trimmed)) { + return trimmed; + } + return `return (${trimmed})`; +} + +function _formatValue(value) { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + const type = typeof value; + if (type === 'string') return JSON.stringify(value); + if (type === 'number' || type === 'bigint' || type === 'boolean') return String(value); + if (type === 'function') return `[Function: ${value.name || 'anonymous'}]`; + if (type === 'symbol') return String(value); + if (value instanceof HTMLElement) { + return `<${value.tagName.toLowerCase()}${value.id ? '#' + value.id : ''}${value.className ? '.' + value.className.split(' ').join('.') : ''}>`; + } + if (value instanceof Error) return `${value.constructor.name}: ${value.message}`; + try { return JSON.stringify(value, null, 2); } + catch (_) { return String(value); } +} + +/** + * @param {{ visible: boolean }} props + */ +export function ReplTab({ visible }) { + const [output, setOutput] = useState([]); + const [historyList] = useState(() => { + try { + const saved = sessionStorage.getItem('cs::repl::history'); + return saved ? JSON.parse(saved) : []; + } catch (_) { return []; } + }); + const historyIdx = useRef(-1); + const scratchRef = useRef(''); + const inputRef = useRef(null); + const outputRef = useRef(null); + + // Admin gate + const isAdmin = window.sw?.auth?.user?.role === 'admin'; + const debugParam = new URLSearchParams(window.location.search).has('debug'); + if (!isAdmin && !debugParam) return null; + + const appendEntry = useCallback((type, text) => { + setOutput(prev => [...prev, { type, text }]); + }, []); + + const runCode = useCallback(async (code) => { + if (!code.trim()) return; + + historyList.push(code); + if (historyList.length > 100) historyList.shift(); + historyIdx.current = -1; + try { sessionStorage.setItem('cs::repl::history', JSON.stringify(historyList)); } catch (_) {} + + appendEntry('input', '> ' + code); + + const startTime = performance.now(); + try { + const globals = _getGlobals(); + const argNames = Object.keys(globals); + const argValues = Object.values(globals); + const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; + const fn = new AsyncFunction(...argNames, _wrapCode(code)); + const result = await fn(...argValues); + const elapsed = (performance.now() - startTime).toFixed(1); + + if (result !== undefined) { + appendEntry('result', `${_formatValue(result)} [${elapsed}ms]`); + } else { + appendEntry('info', `(undefined) [${elapsed}ms]`); + } + } catch (err) { + appendEntry('error', err.message || String(err)); + if (err.stack) { + appendEntry('stack', err.stack.split('\n').slice(1, 5).join('\n')); + } + } + }, [historyList, appendEntry]); + + const onKeyDown = useCallback((e) => { + const input = e.target; + + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + runCode(input.value); + input.value = ''; + return; + } + + if (e.key === 'ArrowUp' && input.value.substring(0, input.selectionStart).indexOf('\n') === -1) { + e.preventDefault(); + if (historyIdx.current === -1) { + scratchRef.current = input.value; + historyIdx.current = historyList.length; + } + historyIdx.current = Math.max(0, historyIdx.current - 1); + input.value = historyList[historyIdx.current] || ''; + input.selectionStart = input.selectionEnd = input.value.length; + return; + } + if (e.key === 'ArrowDown' && input.value.substring(input.selectionStart).indexOf('\n') === -1) { + e.preventDefault(); + if (historyIdx.current === -1) return; + historyIdx.current++; + if (historyIdx.current >= historyList.length) { + historyIdx.current = -1; + input.value = scratchRef.current || ''; + } else { + input.value = historyList[historyIdx.current] || ''; + } + input.selectionStart = input.selectionEnd = input.value.length; + return; + } + + if (e.key === 'Tab') { + e.preventDefault(); + _tabComplete(input); + } + }, [runCode, historyList]); + + useEffect(() => { + if (outputRef.current) { + outputRef.current.scrollTop = outputRef.current.scrollHeight; + } + }, [output]); + + if (!visible) return null; + + return html` +
+
+ ${output.map((entry, i) => html` +
+
${_esc(entry.text)}
+
`)} +
+
+ +
+
`; +} + +function _tabComplete(input) { + const cursor = input.selectionStart; + const text = input.value.substring(0, cursor); + + const propMatch = text.match(/([\w$.]+)\.([\w]*)$/); + if (propMatch) { + try { + const globals = _getGlobals(); + const obj = new Function(...Object.keys(globals), `return ${propMatch[1]}`)(...Object.values(globals)); + if (obj == null) return; + + const keys = []; + for (let o = obj; o && o !== Object.prototype; o = Object.getPrototypeOf(o)) { + for (const k of Object.getOwnPropertyNames(o)) { + if (k.startsWith(propMatch[2]) && !k.startsWith('_')) keys.push(k); + } + } + const unique = [...new Set(keys)].sort(); + if (unique.length === 1) { + _insertCompletion(input, cursor, propMatch[2], unique[0]); + } else if (unique.length > 1) { + const common = _commonPrefix(unique); + if (common.length > propMatch[2].length) { + _insertCompletion(input, cursor, propMatch[2], common); + } + } + } catch (_) {} + return; + } + + const wordMatch = text.match(/([\w$]+)$/); + if (wordMatch) { + const globals = Object.keys(_getGlobals()); + const candidates = [...globals, 'document', 'window', 'console', 'fetch', 'JSON', 'Math', + 'Array', 'Object', 'String', 'Number', 'Boolean', 'Promise', 'Map', 'Set']; + const matches = [...new Set(candidates)].filter(c => c.startsWith(wordMatch[1])).sort(); + if (matches.length === 1) { + _insertCompletion(input, cursor, wordMatch[1], matches[0]); + } else if (matches.length > 1) { + const common = _commonPrefix(matches); + if (common.length > wordMatch[1].length) { + _insertCompletion(input, cursor, wordMatch[1], common); + } + } + } +} + +function _insertCompletion(input, cursor, partial, completion) { + const before = input.value.substring(0, cursor - partial.length); + const after = input.value.substring(cursor); + input.value = before + completion + after; + const newPos = before.length + completion.length; + input.selectionStart = input.selectionEnd = newPos; +} + +function _commonPrefix(strs) { + if (strs.length === 0) return ''; + let prefix = strs[0]; + for (let i = 1; i < strs.length; i++) { + while (!strs[i].startsWith(prefix)) prefix = prefix.slice(0, -1); + } + return prefix; +} diff --git a/src/js/sw/components/debug/state-tab.js b/src/js/sw/components/debug/state-tab.js new file mode 100644 index 0000000..956ae12 --- /dev/null +++ b/src/js/sw/components/debug/state-tab.js @@ -0,0 +1,28 @@ +// ========================================== +// Debug Modal — State Tab +// ========================================== +// Displays current application state snapshot as formatted JSON. +// +// v0.37.18: Preact rebuild from debug.js _renderStateTab(). + +const html = window.html; +const { useMemo } = window.hooks; + +function _esc(s) { + if (!s) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>'); +} + +/** + * @param {{ engine: { getStateSnapshot: () => object } }} props + */ +export function StateTab({ engine }) { + const snap = useMemo(() => engine.getStateSnapshot(), [engine.entries.length]); + + return html` +
+
+
${_esc(JSON.stringify(snap, null, 2))}
+
+
`; +} diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js index f23d830..537c3be 100644 --- a/src/js/sw/sdk/api-domains.js +++ b/src/js/sw/sdk/api-domains.js @@ -94,7 +94,7 @@ export function createDomains(restClient) { kbs: (id) => rc.get(`/api/v1/channels/${id}/knowledge-bases`), setKbs: (id, kbIds) => rc.put(`/api/v1/channels/${id}/knowledge-bases`, { kb_ids: kbIds }), // Files - files: (id) => rc.get(`/api/v1/channels/${id}/files`), + files: (id, opts) => rc.get(`/api/v1/channels/${id}/files` + _qs(opts)), uploadFile: (id, file) => rc.upload(`/api/v1/channels/${id}/files`, file), }, @@ -150,7 +150,8 @@ export function createDomains(restClient) { // ── 7. Workspaces ────────────────────── workspaces: { ...crud(rc, '/api/v1/workspaces'), - update: (id, data) => rc.patch(`/api/v1/workspaces/${id}`, data), + update: (id, data) => rc.patch(`/api/v1/workspaces/${id}`, data), + getDefault: () => rc.get('/api/v1/workspaces/default'), files: (id, opts) => rc.get(`/api/v1/workspaces/${id}/files` + _qs(opts)), readFile: (id, path) => rc.get(`/api/v1/workspaces/${id}/files/read` + _qs({ path })), writeFile: (id, path, content) => rc.put(`/api/v1/workspaces/${id}/files/write` + _qs({ path }), content), diff --git a/src/js/sw/shell/user-menu.js b/src/js/sw/shell/user-menu.js index f60095c..7fbf07a 100644 --- a/src/js/sw/shell/user-menu.js +++ b/src/js/sw/shell/user-menu.js @@ -117,8 +117,7 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) { }); break; case 'debug': - const dbg = document.getElementById('debugModal'); - if (dbg) dbg.classList.toggle('active'); + window.dispatchEvent(new CustomEvent('debug:toggle')); break; case 'chat': location.href = BASE + '/'; diff --git a/src/js/sw/surfaces/admin/providers.js b/src/js/sw/surfaces/admin/providers.js index 7ac3e69..5815be5 100644 --- a/src/js/sw/surfaces/admin/providers.js +++ b/src/js/sw/surfaces/admin/providers.js @@ -54,6 +54,16 @@ export default function ProvidersSection() { } catch (e) { sw.toast(e.message, 'error'); } } + async function testProvider(cfg) { + sw.toast(`Testing ${cfg.name}\u2026`, 'info'); + try { + const result = await sw.api.admin.models.fetch(); + sw.toast(`${cfg.name}: connection OK`, 'success'); + } catch (e) { + sw.toast(`${cfg.name}: connection failed \u2014 ${e.message}`, 'error'); + } + } + async function deleteProvider(id) { const ok = await sw.confirm('Delete this provider config?', true); if (!ok) return; @@ -118,6 +128,7 @@ export default function ProvidersSection() {
${c.endpoint || '(default)'}
+ diff --git a/src/js/sw/surfaces/settings/providers.js b/src/js/sw/surfaces/settings/providers.js index 21afc8e..d8ffa31 100644 --- a/src/js/sw/surfaces/settings/providers.js +++ b/src/js/sw/surfaces/settings/providers.js @@ -110,6 +110,17 @@ export function ProvidersSection() { } }, []); + const testConnection = useCallback(async (prov) => { + sw.emit('toast', { message: `Testing ${prov.name}\u2026`, variant: 'info' }); + try { + const result = await sw.api.providers.fetchModels(prov.id); + const total = result?.total || 0; + sw.emit('toast', { message: `${prov.name}: connection OK (${total} model${total !== 1 ? 's' : ''})`, variant: 'success' }); + } catch (e) { + sw.emit('toast', { message: `${prov.name}: connection failed \u2014 ${e.message}`, variant: 'error' }); + } + }, []); + const setField = useCallback((key, val) => { setForm(f => ({ ...f, [key]: val })); }, []); @@ -179,6 +190,9 @@ export function ProvidersSection() { ${esc(p.endpoint)} + diff --git a/src/sw.js b/src/sw.js index 102398c..819058d 100644 --- a/src/sw.js +++ b/src/sw.js @@ -29,15 +29,14 @@ const SHELL_FILES = [ // CSS — Preact (sw-*) './css/sw-primitives.css', './css/sw-shell.css', + './css/sw-debug.css', './css/sw-login.css', './css/sw-chat-pane.css', './css/sw-chat-surface.css', './css/sw-notes-pane.css', './css/sw-notes-surface.css', - // JS — debug tooling (standalone, no old-layer deps) - // v0.37.14: sb.js, events.js, switchboard-sdk.js removed + // JS — debug tooling (v0.37.18: repl.js absorbed into Preact debug components) './js/debug.js', - './js/repl.js', // Vendor './vendor/marked.min.js', './vendor/purify.min.js',