Feat v0.2.5 ui polish dead code (#9)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #9.
This commit is contained in:
55
CHANGELOG.md
55
CHANGELOG.md
@@ -2,6 +2,61 @@
|
|||||||
|
|
||||||
All notable changes to Switchboard Core are documented here.
|
All notable changes to Switchboard Core are documented here.
|
||||||
|
|
||||||
|
## [Unreleased] — v0.2.5
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Welcome surface**: New core surface shown as fallback. Detects whether
|
||||||
|
extensions exist (shows "Set Default Surface") vs truly empty install
|
||||||
|
(shows "Go to Packages"). Topbar + UserMenu included.
|
||||||
|
- **User default surface**: Users can set a personal landing page in
|
||||||
|
Settings > General, overriding the admin-configured global default.
|
||||||
|
- **UserMenu in admin topbar**: Replaced Back button with UserMenu,
|
||||||
|
providing consistent surface navigation and eliminating infinite loops.
|
||||||
|
- **Package manifest icons**: Added emoji icons to hello-dashboard,
|
||||||
|
icd-test-runner, sdk-test-runner, and team-activity-log manifests.
|
||||||
|
- **PoC documentation**: README.md for tasks and schedules packages
|
||||||
|
documenting Proof of Concept status and graduation criteria.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Default surface resolution**: Priority chain is now
|
||||||
|
user preference → global config → first extension → `/welcome`.
|
||||||
|
User preference read from JWT cookie on unauthenticated `/` route.
|
||||||
|
Correctly resolves `type: "full"` extension packages (not just `type: "surface"`).
|
||||||
|
- **User settings General section**: Replaced dead chat fields (Default
|
||||||
|
Model, System Prompt, Max Tokens, Temperature, Show Thinking) with
|
||||||
|
a Default Surface dropdown.
|
||||||
|
- **Admin settings**: Removed dead chat sections (System Prompt, Default
|
||||||
|
Model, Policies, Web Search, Auto-Compaction, Memory Extraction).
|
||||||
|
- **Roadmap restructured**: Workflows → v0.3.x (includes team roles),
|
||||||
|
Notes → v0.4.0, MVP → v0.5.0. Added settings audit milestones
|
||||||
|
(v0.2.7 admin, v0.2.8 user, v0.2.9 team-admin pass 1).
|
||||||
|
- Updated bus doc examples and test labels from `chat.*` to `workflow.*`.
|
||||||
|
- Renamed `channel-` prefixed test paths to `test-` in storage tests.
|
||||||
|
- Updated doc comments throughout to remove references to gutted surfaces.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **~500 lines of dead CSS**: Orphaned classes for gutted chat, channel,
|
||||||
|
project, notes, editor-chat, sidebar, and router-picker features from
|
||||||
|
`layout.css` and `surfaces.css`.
|
||||||
|
- **Dead Go types**: `Grant` struct (persona-era), `CompositeModelKey` func,
|
||||||
|
comment-only stubs for NoteGraph, ProjectChannel, etc.
|
||||||
|
- **Dead event code**: `chat.typing.*` / `channel.typing.*` condition in WS
|
||||||
|
subscriber, empty Chat/Channel event route table sections.
|
||||||
|
- **Dead test helpers**: `seed_helpers.go` (SeedTestMessage, SeedTestMessages,
|
||||||
|
SeedTestCursor — all callerless).
|
||||||
|
- **Stale template refs**: CSS link tags for non-existent `sw-chat-pane.css`,
|
||||||
|
`sw-notes-pane.css`, `chat.css`. Orphaned `chat-pane.html` component.
|
||||||
|
- **Vestigial guards**: `"chat"` surface checks in `IsSurfaceEnabled()` and
|
||||||
|
`DisablePackage()` (chat is no longer a surface).
|
||||||
|
- **Dead settings UI**: Chat defaults from user settings, System Prompt /
|
||||||
|
Default Model / Policies / Web Search / Compaction / Memory from admin.
|
||||||
|
- Unused `fmt.Sprintf` references in team stores.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [Unreleased] — v0.2.4
|
## [Unreleased] — v0.2.4
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
54
ROADMAP.md
54
ROADMAP.md
@@ -81,20 +81,56 @@ SDK stabilization, and the first rebuilt extension (tasks).
|
|||||||
|
|
||||||
| Step | Status | Description |
|
| Step | Status | Description |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| UI bug pass | ⬚ | Systematic review of all surfaces (admin, settings, dashboard, git-board, tasks, schedules). Fix broken CSS, missing states, responsive issues, dark/light theme gaps. |
|
| UI bug pass | ✅ | Reviewed admin, settings, login, welcome surfaces in light/dark themes. Fixed settings crash (models API undefined). Removed dead chat settings from both user and admin settings. |
|
||||||
| Dead code sweep | ⬚ | Audit for Phase 0 remnants: orphaned CSS classes, stale imports, references to gutted chat/notes/projects in templates and route registrations, unused Go handlers/stores. |
|
| Dead code sweep | ✅ | Removed ~700 lines: orphaned CSS, dead Go types, stale event code, unused test helpers, dead settings UI (chat defaults, system prompt, default model, policies, web search, compaction, memory). |
|
||||||
| Template cleanup | ⬚ | Remove stale CSS link tags (sw-chat-pane.css, sw-notes-pane.css etc.) from HTML templates. Clean up base.html surface wiring. |
|
| Template cleanup | ✅ | Removed stale CSS link tags and orphaned chat-pane.html. Updated doc comments throughout. |
|
||||||
| Package proof-of-concept status | ⬚ | Mark tasks + schedules packages as PoC (not permanent). Document what "permanent" means and the graduation criteria for extensions shipping with core. |
|
| Package proof-of-concept status | ✅ | Created README.md for tasks + schedules with graduation criteria. Added missing manifest icons. |
|
||||||
|
| Welcome surface | ✅ | New fallback surface when no extensions installed. Topbar + welcome card with admin link. Replaces `/admin` as final redirect target. |
|
||||||
|
| Default surface routing | ✅ | Resolution chain: user preference → global config → first extension → `/welcome`. Users can set personal default in Settings > General. Admin sets global default in Admin > Settings. |
|
||||||
|
| Admin navigation | ✅ | Replaced Back button with UserMenu in admin topbar. Eliminates back-button infinite loop. |
|
||||||
|
|
||||||
### v0.2.6 — Workflow Architecture
|
### v0.2.6 — Extension Lifecycle
|
||||||
|
|
||||||
| Step | Status | Description |
|
| Step | Status | Description |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| Workflow design session | ⬚ | Define what "workflow" means in the extension-first model. Determine if the existing `workflows` table/handler survives, gets rebuilt, or gets removed. Document the Starlark contract for multi-step automation. |
|
|
||||||
| Trigger composition model | ⬚ | How do triggers, schedules, and workflows compose? Event chains, conditional branching, error handling. Design doc before code. |
|
|
||||||
| Extension lifecycle | ⬚ | Define permanent vs PoC extensions. Package graduation criteria. Dependency policy. What ships with core vs what's installed separately. |
|
| Extension lifecycle | ⬚ | Define permanent vs PoC extensions. Package graduation criteria. Dependency policy. What ships with core vs what's installed separately. |
|
||||||
|
|
||||||
## v0.3.0 — Notes Surface
|
### v0.2.7 — Admin Settings Audit
|
||||||
|
|
||||||
|
| Step | Status | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| Admin settings E2E | ⬚ | Verify all surviving admin settings work end-to-end: default surface, registration, banner, message bar, footer, vault, email. Remove any handler/store vestiges of deleted settings (search, compaction, memory). |
|
||||||
|
| Packages surface | ⬚ | Verify packages page correctly shows installed vs available. Enable/disable works. Broken post-fork surfaces identified and either fixed or marked. |
|
||||||
|
|
||||||
|
### v0.2.8 — User Settings Audit
|
||||||
|
|
||||||
|
| Step | Status | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| User settings E2E | ⬚ | Verify all user settings sections: General (default surface), Appearance (theme, scale, font), Profile (display name, avatar, handle), Connections, Notifications. Remove dead features, fix broken states. |
|
||||||
|
| Visibility gating | ⬚ | Ensure settings sections only show features that are actually available. Hide empty sections. Respect `user_overridable` flag from extension manifests. |
|
||||||
|
|
||||||
|
### v0.2.9 — Team Admin Settings Audit (Pass 1)
|
||||||
|
|
||||||
|
| Step | Status | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| Team admin E2E | ⬚ | Verify team member management, team settings cascade, role assignment (admin/member). Audit for dead code from pre-fork team features. First pass — validates current functionality before v0.3.x adds team roles. |
|
||||||
|
| User settings team tab | ⬚ | Verify the Teams section in user settings — team list, join/leave, team-scoped settings. |
|
||||||
|
|
||||||
|
## v0.3.x — Workflow Architecture
|
||||||
|
|
||||||
|
Workflows are the core platform capability. This series implements the
|
||||||
|
full multi-step automation system with team role integration.
|
||||||
|
|
||||||
|
### v0.3.0 — Workflow Design + Schema
|
||||||
|
|
||||||
|
| Step | Status | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| Workflow design session | ⬚ | Define what "workflow" means in the extension-first model. Determine if the existing `workflows` table/handler survives, gets rebuilt, or gets removed. Document the Starlark contract for multi-step automation. See `docs/DESIGN-WORKFLOW-REDESIGN-0.2.6.md`. |
|
||||||
|
| Team roles | ⬚ | Different roles per team responsible for different workflow stages. Role-based stage assignment, multi-party validation (2-party sign-off at stage boundaries). |
|
||||||
|
| Trigger composition model | ⬚ | How do triggers, schedules, and workflows compose? Event chains, conditional branching, error handling. Design doc before code. |
|
||||||
|
| Settings audit pass 2 | ⬚ | Focused audit of team admin + user settings for workflow/team-role changes applied in this series. Validates new team role UI, stage assignment settings, workflow preferences. |
|
||||||
|
|
||||||
|
## v0.4.0 — Notes Surface
|
||||||
|
|
||||||
Obsidian-style rich-text notes rebuilt as an installable surface package.
|
Obsidian-style rich-text notes rebuilt as an installable surface package.
|
||||||
Zero platform special-casing. Proves the full extension stack E2E.
|
Zero platform special-casing. Proves the full extension stack E2E.
|
||||||
@@ -104,7 +140,7 @@ Zero platform special-casing. Proves the full extension stack E2E.
|
|||||||
- Folder tree, backlinks, tags — all extension-provided
|
- Folder tree, backlinks, tags — all extension-provided
|
||||||
- Markdown import/export
|
- Markdown import/export
|
||||||
|
|
||||||
## v0.4.0 — MVP
|
## v0.5.0 — MVP
|
||||||
|
|
||||||
Extension and operations tracks converge. First externally usable release.
|
Extension and operations tracks converge. First externally usable release.
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ server {
|
|||||||
location ${BASE_PATH}/admin { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
location ${BASE_PATH}/admin { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||||
location ${BASE_PATH}/team-admin { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
location ${BASE_PATH}/team-admin { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||||
location ${BASE_PATH}/settings { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
location ${BASE_PATH}/settings { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||||
|
location ${BASE_PATH}/welcome { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||||
location ${BASE_PATH}/w/ { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
location ${BASE_PATH}/w/ { proxy_pass $backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||||
|
|
||||||
# Extension surface page routes → backend
|
# Extension surface page routes → backend
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "hello-dashboard",
|
"id": "hello-dashboard",
|
||||||
|
"icon": "👋",
|
||||||
"title": "Hello Dashboard",
|
"title": "Hello Dashboard",
|
||||||
"route": "/s/hello-dashboard",
|
"route": "/s/hello-dashboard",
|
||||||
"auth": "authenticated",
|
"auth": "authenticated",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "icd-test-runner",
|
"id": "icd-test-runner",
|
||||||
|
"icon": "🧪",
|
||||||
"title": "ICD Test Runner",
|
"title": "ICD Test Runner",
|
||||||
"route": "/s/icd-test-runner",
|
"route": "/s/icd-test-runner",
|
||||||
"auth": "authenticated",
|
"auth": "authenticated",
|
||||||
|
|||||||
22
packages/schedules/README.md
Normal file
22
packages/schedules/README.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Schedules
|
||||||
|
|
||||||
|
**Status: Proof of Concept**
|
||||||
|
|
||||||
|
Schedule management surface wrapping the kernel cron API. Ships with
|
||||||
|
core for SDK validation purposes, not as a permanent commitment.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Provides a management UI for user-created scheduled tasks (cron). Proves
|
||||||
|
that kernel APIs can be wrapped by extension surfaces without
|
||||||
|
special-casing.
|
||||||
|
|
||||||
|
## Graduation Criteria
|
||||||
|
|
||||||
|
To become a permanent core package, schedules must meet:
|
||||||
|
|
||||||
|
1. **Cron expression builder** — visual UI for building cron expressions
|
||||||
|
2. **Execution history** — log viewer with output and error details
|
||||||
|
3. **Error alerting** — notification on failed runs
|
||||||
|
4. **Test coverage** — API contract tests in the ICD test runner
|
||||||
|
5. **Timezone-aware display** — show next run in user's local timezone
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "sdk-test-runner",
|
"id": "sdk-test-runner",
|
||||||
|
"icon": "🔬",
|
||||||
"title": "SDK Test Runner",
|
"title": "SDK Test Runner",
|
||||||
"route": "/s/sdk-test-runner",
|
"route": "/s/sdk-test-runner",
|
||||||
"auth": "authenticated",
|
"auth": "authenticated",
|
||||||
|
|||||||
22
packages/tasks/README.md
Normal file
22
packages/tasks/README.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
**Status: Proof of Concept**
|
||||||
|
|
||||||
|
Task management surface rebuilt as a Starlark extension. Ships with core
|
||||||
|
for SDK validation purposes, not as a permanent commitment.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Validates all three trigger primitives (event bus, webhook, cron) and
|
||||||
|
proves the full extension surface stack: Starlark API routes, ext_data
|
||||||
|
tables, SDK UI components, and notifications.
|
||||||
|
|
||||||
|
## Graduation Criteria
|
||||||
|
|
||||||
|
To become a permanent core package, tasks must meet:
|
||||||
|
|
||||||
|
1. **Feature completeness** — assignees, comments, attachments, subtasks
|
||||||
|
2. **Test coverage** — API contract tests in the ICD test runner
|
||||||
|
3. **Performance** — tested at 1000+ tasks with acceptable load times
|
||||||
|
4. **Design review** — UI/UX matches platform design language
|
||||||
|
5. **No kernel special-casing** — runs purely through extension APIs
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "team-activity-log",
|
"id": "team-activity-log",
|
||||||
|
"icon": "📋",
|
||||||
"title": "Team Activity Log",
|
"title": "Team Activity Log",
|
||||||
"type": "full",
|
"type": "full",
|
||||||
"tier": "starlark",
|
"tier": "starlark",
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Additional Test Seed Helpers (v0.15.0) ──
|
|
||||||
|
|
||||||
// SeedTestMessage creates a single message in a channel and returns the message ID.
|
|
||||||
func SeedTestMessage(t *testing.T, channelID, parentID, role, content string) string {
|
|
||||||
t.Helper()
|
|
||||||
var id string
|
|
||||||
var parentPtr *string
|
|
||||||
if parentID != "" {
|
|
||||||
parentPtr = &parentID
|
|
||||||
}
|
|
||||||
err := DB.QueryRow(`
|
|
||||||
INSERT INTO messages (channel_id, parent_id, role, content, sibling_index)
|
|
||||||
VALUES ($1, $2, $3, $4, 0)
|
|
||||||
RETURNING id
|
|
||||||
`, channelID, parentPtr, role, content).Scan(&id)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SeedTestMessage: %v", err)
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeedTestMessages creates a linear chain of alternating user/assistant messages.
|
|
||||||
// Returns all message IDs in order. The first message has no parent.
|
|
||||||
func SeedTestMessages(t *testing.T, channelID string, count int, contentSize int) []string {
|
|
||||||
t.Helper()
|
|
||||||
content := strings.Repeat("x", contentSize)
|
|
||||||
ids := make([]string, 0, count)
|
|
||||||
|
|
||||||
parentID := ""
|
|
||||||
for i := 0; i < count; i++ {
|
|
||||||
role := "user"
|
|
||||||
if i%2 == 1 {
|
|
||||||
role = "assistant"
|
|
||||||
}
|
|
||||||
id := SeedTestMessage(t, channelID, parentID, role, content)
|
|
||||||
ids = append(ids, id)
|
|
||||||
parentID = id
|
|
||||||
}
|
|
||||||
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeedTestCursor sets the active leaf for a user in a channel.
|
|
||||||
func SeedTestCursor(t *testing.T, channelID, userID, leafID string) {
|
|
||||||
t.Helper()
|
|
||||||
_, err := DB.Exec(`
|
|
||||||
INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (channel_id, user_id)
|
|
||||||
DO UPDATE SET active_leaf_id = $3, updated_at = NOW()
|
|
||||||
`, channelID, userID, leafID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SeedTestCursor: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,9 +43,9 @@ func (b *Bus) SetBroadcastHook(fn func(Event)) {
|
|||||||
//
|
//
|
||||||
// Patterns:
|
// Patterns:
|
||||||
//
|
//
|
||||||
// "chat.message.abc123" — exact match
|
// "workflow.assigned.abc123" — exact match
|
||||||
// "chat.message.*" — wildcard: matches chat.message.{anything}
|
// "workflow.assigned.*" — wildcard: matches workflow.assigned.{anything}
|
||||||
// "chat.*" — wildcard: matches chat.{anything}
|
// "workflow.*" — wildcard: matches workflow.{anything}
|
||||||
// "*" — matches all events
|
// "*" — matches all events
|
||||||
func (b *Bus) Subscribe(pattern string, handler Handler) func() {
|
func (b *Bus) Subscribe(pattern string, handler Handler) func() {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ func TestMatch(t *testing.T) {
|
|||||||
label, pattern string
|
label, pattern string
|
||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{"chat.message.abc", "chat.message.abc", true},
|
{"workflow.assigned.abc", "workflow.assigned.abc", true},
|
||||||
{"chat.message.abc", "chat.message.*", true},
|
{"workflow.assigned.abc", "workflow.assigned.*", true},
|
||||||
{"chat.message.abc", "chat.*", true},
|
{"workflow.assigned.abc", "workflow.*", true},
|
||||||
{"chat.message.abc", "*", true},
|
{"workflow.assigned.abc", "*", true},
|
||||||
{"chat.message.abc", "chat.message.xyz", false},
|
{"workflow.assigned.abc", "workflow.assigned.xyz", false},
|
||||||
{"chat.message.abc", "channel.message.*", false},
|
{"workflow.assigned.abc", "notification.new.*", false},
|
||||||
{"chat.message.abc", "chat.message", false},
|
{"workflow.assigned.abc", "workflow.assigned", false},
|
||||||
{"ping", "ping", true},
|
{"ping", "ping", true},
|
||||||
{"ping", "pong", false},
|
{"ping", "pong", false},
|
||||||
{"plugin.hook.pre_completion", "plugin.hook.*", true},
|
{"plugin.hook.pre_completion", "plugin.hook.*", true},
|
||||||
@@ -37,12 +37,12 @@ func TestBusPublishExact(t *testing.T) {
|
|||||||
bus := NewBus()
|
bus := NewBus()
|
||||||
var count int32
|
var count int32
|
||||||
|
|
||||||
bus.Subscribe("chat.message.abc", func(e Event) {
|
bus.Subscribe("workflow.assigned.abc", func(e Event) {
|
||||||
atomic.AddInt32(&count, 1)
|
atomic.AddInt32(&count, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
bus.Publish(Event{Label: "chat.message.abc", Payload: json.RawMessage(`{}`)})
|
bus.Publish(Event{Label: "workflow.assigned.abc", Payload: json.RawMessage(`{}`)})
|
||||||
bus.Publish(Event{Label: "chat.message.xyz", Payload: json.RawMessage(`{}`)})
|
bus.Publish(Event{Label: "workflow.assigned.xyz", Payload: json.RawMessage(`{}`)})
|
||||||
|
|
||||||
if atomic.LoadInt32(&count) != 1 {
|
if atomic.LoadInt32(&count) != 1 {
|
||||||
t.Errorf("expected 1 dispatch, got %d", count)
|
t.Errorf("expected 1 dispatch, got %d", count)
|
||||||
@@ -53,13 +53,13 @@ func TestBusPublishWildcard(t *testing.T) {
|
|||||||
bus := NewBus()
|
bus := NewBus()
|
||||||
var count int32
|
var count int32
|
||||||
|
|
||||||
bus.Subscribe("chat.message.*", func(e Event) {
|
bus.Subscribe("workflow.assigned.*", func(e Event) {
|
||||||
atomic.AddInt32(&count, 1)
|
atomic.AddInt32(&count, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
bus.Publish(Event{Label: "chat.message.abc", Payload: json.RawMessage(`{}`)})
|
bus.Publish(Event{Label: "workflow.assigned.abc", Payload: json.RawMessage(`{}`)})
|
||||||
bus.Publish(Event{Label: "chat.message.xyz", Payload: json.RawMessage(`{}`)})
|
bus.Publish(Event{Label: "workflow.assigned.xyz", Payload: json.RawMessage(`{}`)})
|
||||||
bus.Publish(Event{Label: "chat.typing.abc", Payload: json.RawMessage(`{}`)})
|
bus.Publish(Event{Label: "workflow.claimed.abc", Payload: json.RawMessage(`{}`)})
|
||||||
|
|
||||||
if atomic.LoadInt32(&count) != 2 {
|
if atomic.LoadInt32(&count) != 2 {
|
||||||
t.Errorf("expected 2 dispatches, got %d", count)
|
t.Errorf("expected 2 dispatches, got %d", count)
|
||||||
@@ -91,7 +91,7 @@ func TestBusGlobalWildcard(t *testing.T) {
|
|||||||
atomic.AddInt32(&count, 1)
|
atomic.AddInt32(&count, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
bus.Publish(Event{Label: "chat.message.abc", Payload: json.RawMessage(`{}`)})
|
bus.Publish(Event{Label: "workflow.assigned.abc", Payload: json.RawMessage(`{}`)})
|
||||||
bus.Publish(Event{Label: "system.notify", Payload: json.RawMessage(`{}`)})
|
bus.Publish(Event{Label: "system.notify", Payload: json.RawMessage(`{}`)})
|
||||||
bus.Publish(Event{Label: "plugin.hook.pre_completion", Payload: json.RawMessage(`{}`)})
|
bus.Publish(Event{Label: "plugin.hook.pre_completion", Payload: json.RawMessage(`{}`)})
|
||||||
|
|
||||||
@@ -105,8 +105,6 @@ func TestRouteFor(t *testing.T) {
|
|||||||
label string
|
label string
|
||||||
want Direction
|
want Direction
|
||||||
}{
|
}{
|
||||||
{"chat.message.abc", DirLocal}, // chat routes removed in v0.1.0
|
|
||||||
{"chat.typing.abc", DirLocal}, // chat routes removed in v0.1.0
|
|
||||||
{"system.notify", DirToClient},
|
{"system.notify", DirToClient},
|
||||||
{"plugin.hook.pre_completion", DirLocal},
|
{"plugin.hook.pre_completion", DirLocal},
|
||||||
{"internal.db.write", DirLocal},
|
{"internal.db.write", DirLocal},
|
||||||
|
|||||||
@@ -35,10 +35,6 @@ const (
|
|||||||
// routeTable defines the default routing for known event prefixes.
|
// routeTable defines the default routing for known event prefixes.
|
||||||
// Events not listed default to DirLocal (server-only).
|
// Events not listed default to DirLocal (server-only).
|
||||||
var routeTable = map[string]Direction{
|
var routeTable = map[string]Direction{
|
||||||
// Chat events
|
|
||||||
|
|
||||||
// Channel events
|
|
||||||
|
|
||||||
// User/presence
|
// User/presence
|
||||||
"user.presence": DirToClient,
|
"user.presence": DirToClient,
|
||||||
"user.status": DirToClient,
|
"user.status": DirToClient,
|
||||||
|
|||||||
@@ -250,13 +250,6 @@ func (c *Conn) subscribeToBus() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't echo typing events back to the sender
|
|
||||||
if strings.HasPrefix(e.Label, "chat.typing.") || strings.HasPrefix(e.Label, "channel.typing.") {
|
|
||||||
if e.SenderID == c.userID && e.ConnID == c.id {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Room filtering: if event has a room, only send if conn is in that room
|
// Room filtering: if event has a room, only send if conn is in that room
|
||||||
if e.Room != "" && !c.rooms[e.Room] {
|
if e.Room != "" && !c.rooms[e.Room] {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -101,13 +101,13 @@ func (h *PackageHandler) EnablePackage(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": true})
|
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DisablePackage disables a package. Chat and Admin cannot be disabled.
|
// DisablePackage disables a package. Admin cannot be disabled.
|
||||||
// PUT /api/v1/admin/packages/:id/disable
|
// PUT /api/v1/admin/packages/:id/disable
|
||||||
// PUT /api/v1/admin/surfaces/:id/disable (alias)
|
// PUT /api/v1/admin/surfaces/:id/disable (alias)
|
||||||
func (h *PackageHandler) DisablePackage(c *gin.Context) {
|
func (h *PackageHandler) DisablePackage(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
if id == "chat" || id == "admin" {
|
if id == "admin" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": id + " cannot be disabled"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": id + " cannot be disabled"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ func (h *WorkflowHandler) Publish(c *gin.Context) {
|
|||||||
stages = []models.WorkflowStage{}
|
stages = []models.WorkflowStage{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot includes persona tool grants at publish time (frozen for running instances)
|
// Snapshot stages at publish time (frozen for running instances)
|
||||||
type stageSnapshot struct {
|
type stageSnapshot struct {
|
||||||
models.WorkflowStage
|
models.WorkflowStage
|
||||||
ToolGrants []string `json:"tool_grants,omitempty"`
|
ToolGrants []string `json:"tool_grants,omitempty"`
|
||||||
|
|||||||
@@ -142,6 +142,21 @@ func parseAndValidateJWT(tokenString string, jwtSecret string) (*Claims, bool) {
|
|||||||
return claims, true
|
return claims, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserIDFromCookie extracts the user ID from the sb_token cookie without
|
||||||
|
// requiring authentication. Returns "" if no valid token is found.
|
||||||
|
// Used by unauthenticated routes that want optional user context.
|
||||||
|
func UserIDFromCookie(c *gin.Context, jwtSecret string) string {
|
||||||
|
cookie, err := c.Cookie("sb_token")
|
||||||
|
if err != nil || cookie == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
claims, ok := parseAndValidateJWT(cookie, jwtSecret)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return claims.UserID
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Auth middleware ─────────────────────────────────────────
|
// ─── Auth middleware ─────────────────────────────────────────
|
||||||
|
|
||||||
// Auth returns a Gin middleware that validates JWT bearer tokens and
|
// Auth returns a Gin middleware that validates JWT bearer tokens and
|
||||||
|
|||||||
@@ -81,19 +81,6 @@ type TeamMember struct {
|
|||||||
UserRole string `json:"user_role,omitempty"`
|
UserRole string `json:"user_role,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
|
||||||
// GRANTS
|
|
||||||
// =========================================
|
|
||||||
|
|
||||||
type Grant struct {
|
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
PersonaID string `json:"persona_id" db:"persona_id"`
|
|
||||||
GrantType string `json:"grant_type" db:"grant_type"`
|
|
||||||
GrantRef string `json:"grant_ref" db:"grant_ref"`
|
|
||||||
Config JSONMap `json:"config,omitempty" db:"config"`
|
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PLATFORM POLICIES
|
// PLATFORM POLICIES
|
||||||
|
|
||||||
var PolicyDefaults = map[string]string{
|
var PolicyDefaults = map[string]string{
|
||||||
@@ -106,26 +93,6 @@ var PolicyDefaults = map[string]string{
|
|||||||
"default_model": "",
|
"default_model": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
// USER MODEL SETTINGS
|
|
||||||
|
|
||||||
// HiddenEntry identifies a model+provider pair for bulk visibility operations.
|
|
||||||
// CompositeModelKey builds the composite key used for per-provider model preferences.
|
|
||||||
func CompositeModelKey(providerConfigID, modelID string) string {
|
|
||||||
return providerConfigID + ":" + modelID
|
|
||||||
}
|
|
||||||
|
|
||||||
// CHANNELS
|
|
||||||
|
|
||||||
// SESSION PARTICIPANTS (v0.24.3)
|
|
||||||
|
|
||||||
// SessionParticipant is an ephemeral identity for anonymous workflow
|
|
||||||
// channel visitors. Scoped to a single channel, no users row required.
|
|
||||||
// MESSAGES
|
|
||||||
|
|
||||||
// CHANNEL PARTICIPANTS, MODELS, CURSORS
|
|
||||||
|
|
||||||
// PERSONA GROUPS (v0.23.0)
|
|
||||||
|
|
||||||
// HandleFromName generates a URL-safe @mention handle from a display name.
|
// HandleFromName generates a URL-safe @mention handle from a display name.
|
||||||
// "Veronica Sharpe" → "veronica-sharpe"
|
// "Veronica Sharpe" → "veronica-sharpe"
|
||||||
func HandleFromName(name string) string {
|
func HandleFromName(name string) string {
|
||||||
@@ -150,26 +117,6 @@ func HandleFromName(name string) string {
|
|||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
// ORGANIZATION
|
|
||||||
|
|
||||||
// ProjectPatch holds optional fields for updating a project.
|
|
||||||
// ProjectChannel represents a channel's membership in a project.
|
|
||||||
// ProjectKB represents a KB's association with a project.
|
|
||||||
// ProjectNote represents a note's association with a project.
|
|
||||||
// NOTES
|
|
||||||
|
|
||||||
// NoteLink represents a directed link extracted from [[wikilink]] syntax.
|
|
||||||
// ExportNoteLink is a note_link with source_note_id included (for export).
|
|
||||||
// NoteLinkResult represents a backlink — a note that links to a given note.
|
|
||||||
// NoteGraphNode is a lightweight note representation for graph display.
|
|
||||||
// NoteGraphEdge is a resolved link between two notes.
|
|
||||||
// NoteGraphDangling is an unresolved [[link]] reference.
|
|
||||||
// NoteGraph is the full graph topology for a user's notes.
|
|
||||||
// ATTACHMENTS
|
|
||||||
|
|
||||||
// FILES
|
|
||||||
|
|
||||||
// FileOrigin constants
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// AUDIT LOG
|
// AUDIT LOG
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -187,14 +134,6 @@ type AuditEntry struct {
|
|||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// USAGE TRACKING
|
|
||||||
|
|
||||||
// MODEL PRICING
|
|
||||||
|
|
||||||
// VIEW MODELS (computed, not stored)
|
|
||||||
|
|
||||||
// UserModel is the view model returned by the capability resolver.
|
|
||||||
// Combines catalog entries + Personas for the frontend.
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// JSON HELPERS
|
// JSON HELPERS
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
//
|
//
|
||||||
// templates/base.html — outer shell (banner + surface block + scripts)
|
// templates/base.html — outer shell (banner + surface block + scripts)
|
||||||
// templates/login.html — standalone login page
|
// templates/login.html — standalone login page
|
||||||
// templates/components/*.html — reusable partials (model-select, team-select, chat-pane, etc.)
|
// templates/components/*.html — reusable partials (model-select, team-select, etc.)
|
||||||
// templates/surfaces/*.html — one per surface (chat, editor, notes, admin, etc.)
|
// templates/surfaces/*.html — one per surface (admin, settings, team-admin, extension)
|
||||||
package pages
|
package pages
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -46,12 +46,12 @@ type Engine struct {
|
|||||||
// registered in Go at startup. Extension surfaces will be registered from
|
// registered in Go at startup. Extension surfaces will be registered from
|
||||||
// manifest files (future).
|
// manifest files (future).
|
||||||
type SurfaceManifest struct {
|
type SurfaceManifest struct {
|
||||||
ID string `json:"id"` // unique identifier: "chat", "editor", "my-dashboard"
|
ID string `json:"id"` // unique identifier: "admin", "settings", "my-dashboard"
|
||||||
Route string `json:"route"` // primary URL pattern: "/", "/editor/:wsId"
|
Route string `json:"route"` // primary URL pattern: "/", "/admin"
|
||||||
AltRoutes []string `json:"alt_routes"` // additional URL patterns: ["/chat/:chatID"]
|
AltRoutes []string `json:"alt_routes"` // additional URL patterns
|
||||||
Title string `json:"title"` // human-readable: "Chat", "Editor"
|
Title string `json:"title"` // human-readable: "Admin", "Settings"
|
||||||
Template string `json:"template"` // Go template name: "surface-chat", "surface-editor"
|
Template string `json:"template"` // Go template name: "surface-admin", "surface-extension"
|
||||||
Components []string `json:"components"` // component IDs used: ["chat-pane", "file-tree"]
|
Components []string `json:"components"` // component IDs used: ["file-tree"]
|
||||||
DataRequires []string `json:"data_requires"` // data loader keys: ["workspace", "models"]
|
DataRequires []string `json:"data_requires"` // data loader keys: ["workspace", "models"]
|
||||||
Scripts []string `json:"scripts"` // JS files (surface-specific, beyond base.html common)
|
Scripts []string `json:"scripts"` // JS files (surface-specific, beyond base.html common)
|
||||||
Styles []string `json:"styles"` // CSS files (surface-specific)
|
Styles []string `json:"styles"` // CSS files (surface-specific)
|
||||||
@@ -244,6 +244,11 @@ func (e *Engine) registerCoreSurfaces() {
|
|||||||
Title: "Workflow Landing", Template: "workflow-landing", Auth: "public",
|
Title: "Workflow Landing", Template: "workflow-landing", Auth: "public",
|
||||||
Layout: "single", Source: "core",
|
Layout: "single", Source: "core",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "welcome", Route: "/welcome",
|
||||||
|
Title: "Welcome", Template: "surface-welcome", Auth: "authenticated",
|
||||||
|
Layout: "single", Source: "core",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +438,7 @@ func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
|
|||||||
// PageRouteMiddleware holds middleware handlers for each auth level.
|
// PageRouteMiddleware holds middleware handlers for each auth level.
|
||||||
// Passed to RegisterPageRoutes by main.go.
|
// Passed to RegisterPageRoutes by main.go.
|
||||||
type PageRouteMiddleware struct {
|
type PageRouteMiddleware struct {
|
||||||
Authenticated gin.HandlerFunc // AuthOrRedirect — for chat, editor, notes, settings
|
Authenticated gin.HandlerFunc // AuthOrRedirect — for authenticated surfaces
|
||||||
Admin []gin.HandlerFunc // AuthOrRedirect + RequireAdminPage
|
Admin []gin.HandlerFunc // AuthOrRedirect + RequireAdminPage
|
||||||
Session gin.HandlerFunc // AuthOrSession — for workflow
|
Session gin.HandlerFunc // AuthOrSession — for workflow
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"switchboard-core/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SeedSurfaces writes core surface manifests to the registry table.
|
// SeedSurfaces writes core surface manifests to the registry table.
|
||||||
@@ -48,11 +50,10 @@ func (e *Engine) SeedSurfaces() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsSurfaceEnabled checks if a surface is enabled in the registry.
|
// IsSurfaceEnabled checks if a surface is enabled in the registry.
|
||||||
// Chat and Admin are always enabled (system-critical).
|
// Admin is always enabled (system-critical).
|
||||||
// Returns true if the surface is not found (fail-open for backward compat).
|
// Returns true if the surface is not found (fail-open for backward compat).
|
||||||
func (e *Engine) IsSurfaceEnabled(surfaceID string) bool {
|
func (e *Engine) IsSurfaceEnabled(surfaceID string) bool {
|
||||||
// Chat and Admin cannot be disabled — they're system-critical
|
if surfaceID == "admin" {
|
||||||
if surfaceID == "chat" || surfaceID == "admin" {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if e.stores.Packages == nil {
|
if e.stores.Packages == nil {
|
||||||
@@ -92,22 +93,39 @@ func (e *Engine) EnabledSurfaceIDs() []string {
|
|||||||
|
|
||||||
// DefaultSurfaceRedirect returns a handler for GET / that redirects to the
|
// DefaultSurfaceRedirect returns a handler for GET / that redirects to the
|
||||||
// configured default surface, falling back to the first enabled extension
|
// configured default surface, falling back to the first enabled extension
|
||||||
// surface, then /admin.
|
// surface, then /welcome.
|
||||||
|
//
|
||||||
|
// This route has no auth middleware, so we opportunistically read the JWT
|
||||||
|
// from the cookie to check user preferences. If the cookie is missing or
|
||||||
|
// invalid, user preference is skipped (falls through to global default).
|
||||||
func (e *Engine) DefaultSurfaceRedirect() gin.HandlerFunc {
|
func (e *Engine) DefaultSurfaceRedirect() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
target := e.resolveDefaultSurface(c.Request.Context())
|
userID := middleware.UserIDFromCookie(c, e.cfg.JWTSecret)
|
||||||
|
target := e.resolveDefaultSurface(c.Request.Context(), userID)
|
||||||
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+target)
|
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveDefaultSurface returns the path to redirect to (without BasePath).
|
// resolveDefaultSurface returns the path to redirect to (without BasePath).
|
||||||
// Priority: configured default_surface → first enabled extension surface → /admin.
|
// Priority: user preference → global default_surface → first enabled extension → /welcome.
|
||||||
func (e *Engine) resolveDefaultSurface(ctx context.Context) string {
|
func (e *Engine) resolveDefaultSurface(ctx context.Context, userID string) string {
|
||||||
if e.stores.GlobalConfig == nil || e.stores.Packages == nil {
|
if e.stores.GlobalConfig == nil || e.stores.Packages == nil {
|
||||||
return "/admin"
|
return "/welcome"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Check configured default_surface
|
// 1. Check user preference (default_surface in user settings)
|
||||||
|
if userID != "" && e.stores.Users != nil {
|
||||||
|
if user, err := e.stores.Users.GetByID(ctx, userID); err == nil && user != nil {
|
||||||
|
if id, _ := user.Settings["default_surface"].(string); id != "" {
|
||||||
|
if path := e.surfacePath(ctx, id); path != "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
// User's chosen surface is missing or disabled — fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check admin-configured global default_surface
|
||||||
if raw, err := e.stores.GlobalConfig.Get(ctx, "default_surface"); err == nil && raw != nil {
|
if raw, err := e.stores.GlobalConfig.Get(ctx, "default_surface"); err == nil && raw != nil {
|
||||||
if id, ok := raw["id"].(string); ok && id != "" {
|
if id, ok := raw["id"].(string); ok && id != "" {
|
||||||
if path := e.surfacePath(ctx, id); path != "" {
|
if path := e.surfacePath(ctx, id); path != "" {
|
||||||
@@ -117,21 +135,24 @@ func (e *Engine) resolveDefaultSurface(ctx context.Context) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. First enabled extension surface
|
// 3. First enabled extension surface (type "surface" or "full")
|
||||||
surfaces, err := e.stores.Packages.ListEnabledByType(ctx, "surface")
|
allPkgs, err := e.stores.Packages.List(ctx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, s := range surfaces {
|
for _, s := range allPkgs {
|
||||||
if s.Source == "core" {
|
if s.Source == "core" || s.Source == "builtin" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if s.Enabled {
|
if !s.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.Type == "surface" || s.Type == "full" {
|
||||||
return "/s/" + s.ID
|
return "/s/" + s.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fallback
|
// 4. Fallback — welcome surface (no extensions installed)
|
||||||
return "/admin"
|
return "/welcome"
|
||||||
}
|
}
|
||||||
|
|
||||||
// surfacePath returns the URL path for a surface ID, or "" if the surface
|
// surfacePath returns the URL path for a surface ID, or "" if the surface
|
||||||
@@ -156,9 +177,8 @@ func (e *Engine) surfacePath(ctx context.Context, id string) string {
|
|||||||
return "/s/" + id
|
return "/s/" + id
|
||||||
}
|
}
|
||||||
|
|
||||||
// disabledRedirect returns a handler that redirects to /admin.
|
// disabledRedirect returns a handler that redirects to /.
|
||||||
// Uses /admin (not /) to avoid a redirect loop when the default surface
|
// The DefaultSurfaceRedirect handler will resolve to the appropriate surface.
|
||||||
// is the one being disabled.
|
|
||||||
func (e *Engine) disabledRedirect() gin.HandlerFunc {
|
func (e *Engine) disabledRedirect() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/admin")
|
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/admin")
|
||||||
|
|||||||
@@ -12,13 +12,8 @@
|
|||||||
<link rel="stylesheet" href="{{.BasePath}}/css/layout.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/layout.css?v={{.Version}}">
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/primitives.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/primitives.css?v={{.Version}}">
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/modals.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/modals.css?v={{.Version}}">
|
||||||
{{/* chat.css removed in v0.37.10 — replaced by sw-chat-surface.css + sw-chat-pane.css */}}
|
|
||||||
{{/* panels.css, pane-container.css, chat-pane.css removed in v0.37.10 */}}
|
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/surfaces.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/surfaces.css?v={{.Version}}">
|
||||||
{{/* splash.css removed in v0.37.12 — login is Preact with sw-login.css */}}
|
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/sw-primitives.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/sw-primitives.css?v={{.Version}}">
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/sw-chat-pane.css?v={{.Version}}">
|
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/sw-notes-pane.css?v={{.Version}}">
|
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/user-menu.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/user-menu.css?v={{.Version}}">
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/tool-grants.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/tool-grants.css?v={{.Version}}">
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/workflow.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/workflow.css?v={{.Version}}">
|
||||||
@@ -94,6 +89,7 @@
|
|||||||
{{if eq .Surface "admin"}}{{template "surface-admin" .}}
|
{{if eq .Surface "admin"}}{{template "surface-admin" .}}
|
||||||
{{else if eq .Surface "team-admin"}}{{template "surface-team-admin" .}}
|
{{else if eq .Surface "team-admin"}}{{template "surface-team-admin" .}}
|
||||||
{{else if eq .Surface "settings"}}{{template "surface-settings" .}}
|
{{else if eq .Surface "settings"}}{{template "surface-settings" .}}
|
||||||
|
{{else if eq .Surface "welcome"}}{{template "surface-welcome" .}}
|
||||||
{{else if and .Manifest (eq .Manifest.Source "extension")}}{{template "surface-extension" .}}
|
{{else if and .Manifest (eq .Manifest.Source "extension")}}{{template "surface-extension" .}}
|
||||||
{{else}}<div style="padding:20px">Unknown surface: {{.Surface}}</div>
|
{{else}}<div style="padding:20px">Unknown surface: {{.Surface}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -128,6 +124,7 @@
|
|||||||
{{if eq .Surface "admin"}}{{template "scripts-admin" .}}{{end}}
|
{{if eq .Surface "admin"}}{{template "scripts-admin" .}}{{end}}
|
||||||
{{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{end}}
|
{{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{end}}
|
||||||
{{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}}
|
{{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}}
|
||||||
|
{{if eq .Surface "welcome"}}{{template "scripts-welcome" .}}{{end}}
|
||||||
{{/* v0.27.0: Extension surface JS — loaded from /surfaces/{id}/js/main.js */}}
|
{{/* v0.27.0: Extension surface JS — loaded from /surfaces/{id}/js/main.js */}}
|
||||||
{{if and .Manifest (eq .Manifest.Source "extension")}}
|
{{if and .Manifest (eq .Manifest.Source "extension")}}
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
{{/*
|
|
||||||
Chat Pane Component - reusable chat scaffold.
|
|
||||||
Usage: {{template "chat-pane" dict "ID" "main"}}
|
|
||||||
Creates mount points: {ID}ChatMessages, {ID}ChatInput, {ID}SendBtn, {ID}ModelSel
|
|
||||||
ChatPane.create() in chat-pane.js binds to these IDs.
|
|
||||||
|
|
||||||
The header bar ({ID}ChatHeader) is hidden by default.
|
|
||||||
Standalone panes (editor assist) show it for chat switching + model selection.
|
|
||||||
*/}}
|
|
||||||
{{define "chat-pane"}}
|
|
||||||
<div class="chat-pane" id="{{.ID}}ChatPane">
|
|
||||||
<div class="chat-pane-header" id="{{.ID}}ChatHeader" style="display:none;">
|
|
||||||
<div class="chat-pane-header-left">
|
|
||||||
<select class="chat-pane-chat-select" id="{{.ID}}ChatSelect" title="Switch chat">
|
|
||||||
<option value="">New conversation</option>
|
|
||||||
</select>
|
|
||||||
<button class="chat-pane-new-btn" id="{{.ID}}ChatNewBtn" title="New chat">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="chat-pane-header-right">
|
|
||||||
<div class="chat-pane-model-sel" id="{{.ID}}ModelSel"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="chat-pane-messages" id="{{.ID}}ChatMessages"></div>
|
|
||||||
<div class="chat-pane-input-bar">
|
|
||||||
<div class="chat-pane-input-wrap">
|
|
||||||
<div class="chat-pane-input" id="{{.ID}}ChatInput"></div>
|
|
||||||
<button class="chat-pane-send" id="{{.ID}}SendBtn" title="Send">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="chat-pane-toolbar" id="{{.ID}}Toolbar"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
25
server/pages/templates/surfaces/welcome.html
Normal file
25
server/pages/templates/surfaces/welcome.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{{/*
|
||||||
|
Welcome surface — shown when no extension surfaces are installed.
|
||||||
|
Renders topbar + welcome card with getting-started info.
|
||||||
|
*/}}
|
||||||
|
|
||||||
|
{{define "surface-welcome"}}
|
||||||
|
<div id="welcome-mount" style="display:flex;flex-direction:column;height:100%;"></div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts-welcome"}}
|
||||||
|
<script type="module" nonce="{{.CSPNonce}}">
|
||||||
|
const { h, render } = await import('{{.BasePath}}/js/sw/vendor/preact.module.js');
|
||||||
|
const hooksModule = await import('{{.BasePath}}/js/sw/vendor/hooks.module.js');
|
||||||
|
const { default: htm } = await import('{{.BasePath}}/js/sw/vendor/htm.module.js');
|
||||||
|
const html = htm.bind(h);
|
||||||
|
window.preact = window.preact || { h, render };
|
||||||
|
window.hooks = window.hooks || hooksModule;
|
||||||
|
window.html = window.html || html;
|
||||||
|
|
||||||
|
const { boot } = await import('{{.BasePath}}/js/sw/sdk/index.js?v={{.Version}}');
|
||||||
|
await boot();
|
||||||
|
|
||||||
|
await import('{{.BasePath}}/js/sw/surfaces/welcome/index.js?v={{.Version}}');
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
<link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}">
|
<link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}">
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/variables.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/variables.css?v={{.Version}}">
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/primitives.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/primitives.css?v={{.Version}}">
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/chat.css?v={{.Version}}">
|
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body { font-family: var(--font); background: var(--bg); color: var(--text); }
|
body { font-family: var(--font); background: var(--bg); color: var(--text); }
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func TestPVC_PutGetRoundTrip(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
data := []byte("hello, storage world")
|
data := []byte("hello, storage world")
|
||||||
key := "files/channel-1/att-1_test.txt"
|
key := "files/test-1/att-1_test.txt"
|
||||||
|
|
||||||
// Put
|
// Put
|
||||||
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
||||||
@@ -129,18 +129,18 @@ func TestPVC_DeletePrefix(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Create several files under a channel prefix
|
// Create several files under a channel prefix
|
||||||
prefix := "files/channel-abc/"
|
prefix := "files/test-abc/"
|
||||||
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
|
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
|
||||||
key := prefix + name
|
key := prefix + name
|
||||||
_ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
|
_ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also create a file in a different channel
|
// Also create a file in a different channel
|
||||||
other := "files/channel-other/keep.txt"
|
other := "files/test-other/keep.txt"
|
||||||
_ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain")
|
_ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain")
|
||||||
|
|
||||||
// Delete the channel prefix
|
// Delete the channel prefix
|
||||||
err := s.DeletePrefix(ctx, "files/channel-abc")
|
err := s.DeletePrefix(ctx, "files/test-abc")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeletePrefix: %v", err)
|
t.Fatalf("DeletePrefix: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func TestS3_KeyValidation(t *testing.T) {
|
|||||||
|
|
||||||
good := []string{
|
good := []string{
|
||||||
"files/ch/f.txt",
|
"files/ch/f.txt",
|
||||||
"files/channel-1/att-abc_test.pdf",
|
"files/test-1/att-abc_test.pdf",
|
||||||
"processing/abc123/status.json",
|
"processing/abc123/status.json",
|
||||||
}
|
}
|
||||||
for _, key := range good {
|
for _, key := range good {
|
||||||
@@ -145,7 +145,7 @@ func TestS3_Integration_PutGetRoundTrip(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
data := []byte("hello, S3 storage world")
|
data := []byte("hello, S3 storage world")
|
||||||
key := "files/channel-1/att-1_test.txt"
|
key := "files/test-1/att-1_test.txt"
|
||||||
|
|
||||||
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -214,16 +214,16 @@ func TestS3_Integration_DeletePrefix(t *testing.T) {
|
|||||||
s := testS3Store(t)
|
s := testS3Store(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
prefix := "files/channel-abc/"
|
prefix := "files/test-abc/"
|
||||||
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
|
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
|
||||||
key := prefix + name
|
key := prefix + name
|
||||||
_ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
|
_ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
|
||||||
}
|
}
|
||||||
|
|
||||||
other := "files/channel-other/keep.txt"
|
other := "files/test-other/keep.txt"
|
||||||
_ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain")
|
_ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain")
|
||||||
|
|
||||||
err := s.DeletePrefix(ctx, "files/channel-abc")
|
err := s.DeletePrefix(ctx, "files/test-abc")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DeletePrefix: %v", err)
|
t.Fatalf("DeletePrefix: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"switchboard-core/models"
|
"switchboard-core/models"
|
||||||
)
|
)
|
||||||
@@ -224,8 +223,6 @@ func (s *TeamStore) IsMember(ctx context.Context, teamID, userID string) (bool,
|
|||||||
return exists, err
|
return exists, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// unused but keeping for reference
|
|
||||||
var _ = fmt.Sprintf
|
|
||||||
|
|
||||||
// ── CS1 additions (v0.29.0) ─────────────────────────────────────────────
|
// ── CS1 additions (v0.29.0) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"switchboard-core/models"
|
"switchboard-core/models"
|
||||||
@@ -231,8 +230,6 @@ func (s *TeamStore) IsMember(ctx context.Context, teamID, userID string) (bool,
|
|||||||
return exists, err
|
return exists, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// unused but keeping for reference
|
|
||||||
var _ = fmt.Sprintf
|
|
||||||
|
|
||||||
// ── CS1 additions (v0.29.0) ─────────────────────────────────────────────
|
// ── CS1 additions (v0.29.0) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -158,109 +158,6 @@
|
|||||||
margin-left: auto; font-size: 10px; color: var(--text-3);
|
margin-left: auto; font-size: 10px; color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chat/Channel list */
|
|
||||||
.sidebar-chats {
|
|
||||||
flex: 1; overflow-y: auto; overflow-x: hidden;
|
|
||||||
padding: 8px 6px;
|
|
||||||
}
|
|
||||||
.sidebar-chats::-webkit-scrollbar { width: 4px; }
|
|
||||||
|
|
||||||
/* Channels section header */
|
|
||||||
.sidebar-section-header {
|
|
||||||
display: flex; align-items: center; gap: 6px;
|
|
||||||
padding: 8px 10px 4px; cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.sidebar-section-header .section-arrow {
|
|
||||||
font-size: 10px; color: var(--text-3); transition: transform var(--transition);
|
|
||||||
}
|
|
||||||
.sidebar-section-header.collapsed .section-arrow { transform: rotate(-90deg); }
|
|
||||||
.sidebar-section-header .section-label {
|
|
||||||
font-size: 11px; font-weight: 600; color: var(--text-3);
|
|
||||||
text-transform: uppercase; letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
.sidebar-section-header .section-count {
|
|
||||||
font-size: 10px; color: var(--text-3); margin-left: auto;
|
|
||||||
}
|
|
||||||
.sidebar.collapsed .sidebar-section-header { display: none; }
|
|
||||||
|
|
||||||
.channel-item {
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
padding: 7px 10px; border-radius: var(--radius);
|
|
||||||
cursor: pointer; font-size: 13px; color: var(--text-2);
|
|
||||||
transition: background var(--transition); position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.channel-item:hover { background: var(--bg-hover); color: var(--text); }
|
|
||||||
.channel-item.active { background: var(--bg-raised); color: var(--text); }
|
|
||||||
.channel-item .channel-hash { color: var(--text-3); font-weight: 600; flex-shrink: 0; }
|
|
||||||
.channel-item .channel-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.sidebar.collapsed .channel-item .channel-name { display: none; }
|
|
||||||
|
|
||||||
.chat-group-label {
|
|
||||||
font-size: 11px; font-weight: 600; color: var(--text-3);
|
|
||||||
padding: 12px 10px 4px; text-transform: uppercase; letter-spacing: 0.5px;
|
|
||||||
white-space: nowrap; overflow: hidden;
|
|
||||||
}
|
|
||||||
.sidebar.collapsed .chat-group-label { display: none; }
|
|
||||||
|
|
||||||
.chat-item {
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
padding: 7px 10px; border-radius: var(--radius);
|
|
||||||
cursor: pointer; font-size: 13px; color: var(--text-2);
|
|
||||||
transition: background var(--transition); position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.chat-item:hover { background: var(--bg-hover); color: var(--text); }
|
|
||||||
.chat-item.active { background: var(--bg-raised); color: var(--text); }
|
|
||||||
.chat-item-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.chat-item-time {
|
|
||||||
font-size: 10px; color: var(--text-3); flex-shrink: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.chat-item-delete {
|
|
||||||
opacity: 0; position: absolute; right: 6px; background: var(--bg-raised);
|
|
||||||
border: none; color: var(--text-3); cursor: pointer; font-size: 11px;
|
|
||||||
padding: 2px 6px; border-radius: 4px; transition: opacity var(--transition);
|
|
||||||
}
|
|
||||||
.chat-item:hover .chat-item-delete { opacity: 1; }
|
|
||||||
.chat-item:hover .chat-item-time { display: none; }
|
|
||||||
.sidebar.collapsed .chat-item-title,
|
|
||||||
.sidebar.collapsed .chat-item-time,
|
|
||||||
.sidebar.collapsed .chat-item-delete { display: none; }
|
|
||||||
.sidebar.collapsed .sidebar-chats { visibility: hidden; }
|
|
||||||
|
|
||||||
.sidebar-empty {
|
|
||||||
color: var(--text-3); font-size: 12px; text-align: center; padding: 2rem 0.5rem;
|
|
||||||
}
|
|
||||||
.sidebar.collapsed .sidebar-empty { display: none; }
|
|
||||||
|
|
||||||
/* User area */
|
|
||||||
.sidebar-bottom {
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
padding: 8px 10px; position: relative;
|
|
||||||
}
|
|
||||||
.sidebar-bottom-divider {
|
|
||||||
height: 1px; background: var(--border);
|
|
||||||
margin: 4px 8px;
|
|
||||||
}
|
|
||||||
.sidebar-notes-btn {
|
|
||||||
color: var(--text-2); font-size: 13px;
|
|
||||||
}
|
|
||||||
.sidebar-notes-btn:hover { background: var(--bg-hover); color: var(--text); }
|
|
||||||
.sidebar.collapsed .sidebar-notes-btn .sb-label { opacity: 0; width: 0; }
|
|
||||||
.sidebar.collapsed .sidebar-notes-btn { justify-content: center; padding: 8px 0; gap: 0; }
|
|
||||||
.sidebar-editor-btn {
|
|
||||||
display: flex; align-items: center; gap: 10px;
|
|
||||||
padding: 4px 8px; color: var(--text-2); text-decoration: none;
|
|
||||||
font-size: 13px; white-space: nowrap;
|
|
||||||
transition: background var(--transition), color var(--transition);
|
|
||||||
}
|
|
||||||
.sidebar-editor-btn:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; }
|
|
||||||
.sidebar-editor-btn svg { flex-shrink: 0; }
|
|
||||||
.sidebar.collapsed .sidebar-editor-btn .sb-label { opacity: 0; width: 0; }
|
|
||||||
.sidebar.collapsed .sidebar-editor-btn { justify-content: center; padding: 8px 0; gap: 0; }
|
|
||||||
.sidebar.collapsed .sidebar-bottom-divider { margin: 4px 12px; }
|
|
||||||
|
|
||||||
.user-btn {
|
.user-btn {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* ── surfaces.css ─────────────────────────────
|
/* ── surfaces.css ─────────────────────────────
|
||||||
Admin, settings, editor, projects, stat cards, data tables
|
Admin, settings, editor, stat cards, data tables
|
||||||
──────────────────────────────────────────── */
|
──────────────────────────────────────────── */
|
||||||
|
|
||||||
/* ── Admin Storage Panel ────────────────────── */
|
/* ── Admin Storage Panel ────────────────────── */
|
||||||
@@ -19,193 +19,6 @@
|
|||||||
.admin-storage-value { font-size: 14px; font-weight: 500; }
|
.admin-storage-value { font-size: 14px; font-weight: 500; }
|
||||||
|
|
||||||
|
|
||||||
/* =========================================
|
|
||||||
PROJECTS (v0.19.0)
|
|
||||||
========================================= */
|
|
||||||
|
|
||||||
.project-group {
|
|
||||||
margin-bottom: 2px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.project-group.drag-over {
|
|
||||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
||||||
outline: 1px dashed var(--accent);
|
|
||||||
outline-offset: -1px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-header {
|
|
||||||
display: flex; align-items: center; gap: 6px;
|
|
||||||
padding: 6px 10px; cursor: pointer; user-select: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
transition: background var(--transition);
|
|
||||||
}
|
|
||||||
.project-header:hover { background: var(--bg-hover); }
|
|
||||||
|
|
||||||
.project-arrow {
|
|
||||||
font-size: 10px; color: var(--text-3); width: 12px; text-align: center; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.project-dot {
|
|
||||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
|
||||||
background: var(--text-3); display: inline-block;
|
|
||||||
}
|
|
||||||
.project-name {
|
|
||||||
font-size: 12px; font-weight: 600; color: var(--text-2);
|
|
||||||
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.project-count {
|
|
||||||
font-size: 10px; color: var(--text-3); flex-shrink: 0; margin-left: auto;
|
|
||||||
}
|
|
||||||
.project-menu-btn {
|
|
||||||
opacity: 0; background: none; border: none; color: var(--text-3);
|
|
||||||
cursor: pointer; font-size: 14px; padding: 0 4px; border-radius: 4px;
|
|
||||||
transition: opacity var(--transition), background var(--transition);
|
|
||||||
line-height: 1; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.project-header:hover .project-menu-btn { opacity: 1; }
|
|
||||||
.project-header:hover .project-count { display: none; }
|
|
||||||
.project-menu-btn:hover { background: var(--bg-raised); color: var(--text); }
|
|
||||||
|
|
||||||
.project-empty {
|
|
||||||
font-size: 11px; color: var(--text-3); padding: 4px 10px 8px 28px;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Active project indicator (v0.19.1) */
|
|
||||||
.project-group.active { border-left: 2px solid var(--accent); }
|
|
||||||
.project-group.active .project-header { background: color-mix(in srgb, var(--accent) 6%, transparent); }
|
|
||||||
.project-pin { font-size: 10px; margin-left: 2px; opacity: 0.7; }
|
|
||||||
|
|
||||||
.sidebar.collapsed .project-group { display: none; }
|
|
||||||
.sidebar.collapsed .recent-section .chat-group-label { display: none; }
|
|
||||||
|
|
||||||
/* Recent section drop target */
|
|
||||||
.recent-section { min-height: 40px; }
|
|
||||||
.recent-section.drag-over {
|
|
||||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dragging state */
|
|
||||||
.chat-item.dragging { opacity: 0.4; }
|
|
||||||
.chat-item[draggable="true"] { cursor: grab; }
|
|
||||||
.chat-item[draggable="true"]:active { cursor: grabbing; }
|
|
||||||
|
|
||||||
/* Context menu */
|
|
||||||
.project-ctx-menu {
|
|
||||||
position: fixed; z-index: 9999;
|
|
||||||
background: var(--bg-surface); border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius); box-shadow: var(--shadow);
|
|
||||||
padding: 4px; min-width: 160px; max-width: 220px;
|
|
||||||
}
|
|
||||||
.ctx-item {
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
width: 100%; padding: 7px 10px; border: none; background: none;
|
|
||||||
color: var(--text-2); font-size: 13px; cursor: pointer;
|
|
||||||
border-radius: calc(var(--radius) - 2px); text-align: left;
|
|
||||||
white-space: nowrap; overflow: hidden;
|
|
||||||
}
|
|
||||||
.ctx-item:hover { background: var(--bg-hover); color: var(--text); }
|
|
||||||
.ctx-item.ctx-danger:hover { background: var(--danger-bg, #fef2f2); color: var(--danger, #ef4444); }
|
|
||||||
.ctx-icon { width: 16px; text-align: center; flex-shrink: 0; font-size: 12px; }
|
|
||||||
.ctx-divider { height: 1px; background: var(--border); margin: 3px 0; }
|
|
||||||
.ctx-hint { font-size: 11px; color: var(--text-3); padding: 6px 10px; }
|
|
||||||
|
|
||||||
/* Time labels inside project-grouped sidebar */
|
|
||||||
.chat-time-label { padding-left: 12px; font-size: 10px; }
|
|
||||||
|
|
||||||
/* ── Project Detail Panel (v0.19.1) ───────── */
|
|
||||||
.project-panel { padding: 16px; display: flex; flex-direction: column; gap: 16px; }
|
|
||||||
.project-panel-section { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.project-panel-section-header { display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
.project-panel-label { font-size: 12px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px; }
|
|
||||||
.project-panel-textarea {
|
|
||||||
width: 100%; resize: vertical; min-height: 80px; padding: 8px;
|
|
||||||
font-family: var(--mono); font-size: 13px;
|
|
||||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.project-panel-textarea:focus { outline: none; border-color: var(--accent); }
|
|
||||||
.project-panel-status { font-size: 11px; margin-left: 8px; }
|
|
||||||
.project-panel-status.success { color: var(--success, #22c55e); }
|
|
||||||
.project-panel-status.error { color: var(--danger, #ef4444); }
|
|
||||||
.project-panel-list { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.project-panel-item {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: 6px 8px; border-radius: var(--radius);
|
|
||||||
background: var(--bg); border: 1px solid var(--border);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.project-panel-item-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.project-panel-item-remove {
|
|
||||||
background: none; border: none; cursor: pointer; color: var(--text-3);
|
|
||||||
font-size: 14px; padding: 2px 4px; border-radius: 4px; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.project-panel-item-remove:hover { color: var(--danger, #ef4444); background: var(--danger-bg, #fef2f2); }
|
|
||||||
.project-panel-empty { font-size: 12px; color: var(--text-3); font-style: italic; padding: 4px 0; }
|
|
||||||
.project-panel-select {
|
|
||||||
width: 100%; padding: 6px 8px; font-size: 13px;
|
|
||||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.project-panel-select:focus { outline: none; border-color: var(--accent); }
|
|
||||||
.project-panel-hint { font-size: 11px; color: var(--text-3); margin-top: 2px; }
|
|
||||||
.project-panel-ws-row { display: flex; gap: 6px; align-items: center; }
|
|
||||||
.project-panel-ws-row select { flex: 1; }
|
|
||||||
.project-panel-checkbox {
|
|
||||||
display: flex; align-items: center; gap: 8px; font-size: 13px;
|
|
||||||
cursor: pointer; color: var(--text-2);
|
|
||||||
}
|
|
||||||
.project-panel-checkbox input { cursor: pointer; }
|
|
||||||
.project-panel-footer { padding-top: 8px; border-top: 1px solid var(--border); }
|
|
||||||
.project-show-archived {
|
|
||||||
background: none; border: none; cursor: pointer;
|
|
||||||
font-size: 11px; color: var(--text-3); padding: 6px 10px;
|
|
||||||
width: 100%; text-align: left;
|
|
||||||
}
|
|
||||||
.project-show-archived:hover { color: var(--text-2); }
|
|
||||||
.project-group.archived { opacity: 0.5; }
|
|
||||||
.project-group.archived .project-header { font-style: italic; }
|
|
||||||
|
|
||||||
/* ── Router Workspace Picker (v0.21.6) ──── */
|
|
||||||
|
|
||||||
.router-picker-overlay {
|
|
||||||
position: fixed; inset: 0; z-index: 9999;
|
|
||||||
background: rgba(0,0,0,0.5); backdrop-filter: blur(2px);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
.router-picker {
|
|
||||||
background: var(--bg-primary, #1a1a2e); color: var(--text, #e0e0e0);
|
|
||||||
border: 1px solid var(--border, #333); border-radius: 12px;
|
|
||||||
padding: 24px; min-width: 320px; max-width: 440px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
|
||||||
}
|
|
||||||
.router-picker h3 { margin: 0 0 4px; font-size: 16px; font-weight: 600; }
|
|
||||||
.router-picker p { margin: 0 0 16px; font-size: 13px; color: var(--text-2, #999); }
|
|
||||||
.router-picker-list {
|
|
||||||
display: flex; flex-direction: column; gap: 4px;
|
|
||||||
max-height: 280px; overflow-y: auto; margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.router-picker-empty {
|
|
||||||
padding: 24px; text-align: center; color: var(--text-3, #666); font-size: 13px;
|
|
||||||
}
|
|
||||||
.router-picker-item {
|
|
||||||
display: flex; align-items: center; gap: 10px;
|
|
||||||
padding: 10px 12px; border: 1px solid var(--border, #333);
|
|
||||||
border-radius: 8px; background: none; color: var(--text, #e0e0e0);
|
|
||||||
cursor: pointer; font-size: 14px; text-align: left;
|
|
||||||
transition: background 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
.router-picker-item:hover {
|
|
||||||
background: var(--hover, rgba(255,255,255,0.05));
|
|
||||||
border-color: var(--accent, #6366f1);
|
|
||||||
}
|
|
||||||
.router-picker-icon { font-size: 18px; flex-shrink: 0; }
|
|
||||||
.router-picker-actions {
|
|
||||||
display: flex; gap: 8px; justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Settings Surface ────────────────────── */
|
/* ── Settings Surface ────────────────────── */
|
||||||
|
|
||||||
.surface-settings {
|
.surface-settings {
|
||||||
@@ -404,19 +217,6 @@
|
|||||||
font-size: 11px; color: var(--text-3);
|
font-size: 11px; color: var(--text-3);
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
}
|
}
|
||||||
.editor-chat {
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
min-width: 200px; background: var(--bg-surface);
|
|
||||||
}
|
|
||||||
.editor-chat-header {
|
|
||||||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
}
|
|
||||||
.editor-chat-messages { flex: 1; overflow-y: auto; padding: 12px; }
|
|
||||||
.editor-chat-input {
|
|
||||||
padding: 8px 12px; border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Stat Cards Grid ──────────────────────── */
|
/* ── Stat Cards Grid ──────────────────────── */
|
||||||
.stat-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; margin-bottom: 24px; }
|
.stat-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; margin-bottom: 24px; }
|
||||||
.stat-card {
|
.stat-card {
|
||||||
@@ -720,45 +520,3 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Chats section folder items ── */
|
|
||||||
|
|
||||||
.sb-folder-group { margin-bottom: 1px; }
|
|
||||||
.sb-folder-group.drag-over .sb-folder-header { background: var(--accent-dim); }
|
|
||||||
.sb-folder-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.sb-folder-menu {
|
|
||||||
visibility: hidden; background: none; border: none; cursor: pointer;
|
|
||||||
color: var(--text-3); font-size: 14px; padding: 0 2px; line-height: 1;
|
|
||||||
flex-shrink: 0; border-radius: 3px;
|
|
||||||
}
|
|
||||||
.sb-folder-menu:hover { color: var(--text); background: var(--bg-hover); }
|
|
||||||
.sb-folder-header:hover .sb-folder-menu { visibility: visible; }
|
|
||||||
.sb-folder-empty { padding: 4px 14px 6px 28px; font-size: 11px; color: var(--text-3); font-style: italic; }
|
|
||||||
|
|
||||||
.sb-folder-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin: 0 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-3);
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.sb-folder-header:hover { background: var(--bg-hover); color: var(--text-2); }
|
|
||||||
|
|
||||||
.sb-folder-arrow { font-size: 9px; flex-shrink: 0; width: 12px; text-align: center; }
|
|
||||||
|
|
||||||
/* v0.23.2: Drop zone for chats section */
|
|
||||||
#sbBodyChats.drag-over { outline: 2px dashed var(--accent); outline-offset: -2px; border-radius: 4px; }
|
|
||||||
.sb-unfiled-zone { min-height: 30px; border-radius: 4px; margin-top: 2px; }
|
|
||||||
.sb-unfiled-zone.drag-over { outline: 2px dashed var(--accent); outline-offset: -2px; }
|
|
||||||
.sb-unfiled-hint { padding: 6px 14px; font-size: 11px; color: var(--text-3); font-style: italic; }
|
|
||||||
|
|
||||||
/* Indented chat items inside folders */
|
|
||||||
.chat-item.indented { padding-left: 28px; }
|
|
||||||
|
|
||||||
/* ── Notes Surface ─────────────────────── */
|
|
||||||
/* v0.37.11: Moved to sw-notes-surface.css (Preact surface) */
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const { render } = preact;
|
|||||||
|
|
||||||
import { ToastContainer } from '../../primitives/toast.js';
|
import { ToastContainer } from '../../primitives/toast.js';
|
||||||
import { DialogStack } from '../../shell/dialog-stack.js';
|
import { DialogStack } from '../../shell/dialog-stack.js';
|
||||||
|
import { UserMenu } from '../../shell/user-menu.js';
|
||||||
|
|
||||||
// ── Category → Section mapping ──────────────
|
// ── Category → Section mapping ──────────────
|
||||||
const ADMIN_SECTIONS = {
|
const ADMIN_SECTIONS = {
|
||||||
@@ -121,26 +122,6 @@ function AdminSurface() {
|
|||||||
}
|
}
|
||||||
}, [section]);
|
}, [section]);
|
||||||
|
|
||||||
// Back button with return URL stash
|
|
||||||
useEffect(() => {
|
|
||||||
const RETURN_KEY = 'sb_admin_return';
|
|
||||||
if (!sessionStorage.getItem(RETURN_KEY)) {
|
|
||||||
const ref = document.referrer;
|
|
||||||
if (ref && ref.startsWith(location.origin) && !ref.includes('/admin')) {
|
|
||||||
sessionStorage.setItem(RETURN_KEY, ref);
|
|
||||||
} else {
|
|
||||||
sessionStorage.setItem(RETURN_KEY, location.origin + BASE + '/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const backClick = useCallback((e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const RETURN_KEY = 'sb_admin_return';
|
|
||||||
const returnURL = sessionStorage.getItem(RETURN_KEY);
|
|
||||||
sessionStorage.removeItem(RETURN_KEY);
|
|
||||||
location.href = returnURL || BASE + '/';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const navClick = useCallback((e) => {
|
const navClick = useCallback((e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -155,15 +136,6 @@ function AdminSurface() {
|
|||||||
<div class="surface-admin">
|
<div class="surface-admin">
|
||||||
${/* Top Bar */``}
|
${/* Top Bar */``}
|
||||||
<div class="admin-topbar">
|
<div class="admin-topbar">
|
||||||
<a href="${BASE}/" class="admin-topbar-back" onClick=${backClick}>
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
||||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<line x1="19" y1="12" x2="5" y2="12"/>
|
|
||||||
<polyline points="12 19 5 12 12 5"/>
|
|
||||||
</svg>
|
|
||||||
Back
|
|
||||||
</a>
|
|
||||||
<div class="admin-topbar-sep"></div>
|
|
||||||
<span class="admin-topbar-title">
|
<span class="admin-topbar-title">
|
||||||
<img src="${BASE}/favicon.svg" alt="" width="18" height="18" style="vertical-align:-3px;" />
|
<img src="${BASE}/favicon.svg" alt="" width="18" height="18" style="vertical-align:-3px;" />
|
||||||
Administration
|
Administration
|
||||||
@@ -179,6 +151,10 @@ function AdminSurface() {
|
|||||||
</a>
|
</a>
|
||||||
`)}
|
`)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-left:auto;">
|
||||||
|
<${UserMenu} placement="down-right" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-body">
|
<div class="admin-body">
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ const { useState, useEffect, useCallback } = hooks;
|
|||||||
|
|
||||||
export default function SettingsSection() {
|
export default function SettingsSection() {
|
||||||
const [cfg, setCfg] = useState({});
|
const [cfg, setCfg] = useState({});
|
||||||
const [models, setModels] = useState([]);
|
|
||||||
const [surfaces, setSurfaces] = useState([]);
|
const [surfaces, setSurfaces] = useState([]);
|
||||||
const [vault, setVault] = useState(null);
|
const [vault, setVault] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -14,9 +13,8 @@ export default function SettingsSection() {
|
|||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [s, m, v, surfList] = await Promise.all([
|
const [s, v, surfList] = await Promise.all([
|
||||||
sw.api.admin.settings.get(),
|
sw.api.admin.settings.get(),
|
||||||
sw.api.admin.models.list().catch(() => []),
|
|
||||||
sw.api.admin.vault.status().catch(() => null),
|
sw.api.admin.vault.status().catch(() => null),
|
||||||
sw.api.admin.surfaces.list().catch(() => []),
|
sw.api.admin.surfaces.list().catch(() => []),
|
||||||
]);
|
]);
|
||||||
@@ -28,11 +26,6 @@ export default function SettingsSection() {
|
|||||||
default_surface: cfg_.default_surface?.id || '',
|
default_surface: cfg_.default_surface?.id || '',
|
||||||
allow_registration: pol.allow_registration === 'true',
|
allow_registration: pol.allow_registration === 'true',
|
||||||
registration_default_state: cfg_.registration_default_state || 'pending',
|
registration_default_state: cfg_.registration_default_state || 'pending',
|
||||||
system_prompt: cfg_.system_prompt || '',
|
|
||||||
default_model: pol.default_model || '',
|
|
||||||
allow_user_byok: pol.allow_user_byok === 'true',
|
|
||||||
allow_user_personas: pol.allow_user_personas === 'true',
|
|
||||||
allow_kb_direct_access: pol.allow_kb_direct_access === 'true' || pol.kb_direct_access === 'true',
|
|
||||||
banner_enabled: !!cfg_.banner?.enabled,
|
banner_enabled: !!cfg_.banner?.enabled,
|
||||||
banner_text: cfg_.banner?.text || '',
|
banner_text: cfg_.banner?.text || '',
|
||||||
banner_bg: cfg_.banner?.bg || '#007a33',
|
banner_bg: cfg_.banner?.bg || '#007a33',
|
||||||
@@ -43,17 +36,7 @@ export default function SettingsSection() {
|
|||||||
message_variant: cfg_.message?.variant || 'info',
|
message_variant: cfg_.message?.variant || 'info',
|
||||||
footer_enabled: !!cfg_.footer?.enabled,
|
footer_enabled: !!cfg_.footer?.enabled,
|
||||||
footer_text: cfg_.footer?.text || '',
|
footer_text: cfg_.footer?.text || '',
|
||||||
search_provider: cfg_.search_provider || 'duckduckgo',
|
|
||||||
search_endpoint: cfg_.search_endpoint || '',
|
|
||||||
search_api_key: cfg_.search_api_key || '',
|
|
||||||
search_max_results: cfg_.search_max_results || 5,
|
|
||||||
compaction_enabled: !!cfg_.auto_compaction_enabled,
|
|
||||||
compaction_threshold: cfg_.compaction_threshold || 70,
|
|
||||||
compaction_cooldown: cfg_.compaction_cooldown || 30,
|
|
||||||
memory_extraction_enabled: !!cfg_.memory_extraction_enabled,
|
|
||||||
memory_auto_approve: !!cfg_.memory_auto_approve,
|
|
||||||
});
|
});
|
||||||
setModels(m || []);
|
|
||||||
setVault(v);
|
setVault(v);
|
||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
@@ -69,16 +52,9 @@ export default function SettingsSection() {
|
|||||||
// Default surface
|
// Default surface
|
||||||
await sw.api.admin.settings.update('default_surface', { value: cfg.default_surface ? { id: cfg.default_surface } : null });
|
await sw.api.admin.settings.update('default_surface', { value: cfg.default_surface ? { id: cfg.default_surface } : null });
|
||||||
|
|
||||||
// Policies
|
// Registration
|
||||||
await sw.api.admin.settings.update('allow_registration', { value: String(cfg.allow_registration) });
|
await sw.api.admin.settings.update('allow_registration', { value: String(cfg.allow_registration) });
|
||||||
await sw.api.admin.settings.update('registration_default_state', { value: cfg.registration_default_state });
|
await sw.api.admin.settings.update('registration_default_state', { value: cfg.registration_default_state });
|
||||||
await sw.api.admin.settings.update('allow_user_byok', { value: String(cfg.allow_user_byok) });
|
|
||||||
await sw.api.admin.settings.update('allow_user_personas', { value: String(cfg.allow_user_personas) });
|
|
||||||
await sw.api.admin.settings.update('allow_kb_direct_access', { value: String(cfg.allow_kb_direct_access) });
|
|
||||||
|
|
||||||
// System prompt & default model
|
|
||||||
await sw.api.admin.settings.update('system_prompt', { value: cfg.system_prompt });
|
|
||||||
if (cfg.default_model) await sw.api.admin.settings.update('default_model', { value: cfg.default_model });
|
|
||||||
|
|
||||||
// Banner
|
// Banner
|
||||||
await sw.api.admin.settings.update('banner', { value: {
|
await sw.api.admin.settings.update('banner', { value: {
|
||||||
@@ -102,23 +78,6 @@ export default function SettingsSection() {
|
|||||||
text: cfg.footer_text,
|
text: cfg.footer_text,
|
||||||
}});
|
}});
|
||||||
|
|
||||||
// Search
|
|
||||||
await sw.api.admin.settings.update('search_provider', { value: cfg.search_provider });
|
|
||||||
if (cfg.search_provider === 'searxng') {
|
|
||||||
await sw.api.admin.settings.update('search_endpoint', { value: cfg.search_endpoint });
|
|
||||||
await sw.api.admin.settings.update('search_api_key', { value: cfg.search_api_key });
|
|
||||||
}
|
|
||||||
await sw.api.admin.settings.update('search_max_results', { value: cfg.search_max_results });
|
|
||||||
|
|
||||||
// Compaction
|
|
||||||
await sw.api.admin.settings.update('auto_compaction_enabled', { value: String(cfg.compaction_enabled) });
|
|
||||||
await sw.api.admin.settings.update('compaction_threshold', { value: cfg.compaction_threshold });
|
|
||||||
await sw.api.admin.settings.update('compaction_cooldown', { value: cfg.compaction_cooldown });
|
|
||||||
|
|
||||||
// Memory
|
|
||||||
await sw.api.admin.settings.update('memory_extraction_enabled', { value: String(cfg.memory_extraction_enabled) });
|
|
||||||
await sw.api.admin.settings.update('memory_auto_approve', { value: String(cfg.memory_auto_approve) });
|
|
||||||
|
|
||||||
sw.toast('Settings saved', 'success');
|
sw.toast('Settings saved', 'success');
|
||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
@@ -155,23 +114,6 @@ export default function SettingsSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section"><h3>System Prompt</h3>
|
|
||||||
<textarea rows="4" placeholder="Global system prompt\u2026" value=${cfg.system_prompt} onInput=${e => set('system_prompt', e.target.value)}></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-section"><h3>Default Model</h3>
|
|
||||||
<select value=${cfg.default_model} onChange=${e => set('default_model', e.target.value)}>
|
|
||||||
<option value="">-- None (first visible) --</option>
|
|
||||||
${models.map(m => html`<option key=${m.id} value=${m.model_id || m.id}>${m.display_name || m.model_id || m.id}</option>`)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-section"><h3>Policies</h3>
|
|
||||||
<label class="toggle-label"><input type="checkbox" checked=${cfg.allow_user_byok} onChange=${e => set('allow_user_byok', e.target.checked)} /><span class="toggle-track"></span><span>Allow BYOK (user API keys)</span></label>
|
|
||||||
<label class="toggle-label"><input type="checkbox" checked=${cfg.allow_user_personas} onChange=${e => set('allow_user_personas', e.target.checked)} /><span class="toggle-track"></span><span>Allow user personas</span></label>
|
|
||||||
<label class="toggle-label"><input type="checkbox" checked=${cfg.allow_kb_direct_access} onChange=${e => set('allow_kb_direct_access', e.target.checked)} /><span class="toggle-track"></span><span>Allow direct KB access</span></label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-section"><h3>Environment Banner</h3>
|
<div class="settings-section"><h3>Environment Banner</h3>
|
||||||
<label class="toggle-label"><input type="checkbox" checked=${cfg.banner_enabled} onChange=${e => set('banner_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Show environment banner</span></label>
|
<label class="toggle-label"><input type="checkbox" checked=${cfg.banner_enabled} onChange=${e => set('banner_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Show environment banner</span></label>
|
||||||
${cfg.banner_enabled && html`
|
${cfg.banner_enabled && html`
|
||||||
@@ -217,36 +159,6 @@ export default function SettingsSection() {
|
|||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section"><h3>Web Search</h3>
|
|
||||||
<div class="form-group"><label>Provider</label>
|
|
||||||
<select value=${cfg.search_provider} onChange=${e => set('search_provider', e.target.value)}>
|
|
||||||
<option value="duckduckgo">DuckDuckGo</option><option value="searxng">SearXNG</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
${cfg.search_provider === 'searxng' && html`
|
|
||||||
<div class="form-group"><label>Endpoint</label><input value=${cfg.search_endpoint} onInput=${e => set('search_endpoint', e.target.value)} placeholder="https://searxng.example.com" /></div>
|
|
||||||
<div class="form-group"><label>API Key</label><input value=${cfg.search_api_key} onInput=${e => set('search_api_key', e.target.value)} placeholder="Optional" /></div>
|
|
||||||
`}
|
|
||||||
<div class="form-group"><label>Max Results</label><input type="number" min="1" max="20" value=${cfg.search_max_results} onInput=${e => set('search_max_results', parseInt(e.target.value) || 5)} /></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-section"><h3>Auto-Compaction</h3>
|
|
||||||
<label class="toggle-label"><input type="checkbox" checked=${cfg.compaction_enabled} onChange=${e => set('compaction_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Enable auto-compaction</span></label>
|
|
||||||
${cfg.compaction_enabled && html`
|
|
||||||
<div class="form-row" style="margin-top:8px;">
|
|
||||||
<div class="form-group"><label>Threshold (%)</label><input type="number" min="50" max="95" value=${cfg.compaction_threshold} onInput=${e => set('compaction_threshold', parseInt(e.target.value))} /></div>
|
|
||||||
<div class="form-group"><label>Cooldown (min)</label><input type="number" min="5" max="1440" value=${cfg.compaction_cooldown} onInput=${e => set('compaction_cooldown', parseInt(e.target.value))} /></div>
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-section"><h3>Memory Extraction</h3>
|
|
||||||
<label class="toggle-label"><input type="checkbox" checked=${cfg.memory_extraction_enabled} onChange=${e => set('memory_extraction_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Enable memory extraction</span></label>
|
|
||||||
${cfg.memory_extraction_enabled && html`
|
|
||||||
<label class="toggle-label" style="margin-top:8px;"><input type="checkbox" checked=${cfg.memory_auto_approve} onChange=${e => set('memory_auto_approve', e.target.checked)} /><span class="toggle-track"></span><span>Auto-approve extracted memories</span></label>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-section"><h3>Encryption Vault</h3>
|
<div class="settings-section"><h3>Encryption Vault</h3>
|
||||||
${vault
|
${vault
|
||||||
? html`<div class="text-muted" style="font-size:12px;">Key set: ${vault.encryption_key_set ? 'Yes' : 'No'} \u2022 User vaults: ${vault.user_vaults_count ?? '?'}</div>`
|
? html`<div class="text-muted" style="font-size:12px;">Key set: ${vault.encryption_key_set ? 'Yes' : 'No'} \u2022 User vaults: ${vault.user_vaults_count ?? '?'}</div>`
|
||||||
|
|||||||
@@ -1,68 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* GeneralSection — chat defaults: model, system prompt, max tokens, temperature, thinking
|
* GeneralSection — user preferences (default surface)
|
||||||
*/
|
*/
|
||||||
const { html } = window;
|
const { html } = window;
|
||||||
const { useState, useEffect, useCallback, useRef } = hooks;
|
const { useState, useEffect, useCallback } = hooks;
|
||||||
|
|
||||||
export function GeneralSection() {
|
export function GeneralSection() {
|
||||||
const [models, setModels] = useState([]);
|
const [surfaces, setSurfaces] = useState([]);
|
||||||
const [settings, setSettings] = useState({});
|
const [defaultSurface, setDefaultSurface] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [maxHint, setMaxHint] = useState('');
|
|
||||||
const tempRef = useRef(null);
|
|
||||||
|
|
||||||
// Load settings + models
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const [s, m] = await Promise.all([
|
const [s, surfList] = await Promise.all([
|
||||||
sw.api.profile.settings(),
|
sw.api.profile.settings(),
|
||||||
sw.api.models.enabled(),
|
sw.api.get('/api/v1/surfaces').catch(() => []),
|
||||||
]);
|
]);
|
||||||
const sObj = s || {};
|
const sObj = s || {};
|
||||||
setSettings({
|
setDefaultSurface(sObj.default_surface || '');
|
||||||
model: sObj.model || '',
|
setSurfaces((surfList || []).filter(s => s.route?.startsWith('/s/')));
|
||||||
system_prompt: sObj.system_prompt || '',
|
|
||||||
max_tokens: sObj.max_tokens || 0,
|
|
||||||
temperature: sObj.temperature ?? 0.7,
|
|
||||||
show_thinking: sObj.show_thinking || false,
|
|
||||||
});
|
|
||||||
const modelList = (m.data || []).filter(x => !x.hidden);
|
|
||||||
setModels(modelList);
|
|
||||||
|
|
||||||
// Set initial max hint
|
|
||||||
const active = modelList.find(x => x.id === sObj.model);
|
|
||||||
if (active?.capabilities?.max_output_tokens > 0) {
|
|
||||||
setMaxHint(`(model max: ${(active.capabilities.max_output_tokens / 1000).toFixed(0)}K)`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[settings/general] Load failed:', e.message);
|
console.warn('[settings/general] Load failed:', e.message);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onModelChange = useCallback((e) => {
|
|
||||||
const id = e.target.value;
|
|
||||||
setSettings(s => ({ ...s, model: id }));
|
|
||||||
const m = models.find(x => x.id === id);
|
|
||||||
const cap = m?.capabilities || {};
|
|
||||||
setMaxHint(cap.max_output_tokens > 0
|
|
||||||
? `(model max: ${(cap.max_output_tokens / 1000).toFixed(0)}K)` : '');
|
|
||||||
}, [models]);
|
|
||||||
|
|
||||||
const onTempInput = useCallback((e) => {
|
|
||||||
setSettings(s => ({ ...s, temperature: parseFloat(e.target.value) }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = useCallback(async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await sw.api.profile.updateSettings({
|
await sw.api.profile.updateSettings({
|
||||||
model: settings.model,
|
default_surface: defaultSurface,
|
||||||
system_prompt: settings.system_prompt,
|
|
||||||
max_tokens: settings.max_tokens,
|
|
||||||
temperature: settings.temperature,
|
|
||||||
show_thinking: settings.show_thinking,
|
|
||||||
});
|
});
|
||||||
sw.emit('toast', { message: 'Settings saved', variant: 'success' });
|
sw.emit('toast', { message: 'Settings saved', variant: 'success' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -70,58 +37,23 @@ export function GeneralSection() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [settings]);
|
}, [defaultSurface]);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Chat Defaults</h3>
|
<h3>Default Surface</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Default Model</label>
|
<label>Landing page after login</label>
|
||||||
<select value=${settings.model} onChange=${onModelChange}>
|
<select value=${defaultSurface} onChange=${e => setDefaultSurface(e.target.value)}>
|
||||||
${models.map(m => html`
|
<option value="">-- Use global default --</option>
|
||||||
<option value=${m.id}>
|
${surfaces.map(s => html`<option key=${s.id} value=${s.id}>${s.icon ? s.icon + ' ' : ''}${s.title || s.id}</option>`)}
|
||||||
${m.name || m.id}${m.provider ? ` (${m.provider})` : ''}
|
|
||||||
</option>
|
|
||||||
`)}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<span style="font-size:12px;color:var(--text-3);">
|
||||||
<label>System Prompt</label>
|
Override the global default set by your administrator.
|
||||||
<textarea rows="3" placeholder="Optional system prompt\u2026"
|
If your chosen surface is uninstalled, the global default is used.
|
||||||
style="max-width:100%;"
|
|
||||||
value=${settings.system_prompt}
|
|
||||||
onInput=${e => setSettings(s => ({ ...s, system_prompt: e.target.value }))} />
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:16px;">
|
|
||||||
<div class="form-group" style="flex:1;">
|
|
||||||
<label>Max Tokens${' '}
|
|
||||||
<span style="font-weight:400;color:var(--text-3);">${maxHint}</span>
|
|
||||||
</label>
|
|
||||||
<input type="number" placeholder="default"
|
|
||||||
value=${settings.max_tokens || ''}
|
|
||||||
onInput=${e => setSettings(s => ({ ...s, max_tokens: parseInt(e.target.value) || 0 }))} />
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="flex:1;">
|
|
||||||
<label>Temperature</label>
|
|
||||||
<div style="display:flex;align-items:center;gap:8px;">
|
|
||||||
<input type="range" min="0" max="2" step="0.1"
|
|
||||||
ref=${tempRef}
|
|
||||||
value=${settings.temperature}
|
|
||||||
onInput=${onTempInput}
|
|
||||||
style="flex:1;" />
|
|
||||||
<span style="font-size:12px;color:var(--text-2);font-family:var(--mono);">
|
|
||||||
${settings.temperature}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-2);cursor:pointer;margin-top:4px;">
|
|
||||||
<input type="checkbox"
|
|
||||||
checked=${settings.show_thinking}
|
|
||||||
onChange=${e => setSettings(s => ({ ...s, show_thinking: e.target.checked }))} />
|
|
||||||
Show thinking blocks
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button class="btn-md btn-primary" style="margin-top:12px;"
|
<button class="btn-md btn-primary" style="margin-top:12px;"
|
||||||
disabled=${saving} onClick=${save}>
|
disabled=${saving} onClick=${save}>
|
||||||
${saving ? 'Saving\u2026' : 'Save'}
|
${saving ? 'Saving\u2026' : 'Save'}
|
||||||
|
|||||||
67
src/js/sw/surfaces/welcome/index.js
Normal file
67
src/js/sw/surfaces/welcome/index.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* WelcomeSurface — fallback landing page.
|
||||||
|
* Shown when no default surface is configured or no extensions are installed.
|
||||||
|
* Checks for available extension surfaces and adjusts messaging.
|
||||||
|
*/
|
||||||
|
const { html } = window;
|
||||||
|
const { useState, useEffect } = hooks;
|
||||||
|
const { render } = preact;
|
||||||
|
|
||||||
|
import { Topbar } from '../../shell/topbar.js';
|
||||||
|
|
||||||
|
function WelcomeSurface() {
|
||||||
|
const BASE = window.__BASE__ || '';
|
||||||
|
const isAdmin = sw.can?.('surface.admin.access');
|
||||||
|
const [surfaces, setSurfaces] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sw.api.get('/api/v1/surfaces').then(list => {
|
||||||
|
setSurfaces((list || []).filter(s => s.route?.startsWith('/s/')));
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasSurfaces = surfaces.length > 0;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<${Topbar} title="Switchboard" />
|
||||||
|
<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:24px;">
|
||||||
|
<div style="max-width:420px;width:100%;background:var(--bg-surface);border:1px solid var(--border);border-radius:12px;padding:32px;text-align:center;">
|
||||||
|
<div style="font-size:36px;margin-bottom:16px;">${hasSurfaces ? '🏠' : '📦'}</div>
|
||||||
|
<h2 style="font-size:20px;font-weight:600;margin:0 0 8px;">Welcome to Switchboard</h2>
|
||||||
|
${hasSurfaces ? html`
|
||||||
|
<p style="font-size:14px;color:var(--text-2);margin:0 0 24px;line-height:1.5;">
|
||||||
|
No default surface is configured. Choose a surface from
|
||||||
|
the menu, or ${isAdmin ? 'set a default in admin settings.' : 'ask your administrator to set a default.'}
|
||||||
|
</p>
|
||||||
|
${isAdmin && html`
|
||||||
|
<a href="${BASE}/admin/settings"
|
||||||
|
style="display:inline-flex;align-items:center;gap:8px;padding:10px 24px;background:var(--accent);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px;">
|
||||||
|
Set Default Surface
|
||||||
|
</a>
|
||||||
|
`}
|
||||||
|
` : html`
|
||||||
|
<p style="font-size:14px;color:var(--text-2);margin:0 0 24px;line-height:1.5;">
|
||||||
|
No surfaces are installed yet. Install an extension surface
|
||||||
|
to get started.
|
||||||
|
</p>
|
||||||
|
${isAdmin && html`
|
||||||
|
<a href="${BASE}/admin/packages"
|
||||||
|
style="display:inline-flex;align-items:center;gap:8px;padding:10px 24px;background:var(--accent);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px;">
|
||||||
|
Go to Packages
|
||||||
|
</a>
|
||||||
|
`}
|
||||||
|
${!isAdmin && html`
|
||||||
|
<p style="font-size:13px;color:var(--text-3);">
|
||||||
|
Ask your administrator to install a surface package.
|
||||||
|
</p>
|
||||||
|
`}
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mount = document.getElementById('welcome-mount');
|
||||||
|
if (mount) {
|
||||||
|
render(html`<${WelcomeSurface} />`, mount);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user