Changeset 0.37.18 (#230)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-24 19:55:14 +00:00
committed by xcaliber
parent 96a4f16bc5
commit 3a4afea7f2
34 changed files with 1534 additions and 1290 deletions

View File

@@ -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 `<div id="debugMount">`.
- `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

View File

@@ -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:**

View File

@@ -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

View File

@@ -49,7 +49,8 @@ v0.9.xv0.28.7 Foundation through Platform Polish ✅
│ v0.37.3 SDK ✅ │
│ v0.37.4 Shell ✅ │
│ v0.37.516 Surfaces ✅ │
│ v0.37.1719 Surfaces
│ v0.37.1718 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"):**

View File

@@ -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)', {

View File

@@ -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()', {

View File

@@ -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);

View File

@@ -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.
//

View File

@@ -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

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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)

View File

@@ -25,6 +25,7 @@
<link rel="stylesheet" href="{{.BasePath}}/css/admin-surfaces.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/extension-surface.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/sw-shell.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/sw-debug.css?v={{.Version}}">
{{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) ───── */}}
<div id="debugModal" class="modal-overlay">
<div class="modal modal-wide">
<div class="modal-header">
<h2>Debug Log</h2>
<button class="modal-close" onclick="closeModal('debugModal')"></button>
</div>
<div class="modal-tabs">
<button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">Console <span id="debugConsoleCount" class="badge">0</span></button>
<button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">Network <span id="debugNetworkCount" class="badge">0</span></button>
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">State</button>
<button class="debug-tab" data-tab="repl" onclick="switchDebugTab('repl')">REPL</button>
</div>
<div class="modal-body" style="padding:0;overflow:hidden;display:flex;flex-direction:column;min-height:0;">
<div id="debugConsoleTab" class="debug-tab-content" style="display:flex;flex-direction:column;flex:1;min-height:0;">
<div style="padding:8px;display:flex;gap:8px;align-items:center;flex-shrink:0;">
<label><input type="checkbox" id="debugFilterErrors"> Errors only</label>
<label><input type="checkbox" id="debugAutoScroll" checked> Auto-scroll</label>
</div>
<div id="debugConsoleContent" class="debug-content" style="font-family:monospace;font-size:12px;padding:8px;flex:1;overflow-y:auto;min-height:0;"></div>
</div>
<div id="debugNetworkTab" class="debug-tab-content" style="display:none;flex:1;min-height:0;overflow-y:auto;">
<div id="debugNetworkContent" class="debug-content" style="font-family:monospace;font-size:12px;padding:8px;"></div>
</div>
<div id="debugStateTab" class="debug-tab-content" style="display:none;flex:1;min-height:0;overflow-y:auto;">
<div id="debugStateContent" class="debug-content" style="font-family:monospace;font-size:12px;padding:8px;white-space:pre-wrap;"></div>
</div>
<div id="debugReplTab" class="debug-tab-content" style="display:none;flex:1;min-height:0;flex-direction:column;">
<div id="replOutput" style="flex:1;overflow-y:auto;font-family:monospace;font-size:12px;padding:8px;min-height:0;"></div>
<div style="border-top:1px solid var(--border);padding:8px;flex-shrink:0;">
<input type="text" id="replInput" placeholder="Type expression…" style="width:100%;font-family:monospace;font-size:12px;">
</div>
</div>
</div>
<div class="modal-footer" style="display:flex;justify-content:space-between;align-items:center;">
<div style="display:flex;gap:8px;">
<button class="btn-danger btn-small" onclick="runDebugDiagnostics()">Diagnostics</button>
<button class="btn-danger btn-small" onclick="purgeCache()">Purge Cache</button>
</div>
<div style="display:flex;gap:8px;">
<button class="btn-secondary btn-small" onclick="clearDebugLog()">Clear</button>
<button class="btn-secondary btn-small" onclick="copyDebugLog()">Copy</button>
<button class="btn-secondary btn-small" onclick="exportDebugLog()">Export</button>
</div>
</div>
</div>
</div>
{{/* ── Debug Modal (Preact, v0.37.18) ───── */}}
<div id="debugMount"></div>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/debug.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/repl.js?v={{.Version}}"></script>
</body>
</html>
{{end}}

View File

@@ -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:

View File

@@ -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 ─────────────────────── */

112
src/css/sw-debug.css Normal file
View File

@@ -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; }
}

View File

@@ -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 = '<div class="debug-empty">No log entries yet</div>';
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 += `<div class="debug-entry">`;
html += `<span class="debug-time">${time} +${elapsed}s</span> `;
html += `<span style="color:${color};font-weight:bold;">[${this._esc(entry.type)}]</span> `;
html += `<span class="debug-msg">${this._esc(entry.message)}</span>`;
html += `</div>`;
}
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 = '<div class="debug-empty">No network requests logged</div>';
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 += `<details class="debug-net-entry">`;
html += `<summary>`;
html += `<span class="debug-net-method">${req.method}</span> `;
html += `<span class="debug-net-url">${this._esc(req.url)}</span> `;
html += `<span style="color:${statusColor}">${statusText}</span>`;
if (req.duration != null) html += ` <span class="debug-time">${req.duration}ms</span>`;
html += `</summary>`;
html += `<div class="debug-net-detail">`;
if (req.contentType) {
html += `<div><strong>Content-Type:</strong> <code>${this._esc(req.contentType)}</code></div>`;
}
if (req.requestBodyPreview) {
html += `<div><strong>Request:</strong> <code>${this._esc(req.requestBodyPreview)}</code></div>`;
}
if (req.responsePreview) {
html += `<div><strong>Response:</strong> <pre class="debug-pre">${this._esc(req.responsePreview)}</pre></div>`;
}
html += `<div class="debug-time">${req.ts}</div>`;
html += `</div></details>`;
}
container.innerHTML = html;
},
_renderStateTab() {
const container = document.getElementById('debugStateContent');
if (!container) return;
const snap = this.getStateSnapshot();
container.innerHTML = `<pre class="debug-pre debug-state-pre">${this._esc(JSON.stringify(snap, null, 2))}</pre>`;
},
_esc(s) {
if (!s) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
},
// ── 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 = `
<div class="confirm-dialog">
<div class="confirm-header">${_escHtml(title)}</div>
<div class="confirm-body">${_escHtml(message)}</div>
<div class="confirm-footer">
<button class="btn-small" data-action="cancel">${_escHtml(caText)}</button>
<button class="btn-small ${danger ? 'btn-danger' : 'btn-primary'}" data-action="ok">${_escHtml(okText)}</button>
</div>
</div>`;
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;

View File

@@ -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;

View File

@@ -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(/&amp;/g, '&')

View File

@@ -38,15 +38,25 @@ const DELETE_SVG = html`
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>`;
const SAVE_SVG = html`
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>`;
/**
* @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}
</button>
${role === 'assistant' && hasCodeBlocks && onSave && html`
<button class="sw-chat-msg__action-btn"
title="Save to workspace"
onClick=${onSave}
aria-label="Save code to workspace">
${SAVE_SVG}
</button>`}
${onDelete && html`
<button class="sw-chat-msg__action-btn sw-chat-msg__action-btn--danger"
title="Delete"

View File

@@ -12,11 +12,11 @@
// onRegenerate=${fn} onDelete=${fn} />`
import { renderMarkdown } from './markdown.js';
import { CodeBlock, extractCodeBlocks } from './code-block.js';
import { CodeBlock, extractCodeBlocks, guessExtension } from './code-block.js';
import { MessageActions } from './message-actions.js';
const html = window.html;
const { useMemo } = window.hooks;
const { useMemo, useCallback } = window.hooks;
/**
* Format a relative time string from an ISO date.
@@ -65,6 +65,33 @@ export function MessageBubble({ role, content, thinking, id, created_at, streami
}, [thinking]);
const timeStr = _relTime(created_at);
const hasCodeBlocks = useMemo(() => segments.some(s => s.type === 'code'), [segments]);
const handleSave = useCallback(async () => {
if (!window.sw?.api?.workspaces?.getDefault) return;
const codeBlocks = segments.filter(s => s.type === 'code');
if (codeBlocks.length === 0) return;
try {
const ws = await window.sw.api.workspaces.getDefault();
const ts = Date.now();
for (let i = 0; i < codeBlocks.length; i++) {
const block = codeBlocks[i];
const ext = guessExtension(block.language);
const filename = codeBlocks.length === 1
? `snippet-${ts}.${ext}`
: `snippet-${ts}-${i + 1}.${ext}`;
await window.sw.api.workspaces.writeFile(ws.id, filename, block.code);
}
if (window.sw?.toast) {
window.sw.toast(`Saved ${codeBlocks.length} file${codeBlocks.length > 1 ? 's' : ''} to My Files`, 'success');
}
} catch (err) {
if (window.sw?.toast) {
window.sw.toast('Failed to save: ' + (err.message || err), 'error');
}
}
}, [segments]);
// While streaming, show thinking open if content hasn't started yet
const thinkingOpen = streaming && thinking && !content;
@@ -93,7 +120,9 @@ export function MessageBubble({ role, content, thinking, id, created_at, streami
role=${role}
content=${content}
onRegenerate=${onRegenerate}
onDelete=${onDelete} />
onDelete=${onDelete}
hasCodeBlocks=${hasCodeBlocks}
onSave=${handleSave} />
</div>`}
</div>`;
}

View File

@@ -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 }) {
<option value="" disabled selected>No models</option>`}
${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`<option key=${id} value=${id}>${label}</option>`;
})}
</select>`;

View File

@@ -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
}

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/**
* @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`
<div class="debug-console-tab">
<div class="debug-toolbar">
<label class="debug-check">
<input type="checkbox" checked=${errorsOnly}
onChange=${e => setErrorsOnly(e.target.checked)} />
Errors only
</label>
<label class="debug-check">
<input type="checkbox" checked=${autoScroll}
onChange=${e => setAutoScroll(e.target.checked)} />
Auto-scroll
</label>
<span class="debug-badge">${filtered.length}</span>
</div>
<div class="debug-content" ref=${contentRef}>
${filtered.length === 0
? html`<div class="debug-empty">No log entries yet</div>`
: filtered.map((entry, i) => html`
<div class="debug-entry" key=${i}>
<span class="debug-time">${entry.ts.slice(11, 23)} +${(entry.elapsed / 1000).toFixed(1)}s</span>${' '}
<span style="color:${TYPE_COLORS[entry.type] || '#95a5a6'};font-weight:bold;">[${_esc(entry.type)}]</span>${' '}
<span class="debug-msg">${_esc(entry.message)}</span>
</div>`)}
</div>
</div>`;
}

View File

@@ -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}`);
}
}
};

View File

@@ -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`
<div class="modal-overlay active" ref=${overlayRef} onClick=${onOverlayClick}>
<div class="modal modal-wide">
<div class="modal-header">
<h2>Debug Log</h2>
<button class="modal-close" onClick=${() => setOpen(false)}>\u2715</button>
</div>
<div class="modal-tabs">
${TABS.map(tab => html`
<button key=${tab}
class="debug-tab${activeTab === tab ? ' active' : ''}${tab === 'repl' && !_replVisible() ? ' debug-tab--hidden' : ''}"
onClick=${() => setActiveTab(tab)}>
${tab.charAt(0).toUpperCase() + tab.slice(1)}
${tab === 'console' && html`${' '}<span class="badge">${engine.entries.length}</span>`}
${tab === 'network' && html`${' '}<span class="badge">${engine.networkLog.length}</span>`}
</button>`)}
</div>
<div class="modal-body debug-modal-body">
${activeTab === 'console' && html`<${ConsoleTab} entries=${engine.entries} />`}
${activeTab === 'network' && html`<${NetworkTab} networkLog=${engine.networkLog} />`}
${activeTab === 'state' && html`<${StateTab} engine=${engine} />`}
${activeTab === 'repl' && html`<${ReplTab} visible=${true} />`}
</div>
<div class="modal-footer debug-modal-footer">
<div class="debug-footer-left">
<button class="btn-danger btn-small" onClick=${() => { setActiveTab('console'); engine.runDiagnostics(); }}>Diagnostics</button>
<button class="btn-danger btn-small" onClick=${onPurge}>Purge Cache</button>
</div>
<div class="debug-footer-right">
<button class="btn-secondary btn-small" onClick=${onClear}>Clear</button>
<button class="btn-secondary btn-small" onClick=${() => engine.copyLog()}>Copy</button>
<button class="btn-secondary btn-small" onClick=${() => engine.downloadLog()}>Export</button>
</div>
</div>
</div>
</div>`}`;
}
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);
}

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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`
<div class="debug-net-entry">
<div class="debug-net-summary" onClick=${() => setOpen(!open)}>
<span class="debug-net-arrow">${open ? '\u25be' : '\u25b8'}</span>
<span class="debug-net-method">${req.method}</span>${' '}
<span class="debug-net-url">${_esc(req.url)}</span>${' '}
<span style="color:${statusColor}">${statusText}</span>
${req.duration != null && html`${' '}<span class="debug-time">${req.duration}ms</span>`}
</div>
${open && html`
<div class="debug-net-detail">
${req.contentType && html`<div><strong>Content-Type:</strong> <code>${_esc(req.contentType)}</code></div>`}
${req.requestBodyPreview && html`<div><strong>Request:</strong> <code>${_esc(req.requestBodyPreview)}</code></div>`}
${req.responsePreview && html`<div><strong>Response:</strong> <pre class="debug-pre">${_esc(req.responsePreview)}</pre></div>`}
<div class="debug-time">${req.ts}</div>
</div>`}
</div>`;
}
/**
* @param {{ networkLog: Array }} props
*/
export function NetworkTab({ networkLog }) {
const reversed = [...networkLog].reverse();
return html`
<div class="debug-network-tab">
<div class="debug-toolbar">
<span class="debug-badge">${networkLog.length}</span>
</div>
<div class="debug-content">
${reversed.length === 0
? html`<div class="debug-empty">No network requests logged</div>`
: reversed.map((req, i) => html`<${NetEntry} key=${req.id || i} req=${req} />`)}
</div>
</div>`;
}

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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`
<div class="debug-repl-tab">
<div class="debug-repl-output" ref=${outputRef}>
${output.map((entry, i) => html`
<div key=${i} class="repl-entry repl-entry-${entry.type}">
<pre>${_esc(entry.text)}</pre>
</div>`)}
</div>
<div class="debug-repl-input">
<input type="text" ref=${inputRef}
placeholder="Type expression\u2026"
onKeyDown=${onKeyDown} />
</div>
</div>`;
}
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;
}

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/**
* @param {{ engine: { getStateSnapshot: () => object } }} props
*/
export function StateTab({ engine }) {
const snap = useMemo(() => engine.getStateSnapshot(), [engine.entries.length]);
return html`
<div class="debug-state-tab">
<div class="debug-content">
<pre class="debug-pre debug-state-pre">${_esc(JSON.stringify(snap, null, 2))}</pre>
</div>
</div>`;
}

View File

@@ -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),

View File

@@ -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 + '/';

View File

@@ -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() {
</div>
<div class="text-muted" style="font-size:12px;margin-bottom:8px;word-break:break-all;">${c.endpoint || '(default)'}</div>
<div style="display:flex;gap:6px;">
<button class="btn-small" onClick=${() => testProvider(c)}>Test</button>
<button class="btn-small" onClick=${() => setEditing(c)}>Edit</button>
<button class="btn-small" onClick=${() => syncModels(c.id)}>Sync Models</button>
<button class="btn-small btn-danger" onClick=${() => deleteProvider(c.id)}>Delete</button>

View File

@@ -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)}
</td>
<td class="admin-actions-cell" style="white-space:nowrap;">
<button class="icon-btn" title="Test connection" onClick=${() => testConnection(p)}>
\u{26A1}
</button>
<button class="icon-btn" title="Sync models" onClick=${() => sync(p)}>
\u{1F504}
</button>

View File

@@ -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',