Changeset 0.37.18 (#230)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
58
CHANGELOG.md
58
CHANGELOG.md
@@ -1,5 +1,63 @@
|
|||||||
# Changelog
|
# 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
|
## [0.37.17.0] — 2026-03-24
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
@@ -454,10 +454,11 @@ Content-Type: multipart/form-data
|
|||||||
|
|
||||||
Field: `file`. Sets `origin: "user_upload"`. Returns the file object.
|
Field: `file`. Sets `origin: "user_upload"`. Returns the file object.
|
||||||
|
|
||||||
**Create (tool output):** Tool-generated files are created internally
|
**Create (tool output):** When `workspace_write` or `workspace_patch`
|
||||||
by the tool execution loop. There is no public REST endpoint for
|
tools succeed during a completion, the handler auto-records a file
|
||||||
tool output creation — the completion handler writes files directly
|
reference with `origin: "tool_output"` linked to the assistant message
|
||||||
via the store layer.
|
(v0.37.18). No public endpoint — created internally by the completion
|
||||||
|
handler via the store layer.
|
||||||
|
|
||||||
**List by channel:**
|
**List by channel:**
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ owning team/project/channel).
|
|||||||
```
|
```
|
||||||
GET /workspaces → { "data": [...] }
|
GET /workspaces → { "data": [...] }
|
||||||
POST /workspaces ← { "name", "owner_type", "owner_id" }
|
POST /workspaces ← { "name", "owner_type", "owner_id" }
|
||||||
|
GET /workspaces/default → workspace object (v0.37.18)
|
||||||
GET /workspaces/:id → workspace object
|
GET /workspaces/:id → workspace object
|
||||||
PATCH /workspaces/:id ← partial update
|
PATCH /workspaces/:id ← partial update
|
||||||
DELETE /workspaces/:id
|
DELETE /workspaces/:id
|
||||||
@@ -25,6 +26,15 @@ DELETE /workspaces/:id
|
|||||||
workspaces plus those owned by the user's teams. All `:id` endpoints
|
workspaces plus those owned by the user's teams. All `:id` endpoints
|
||||||
verify the caller can access the workspace's owner entity.
|
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:
|
Workspace object:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅
|
|||||||
│ v0.37.3 SDK ✅ │
|
│ v0.37.3 SDK ✅ │
|
||||||
│ v0.37.4 Shell ✅ │
|
│ v0.37.4 Shell ✅ │
|
||||||
│ v0.37.5–16 Surfaces ✅ │
|
│ v0.37.5–16 Surfaces ✅ │
|
||||||
│ v0.37.17–19 Surfaces │
|
│ v0.37.17–18 Surfaces ✅ │
|
||||||
|
│ v0.37.19 Tag │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ v0.38.x Workflow │
|
│ v0.38.x Workflow │
|
||||||
│ Product Maturity │
|
│ 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.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.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.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.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.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.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):
|
**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] Archive download: zip bundle of project files
|
||||||
- [x] Storage quota enforcement (`max_bytes` on workspace)
|
- [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.
|
Debug modal Preact rebuild (673-line imperative → component tree, 8 files).
|
||||||
Also: user default workspace, `origin=tool_output` auto-save in completion
|
User default workspace (auto-create "My Files" on first access). tool_output
|
||||||
handler, "Save to workspace" bridge action on chat files/artifacts.
|
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"):**
|
**v0.37.19 — Tag ("UI Complete"):**
|
||||||
|
|
||||||
|
|||||||
@@ -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 ──
|
// ── Participants ──
|
||||||
|
|
||||||
await T.test('channels', 'participants', 'sw.api.channels.participants(id)', {
|
await T.test('channels', 'participants', 'sw.api.channels.participants(id)', {
|
||||||
|
|||||||
@@ -24,6 +24,31 @@
|
|||||||
validate: function (r) { var arr = T.unwrapList(r); T.assert(arr.length >= 0, 'expected list'); }
|
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 ──
|
// ── Create ──
|
||||||
|
|
||||||
await T.test('workspaces', 'crud', 'sw.api.workspaces.create()', {
|
await T.test('workspaces', 'crud', 'sw.api.workspaces.create()', {
|
||||||
|
|||||||
@@ -84,6 +84,17 @@
|
|||||||
console.log('[SDKR:fixtures] ' + msg);
|
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
|
// Check if any configs already exist
|
||||||
try {
|
try {
|
||||||
var configs = await T.raw.get('/admin/configs');
|
var configs = await T.raw.get('/admin/configs');
|
||||||
@@ -103,6 +114,7 @@
|
|||||||
var configData = {
|
var configData = {
|
||||||
name: 'sdkr-fixture-' + selectedProvider,
|
name: 'sdkr-fixture-' + selectedProvider,
|
||||||
provider: selectedProvider,
|
provider: selectedProvider,
|
||||||
|
endpoint: defaultEndpoint,
|
||||||
api_key: apiKey,
|
api_key: apiKey,
|
||||||
scope: 'global'
|
scope: 'global'
|
||||||
};
|
};
|
||||||
@@ -129,6 +141,7 @@
|
|||||||
var configData2 = {
|
var configData2 = {
|
||||||
name: 'sdkr-fixture-' + selectedProvider,
|
name: 'sdkr-fixture-' + selectedProvider,
|
||||||
provider: selectedProvider,
|
provider: selectedProvider,
|
||||||
|
endpoint: defaultEndpoint,
|
||||||
scope: 'global'
|
scope: 'global'
|
||||||
};
|
};
|
||||||
var created2 = await T.raw.post('/admin/configs', configData2);
|
var created2 = await T.raw.post('/admin/configs', configData2);
|
||||||
|
|||||||
@@ -963,6 +963,9 @@ func (h *CompletionHandler) streamCompletion(
|
|||||||
log.Printf("Failed to persist assistant message: %v", err)
|
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
|
// Broadcast assistant message to other channel participants
|
||||||
if h.hub != nil && asstMsgID != "" {
|
if h.hub != nil && asstMsgID != "" {
|
||||||
broadcastAssistantMessage(c.Request.Context(), h.hub, channelID, asstMsgID, result.Content, model, userID, personaID)
|
broadcastAssistantMessage(c.Request.Context(), h.hub, channelID, asstMsgID, result.Content, model, userID, personaID)
|
||||||
@@ -1049,10 +1052,14 @@ func (h *CompletionHandler) syncCompletion(
|
|||||||
finalContent := result.Content
|
finalContent := result.Content
|
||||||
|
|
||||||
// Persist assistant response
|
// 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)
|
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
|
// AI-to-AI chaining
|
||||||
go func() {
|
go func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -1770,6 +1777,31 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
|
|||||||
|
|
||||||
// ── Message Persistence ─────────────────────
|
// ── 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
|
// persistMessage inserts a message into the tree, updates the cursor, and
|
||||||
// returns the new message's ID.
|
// returns the new message's ID.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -271,7 +271,8 @@ func (h *FileHandler) ListByChannel(c *gin.Context) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -42,9 +42,16 @@ const defaultMaxRounds = 10
|
|||||||
|
|
||||||
// LoopResult holds the accumulated output from a completion with tool
|
// LoopResult holds the accumulated output from a completion with tool
|
||||||
// execution. Callers are responsible for persistence.
|
// 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 {
|
type LoopResult struct {
|
||||||
Content string
|
Content string
|
||||||
ToolActivity []map[string]interface{}
|
ToolActivity []map[string]interface{}
|
||||||
|
FileRefs []FileRef // v0.37.18: workspace files created by tools
|
||||||
InputTokens int
|
InputTokens int
|
||||||
OutputTokens int
|
OutputTokens int
|
||||||
CacheCreationTokens 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)
|
// Emit workspace.file.changed for live editor updates (v0.21.5)
|
||||||
emitWorkspaceEvent(lcfg, call, toolResult)
|
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
|
// Collect for persistence
|
||||||
result.ToolActivity = append(result.ToolActivity, map[string]interface{}{
|
result.ToolActivity = append(result.ToolActivity, map[string]interface{}{
|
||||||
"id": tc.ID,
|
"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 ───────────────────────
|
// ── Sink Implementations ───────────────────────
|
||||||
|
|
||||||
// sseSink writes SSE events to a gin.Context writer for interactive streaming.
|
// sseSink writes SSE events to a gin.Context writer for interactive streaming.
|
||||||
|
|||||||
@@ -85,6 +85,55 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, w)
|
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) {
|
func (h *WorkspaceHandler) Get(c *gin.Context) {
|
||||||
w, ok := h.loadAndAuthorize(c)
|
w, ok := h.loadAndAuthorize(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -919,6 +919,7 @@ func main() {
|
|||||||
wsH := handlers.NewWorkspaceHandler(stores, wfs)
|
wsH := handlers.NewWorkspaceHandler(stores, wfs)
|
||||||
protected.POST("/workspaces", wsH.Create)
|
protected.POST("/workspaces", wsH.Create)
|
||||||
protected.GET("/workspaces", wsH.List)
|
protected.GET("/workspaces", wsH.List)
|
||||||
|
protected.GET("/workspaces/default", wsH.GetDefault) // v0.37.18: before :id param
|
||||||
protected.GET("/workspaces/:id", wsH.Get)
|
protected.GET("/workspaces/:id", wsH.Get)
|
||||||
protected.PATCH("/workspaces/:id", wsH.Update)
|
protected.PATCH("/workspaces/:id", wsH.Update)
|
||||||
protected.DELETE("/workspaces/:id", wsH.Delete)
|
protected.DELETE("/workspaces/:id", wsH.Delete)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<link rel="stylesheet" href="{{.BasePath}}/css/admin-surfaces.css?v={{.Version}}">
|
<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/extension-surface.css?v={{.Version}}">
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/sw-shell.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 "chat"}}{{template "css-chat" .}}{{end}}
|
||||||
{{if eq .Surface "notes"}}{{template "css-notes" .}}{{end}}
|
{{if eq .Surface "notes"}}{{template "css-notes" .}}{{end}}
|
||||||
{{if eq .Surface "projects"}}{{template "css-projects" .}}{{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. */}}
|
{{/* v0.37.13: Legacy UserMenu hydration removed — all surfaces use Preact UserMenu. */}}
|
||||||
|
|
||||||
{{/* ── Debug Log Modal (all surfaces) ───── */}}
|
{{/* ── Debug Modal (Preact, v0.37.18) ───── */}}
|
||||||
<div id="debugModal" class="modal-overlay">
|
<div id="debugMount"></div>
|
||||||
<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>
|
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/debug.js?v={{.Version}}"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -4441,6 +4441,23 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/MessageResponse'
|
$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}:
|
/api/v1/workspaces/{id}:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -303,30 +303,7 @@
|
|||||||
.admin-user-row.user-inactive { opacity: 0.7; }
|
.admin-user-row.user-inactive { opacity: 0.7; }
|
||||||
|
|
||||||
|
|
||||||
/* ── Debug Modal ─────────────────────────── */
|
/* ── Debug Modal — moved to sw-debug.css (v0.37.18) ── */
|
||||||
|
|
||||||
.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; }
|
|
||||||
|
|
||||||
|
|
||||||
/* ── Command Palette ─────────────────────── */
|
/* ── Command Palette ─────────────────────── */
|
||||||
|
|
||||||
|
|||||||
112
src/css/sw-debug.css
Normal file
112
src/css/sw-debug.css
Normal 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; }
|
||||||
|
}
|
||||||
700
src/js/debug.js
700
src/js/debug.js
@@ -1,672 +1,42 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// Debug Logger & Modal
|
// Debug Bootstrap (v0.37.18)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Console interceptor, fetch logger, state inspector.
|
// Thin entry point: initializes the debug engine (synchronous,
|
||||||
// Adapted from ai-editor's error-logger.js + llm-debug-modal.js
|
// captures early errors) then mounts the Preact debug modal
|
||||||
// for no-devtools "hardmode" debugging.
|
// after Preact globals are available.
|
||||||
//
|
//
|
||||||
// Activate: Ctrl+K → "debug" or tap the 🐛 badge (appears after init)
|
// 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
|
||||||
// Exports: window.DebugLog,window.openDebugModal,window.switchDebugTab,window.clearDebugLog,window.copyDebugLog,window.exportDebugLog,window.runDebugDiagnostics,window.purgeCache
|
|
||||||
|
import { debugEngine } from './sw/components/debug/engine.js';
|
||||||
|
|
||||||
const DebugLog = {
|
// Initialize ASAP — before SDK boot so we capture everything.
|
||||||
entries: [],
|
debugEngine.init();
|
||||||
maxEntries: 500,
|
|
||||||
networkLog: [],
|
// Backward-compat globals (used by badge click, command palette, etc.)
|
||||||
maxNetwork: 200,
|
window.DebugLog = debugEngine;
|
||||||
_origConsole: {},
|
window.openDebugModal = () => window.dispatchEvent(new CustomEvent('debug:toggle'));
|
||||||
_origFetch: null,
|
window.copyDebugLog = () => debugEngine.copyLog();
|
||||||
_initTime: Date.now(),
|
window.exportDebugLog = () => debugEngine.downloadLog();
|
||||||
_errorCount: 0,
|
window.runDebugDiagnostics = () => debugEngine.runDiagnostics();
|
||||||
|
window.purgeCache = () => debugEngine.purgeCache();
|
||||||
// ── Bootstrap ────────────────────────────
|
|
||||||
|
// Mount Preact modal once the DOM + Preact globals are ready.
|
||||||
init() {
|
// Preact globals are set by the surface template's script block,
|
||||||
this._interceptConsole();
|
// which loads before this module (type=module is deferred).
|
||||||
this._interceptFetch();
|
function mountWhenReady() {
|
||||||
this._interceptErrors();
|
if (!window.preact || !window.html || !window.hooks) {
|
||||||
this._injectBadge();
|
// Surface script hasn't loaded yet — retry on next tick.
|
||||||
this.log('DEBUG', '🐛 Debug logger initialized');
|
requestAnimationFrame(mountWhenReady);
|
||||||
this.log('DEBUG', `Page: ${location.href}`);
|
return;
|
||||||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Export ───────────────────────────────
|
|
||||||
|
|
||||||
exportText() {
|
|
||||||
let text = `=== Chat Switchboard Debug Export ===\n`;
|
|
||||||
text += `Exported: ${new Date().toISOString()}\n`;
|
|
||||||
text += `URL: ${location.href}\n`;
|
|
||||||
text += `ENV: ${window.__ENV__ || 'unknown'} | VERSION: ${window.__VERSION__ || 'unknown'} | BASE: ${window.__BASE__ || '(root)'}\n`;
|
|
||||||
text += `UA: ${navigator.userAgent}\n\n`;
|
|
||||||
|
|
||||||
text += `--- STATE ---\n`;
|
|
||||||
text += JSON.stringify(this.getStateSnapshot(), null, 2) + '\n\n';
|
|
||||||
|
|
||||||
text += `--- CONSOLE LOG (${this.entries.length}) ---\n`;
|
|
||||||
for (const e of this.entries) {
|
|
||||||
text += `[${e.ts}] [${e.type}] ${e.message}\n`;
|
|
||||||
}
|
|
||||||
text += '\n';
|
|
||||||
|
|
||||||
text += `--- NETWORK LOG (${this.networkLog.length}) ---\n`;
|
|
||||||
for (const r of this.networkLog) {
|
|
||||||
const proxy = r.proxyDetected ? ' [PROXY]' : '';
|
|
||||||
text += `[${r.ts}] ${r.method} ${r.url} → ${r.status || 'FAILED'}${proxy} ${r.error || ''} (${r.duration || '?'}ms)`;
|
|
||||||
if (r.contentType) text += ` [${r.contentType}]`;
|
|
||||||
text += '\n';
|
|
||||||
if (r.responsePreview) text += ` Response: ${r.responsePreview.slice(0, 200)}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return text;
|
|
||||||
}
|
}
|
||||||
};
|
import('./sw/components/debug/index.js').then(({ mountDebugModal }) => {
|
||||||
|
mountDebugModal(document.getElementById('debugMount'));
|
||||||
// ── 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();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
// ── Modal Controls ──────────────────────────
|
document.addEventListener('DOMContentLoaded', mountWhenReady);
|
||||||
|
} else {
|
||||||
function openDebugModal() {
|
mountWhenReady();
|
||||||
DebugLog.render();
|
|
||||||
openModal('debugModal');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
|
|||||||
527
src/js/repl.js
527
src/js/repl.js
@@ -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;
|
|
||||||
@@ -112,6 +112,26 @@ export function extractCodeBlocks(htmlStr) {
|
|||||||
return segments;
|
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) {
|
function _unescapeHtml(text) {
|
||||||
return text
|
return text
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
|
|||||||
@@ -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"/>
|
<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>`;
|
</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 {{
|
* @param {{
|
||||||
* role: string,
|
* role: string,
|
||||||
* content: string,
|
* content: string,
|
||||||
* onRegenerate?: () => void,
|
* onRegenerate?: () => void,
|
||||||
* onDelete?: () => void,
|
* onDelete?: () => void,
|
||||||
|
* onSave?: () => void,
|
||||||
|
* hasCodeBlocks?: boolean,
|
||||||
* }} props
|
* }} props
|
||||||
*/
|
*/
|
||||||
export function MessageActions({ role, content, onRegenerate, onDelete }) {
|
export function MessageActions({ role, content, onRegenerate, onDelete, onSave, hasCodeBlocks }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const _copy = useCallback(async () => {
|
const _copy = useCallback(async () => {
|
||||||
@@ -80,6 +90,13 @@ export function MessageActions({ role, content, onRegenerate, onDelete }) {
|
|||||||
aria-label="Copy message text">
|
aria-label="Copy message text">
|
||||||
${copied ? CHECK_SVG : COPY_SVG}
|
${copied ? CHECK_SVG : COPY_SVG}
|
||||||
</button>
|
</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`
|
${onDelete && html`
|
||||||
<button class="sw-chat-msg__action-btn sw-chat-msg__action-btn--danger"
|
<button class="sw-chat-msg__action-btn sw-chat-msg__action-btn--danger"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
// onRegenerate=${fn} onDelete=${fn} />`
|
// onRegenerate=${fn} onDelete=${fn} />`
|
||||||
|
|
||||||
import { renderMarkdown } from './markdown.js';
|
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';
|
import { MessageActions } from './message-actions.js';
|
||||||
|
|
||||||
const html = window.html;
|
const html = window.html;
|
||||||
const { useMemo } = window.hooks;
|
const { useMemo, useCallback } = window.hooks;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a relative time string from an ISO date.
|
* Format a relative time string from an ISO date.
|
||||||
@@ -65,6 +65,33 @@ export function MessageBubble({ role, content, thinking, id, created_at, streami
|
|||||||
}, [thinking]);
|
}, [thinking]);
|
||||||
|
|
||||||
const timeStr = _relTime(created_at);
|
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
|
// While streaming, show thinking open if content hasn't started yet
|
||||||
const thinkingOpen = streaming && thinking && !content;
|
const thinkingOpen = streaming && thinking && !content;
|
||||||
@@ -93,7 +120,9 @@ export function MessageBubble({ role, content, thinking, id, created_at, streami
|
|||||||
role=${role}
|
role=${role}
|
||||||
content=${content}
|
content=${content}
|
||||||
onRegenerate=${onRegenerate}
|
onRegenerate=${onRegenerate}
|
||||||
onDelete=${onDelete} />
|
onDelete=${onDelete}
|
||||||
|
hasCodeBlocks=${hasCodeBlocks}
|
||||||
|
onSave=${handleSave} />
|
||||||
</div>`}
|
</div>`}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,14 @@
|
|||||||
const { useState, useEffect } = window.hooks;
|
const { useState, useEffect } = window.hooks;
|
||||||
const html = window.html;
|
const html = window.html;
|
||||||
|
|
||||||
|
const HEALTH_DOTS = { healthy: '\u{1F7E2}', degraded: '\u{1F7E1}', inactive: '\u{1F534}' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ value: string, onChange: (id: string) => void }} props
|
* @param {{ value: string, onChange: (id: string) => void }} props
|
||||||
*/
|
*/
|
||||||
export function ModelSelector({ value, onChange }) {
|
export function ModelSelector({ value, onChange }) {
|
||||||
const [models, setModels] = useState([]);
|
const [models, setModels] = useState([]);
|
||||||
|
const [health, setHealth] = useState({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.sw?.api?.models?.enabled) return;
|
if (!window.sw?.api?.models?.enabled) return;
|
||||||
@@ -23,13 +26,25 @@ export function ModelSelector({ value, onChange }) {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const list = (resp.data || []).filter(m => !m.hidden);
|
const list = (resp.data || []).filter(m => !m.hidden);
|
||||||
setModels(list);
|
setModels(list);
|
||||||
// Auto-select first model if no value set
|
|
||||||
if (!value && list.length && onChange) {
|
if (!value && list.length && onChange) {
|
||||||
const first = list[0];
|
const first = list[0];
|
||||||
const id = first.isPersona ? (first.personaId || first.id) : first.id;
|
const id = first.isPersona ? (first.personaId || first.id) : first.id;
|
||||||
onChange(id);
|
onChange(id);
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).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; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -47,7 +62,10 @@ export function ModelSelector({ value, onChange }) {
|
|||||||
<option value="" disabled selected>No models</option>`}
|
<option value="" disabled selected>No models</option>`}
|
||||||
${models.map(m => {
|
${models.map(m => {
|
||||||
const id = m.isPersona ? (m.personaId || m.id) : m.id;
|
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>`;
|
return html`<option key=${id} value=${id}>${label}</option>`;
|
||||||
})}
|
})}
|
||||||
</select>`;
|
</select>`;
|
||||||
|
|||||||
37
src/js/sw/components/debug/badge.js
Normal file
37
src/js/sw/components/debug/badge.js
Normal 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
|
||||||
|
}
|
||||||
73
src/js/sw/components/debug/console-tab.js
Normal file
73
src/js/sw/components/debug/console-tab.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ entries: Array }} props
|
||||||
|
*/
|
||||||
|
export function ConsoleTab({ entries }) {
|
||||||
|
const [errorsOnly, setErrorsOnly] = useState(false);
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
const contentRef = useRef(null);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!errorsOnly) return entries;
|
||||||
|
return entries.filter(e => ERROR_TYPES.has(e.type));
|
||||||
|
}, [entries, errorsOnly]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoScroll && contentRef.current) {
|
||||||
|
contentRef.current.scrollTop = contentRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [filtered, autoScroll]);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<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>`;
|
||||||
|
}
|
||||||
426
src/js/sw/components/debug/engine.js
Normal file
426
src/js/sw/components/debug/engine.js
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
132
src/js/sw/components/debug/index.js
Normal file
132
src/js/sw/components/debug/index.js
Normal 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);
|
||||||
|
}
|
||||||
64
src/js/sw/components/debug/network-tab.js
Normal file
64
src/js/sw/components/debug/network-tab.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function NetEntry({ req }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const isErr = req.error || (req.status && req.status >= 400) || req.proxyDetected;
|
||||||
|
const statusColor = req.proxyDetected ? '#ff6b35' : (isErr ? '#e74c3c' : '#2ecc71');
|
||||||
|
const statusText = req.proxyDetected
|
||||||
|
? `PROXY (${req.status})`
|
||||||
|
: req.error
|
||||||
|
? `ERR ${req.error}`
|
||||||
|
: (req.status ? `${req.status} ${req.statusText}` : 'pending');
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<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>`;
|
||||||
|
}
|
||||||
237
src/js/sw/components/debug/repl-tab.js
Normal file
237
src/js/sw/components/debug/repl-tab.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getGlobals() {
|
||||||
|
const globals = {};
|
||||||
|
if (window.sw) globals.sw = window.sw;
|
||||||
|
if (window.DebugLog) globals.DebugLog = window.DebugLog;
|
||||||
|
globals.$ = (sel) => document.querySelector(sel);
|
||||||
|
globals.$$ = (sel) => [...document.querySelectorAll(sel)];
|
||||||
|
globals.sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
return globals;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wrapCode(code) {
|
||||||
|
const trimmed = code.trim();
|
||||||
|
if (/^(let |const |var |return |if |for |while |switch |try |class |function )/.test(trimmed)) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return `return (${trimmed})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatValue(value) {
|
||||||
|
if (value === null) return 'null';
|
||||||
|
if (value === undefined) return 'undefined';
|
||||||
|
const type = typeof value;
|
||||||
|
if (type === 'string') return JSON.stringify(value);
|
||||||
|
if (type === 'number' || type === 'bigint' || type === 'boolean') return String(value);
|
||||||
|
if (type === 'function') return `[Function: ${value.name || 'anonymous'}]`;
|
||||||
|
if (type === 'symbol') return String(value);
|
||||||
|
if (value instanceof HTMLElement) {
|
||||||
|
return `<${value.tagName.toLowerCase()}${value.id ? '#' + value.id : ''}${value.className ? '.' + value.className.split(' ').join('.') : ''}>`;
|
||||||
|
}
|
||||||
|
if (value instanceof Error) return `${value.constructor.name}: ${value.message}`;
|
||||||
|
try { return JSON.stringify(value, null, 2); }
|
||||||
|
catch (_) { return String(value); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ visible: boolean }} props
|
||||||
|
*/
|
||||||
|
export function ReplTab({ visible }) {
|
||||||
|
const [output, setOutput] = useState([]);
|
||||||
|
const [historyList] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem('cs::repl::history');
|
||||||
|
return saved ? JSON.parse(saved) : [];
|
||||||
|
} catch (_) { return []; }
|
||||||
|
});
|
||||||
|
const historyIdx = useRef(-1);
|
||||||
|
const scratchRef = useRef('');
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const outputRef = useRef(null);
|
||||||
|
|
||||||
|
// Admin gate
|
||||||
|
const isAdmin = window.sw?.auth?.user?.role === 'admin';
|
||||||
|
const debugParam = new URLSearchParams(window.location.search).has('debug');
|
||||||
|
if (!isAdmin && !debugParam) return null;
|
||||||
|
|
||||||
|
const appendEntry = useCallback((type, text) => {
|
||||||
|
setOutput(prev => [...prev, { type, text }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runCode = useCallback(async (code) => {
|
||||||
|
if (!code.trim()) return;
|
||||||
|
|
||||||
|
historyList.push(code);
|
||||||
|
if (historyList.length > 100) historyList.shift();
|
||||||
|
historyIdx.current = -1;
|
||||||
|
try { sessionStorage.setItem('cs::repl::history', JSON.stringify(historyList)); } catch (_) {}
|
||||||
|
|
||||||
|
appendEntry('input', '> ' + code);
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
try {
|
||||||
|
const globals = _getGlobals();
|
||||||
|
const argNames = Object.keys(globals);
|
||||||
|
const argValues = Object.values(globals);
|
||||||
|
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
||||||
|
const fn = new AsyncFunction(...argNames, _wrapCode(code));
|
||||||
|
const result = await fn(...argValues);
|
||||||
|
const elapsed = (performance.now() - startTime).toFixed(1);
|
||||||
|
|
||||||
|
if (result !== undefined) {
|
||||||
|
appendEntry('result', `${_formatValue(result)} [${elapsed}ms]`);
|
||||||
|
} else {
|
||||||
|
appendEntry('info', `(undefined) [${elapsed}ms]`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
appendEntry('error', err.message || String(err));
|
||||||
|
if (err.stack) {
|
||||||
|
appendEntry('stack', err.stack.split('\n').slice(1, 5).join('\n'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [historyList, appendEntry]);
|
||||||
|
|
||||||
|
const onKeyDown = useCallback((e) => {
|
||||||
|
const input = e.target;
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
runCode(input.value);
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowUp' && input.value.substring(0, input.selectionStart).indexOf('\n') === -1) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (historyIdx.current === -1) {
|
||||||
|
scratchRef.current = input.value;
|
||||||
|
historyIdx.current = historyList.length;
|
||||||
|
}
|
||||||
|
historyIdx.current = Math.max(0, historyIdx.current - 1);
|
||||||
|
input.value = historyList[historyIdx.current] || '';
|
||||||
|
input.selectionStart = input.selectionEnd = input.value.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown' && input.value.substring(input.selectionStart).indexOf('\n') === -1) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (historyIdx.current === -1) return;
|
||||||
|
historyIdx.current++;
|
||||||
|
if (historyIdx.current >= historyList.length) {
|
||||||
|
historyIdx.current = -1;
|
||||||
|
input.value = scratchRef.current || '';
|
||||||
|
} else {
|
||||||
|
input.value = historyList[historyIdx.current] || '';
|
||||||
|
}
|
||||||
|
input.selectionStart = input.selectionEnd = input.value.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
_tabComplete(input);
|
||||||
|
}
|
||||||
|
}, [runCode, historyList]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (outputRef.current) {
|
||||||
|
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [output]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<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;
|
||||||
|
}
|
||||||
28
src/js/sw/components/debug/state-tab.js
Normal file
28
src/js/sw/components/debug/state-tab.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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>`;
|
||||||
|
}
|
||||||
@@ -94,7 +94,7 @@ export function createDomains(restClient) {
|
|||||||
kbs: (id) => rc.get(`/api/v1/channels/${id}/knowledge-bases`),
|
kbs: (id) => rc.get(`/api/v1/channels/${id}/knowledge-bases`),
|
||||||
setKbs: (id, kbIds) => rc.put(`/api/v1/channels/${id}/knowledge-bases`, { kb_ids: kbIds }),
|
setKbs: (id, kbIds) => rc.put(`/api/v1/channels/${id}/knowledge-bases`, { kb_ids: kbIds }),
|
||||||
// Files
|
// 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),
|
uploadFile: (id, file) => rc.upload(`/api/v1/channels/${id}/files`, file),
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -150,7 +150,8 @@ export function createDomains(restClient) {
|
|||||||
// ── 7. Workspaces ──────────────────────
|
// ── 7. Workspaces ──────────────────────
|
||||||
workspaces: {
|
workspaces: {
|
||||||
...crud(rc, '/api/v1/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)),
|
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 })),
|
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),
|
writeFile: (id, path, content) => rc.put(`/api/v1/workspaces/${id}/files/write` + _qs({ path }), content),
|
||||||
|
|||||||
@@ -117,8 +117,7 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'debug':
|
case 'debug':
|
||||||
const dbg = document.getElementById('debugModal');
|
window.dispatchEvent(new CustomEvent('debug:toggle'));
|
||||||
if (dbg) dbg.classList.toggle('active');
|
|
||||||
break;
|
break;
|
||||||
case 'chat':
|
case 'chat':
|
||||||
location.href = BASE + '/';
|
location.href = BASE + '/';
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ export default function ProvidersSection() {
|
|||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
} 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) {
|
async function deleteProvider(id) {
|
||||||
const ok = await sw.confirm('Delete this provider config?', true);
|
const ok = await sw.confirm('Delete this provider config?', true);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@@ -118,6 +128,7 @@ export default function ProvidersSection() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-muted" style="font-size:12px;margin-bottom:8px;word-break:break-all;">${c.endpoint || '(default)'}</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;">
|
<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=${() => setEditing(c)}>Edit</button>
|
||||||
<button class="btn-small" onClick=${() => syncModels(c.id)}>Sync Models</button>
|
<button class="btn-small" onClick=${() => syncModels(c.id)}>Sync Models</button>
|
||||||
<button class="btn-small btn-danger" onClick=${() => deleteProvider(c.id)}>Delete</button>
|
<button class="btn-small btn-danger" onClick=${() => deleteProvider(c.id)}>Delete</button>
|
||||||
|
|||||||
@@ -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) => {
|
const setField = useCallback((key, val) => {
|
||||||
setForm(f => ({ ...f, [key]: val }));
|
setForm(f => ({ ...f, [key]: val }));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -179,6 +190,9 @@ export function ProvidersSection() {
|
|||||||
${esc(p.endpoint)}
|
${esc(p.endpoint)}
|
||||||
</td>
|
</td>
|
||||||
<td class="admin-actions-cell" style="white-space:nowrap;">
|
<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)}>
|
<button class="icon-btn" title="Sync models" onClick=${() => sync(p)}>
|
||||||
\u{1F504}
|
\u{1F504}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -29,15 +29,14 @@ const SHELL_FILES = [
|
|||||||
// CSS — Preact (sw-*)
|
// CSS — Preact (sw-*)
|
||||||
'./css/sw-primitives.css',
|
'./css/sw-primitives.css',
|
||||||
'./css/sw-shell.css',
|
'./css/sw-shell.css',
|
||||||
|
'./css/sw-debug.css',
|
||||||
'./css/sw-login.css',
|
'./css/sw-login.css',
|
||||||
'./css/sw-chat-pane.css',
|
'./css/sw-chat-pane.css',
|
||||||
'./css/sw-chat-surface.css',
|
'./css/sw-chat-surface.css',
|
||||||
'./css/sw-notes-pane.css',
|
'./css/sw-notes-pane.css',
|
||||||
'./css/sw-notes-surface.css',
|
'./css/sw-notes-surface.css',
|
||||||
// JS — debug tooling (standalone, no old-layer deps)
|
// JS — debug tooling (v0.37.18: repl.js absorbed into Preact debug components)
|
||||||
// v0.37.14: sb.js, events.js, switchboard-sdk.js removed
|
|
||||||
'./js/debug.js',
|
'./js/debug.js',
|
||||||
'./js/repl.js',
|
|
||||||
// Vendor
|
// Vendor
|
||||||
'./vendor/marked.min.js',
|
'./vendor/marked.min.js',
|
||||||
'./vendor/purify.min.js',
|
'./vendor/purify.min.js',
|
||||||
|
|||||||
Reference in New Issue
Block a user