rebrand + purge orphaned frontend: Chat Switchboard → Switchboard Core
Branding: bulk rename across 50+ files (Go, JS, CSS, HTML, YAML, JSON, shell scripts). Fix Helm chart description and git repo URLs. Fix test helper taglines. Admin surface: strip 14 gutted tabs (providers, models, personas, roles, knowledge, memory, tasks, health, routing, capabilities, channels, dashboard, usage, stats). Collapse to 4 categories: People, Workflows, System, Monitoring. Settings surface: strip 11 gutted tabs (models, personas, providers, roles, knowledge, memory, usage, workflows, tasks, data, gitkeys). Keep: general, appearance, profile, teams, connections, notifications. Team-admin surface: strip 5 gutted tabs (personas, providers, knowledge, tasks, usage). Keep: members, groups, connections, workflows, settings, activity. Components: delete chat-pane/ (13 files, 1938 lines) and notes-pane/ (10 files, 2381 lines). main.go: remove stale Health.Prune maintenance goroutine. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
apiVersion: v2
|
||||
name: switchboard
|
||||
description: Chat Switchboard — self-hosted enterprise AI chat platform
|
||||
description: Switchboard Core — self-hosted extension platform
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "0.0.0" # Patched at CI/release time from /VERSION
|
||||
home: https://gobha.ai
|
||||
sources:
|
||||
- https://git.gobha.me/xcaliber/chat-switchboard
|
||||
- https://git.gobha.me/switchboard/core
|
||||
maintainers:
|
||||
- name: xcaliber
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Chat Switchboard — PrometheusRule alerts (v0.33.0)
|
||||
# Switchboard Core — PrometheusRule alerts (v0.33.0)
|
||||
# Source file for the Helm template. Deploy via:
|
||||
# monitoring.prometheusRule.enabled: true
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
],
|
||||
"id": null,
|
||||
"uid": "switchboard-overview",
|
||||
"title": "Chat Switchboard — Overview",
|
||||
"title": "Switchboard Core — Overview",
|
||||
"description": "System overview: request rates, latency, provider health, token usage, DB pool.",
|
||||
"tags": ["switchboard"],
|
||||
"timezone": "browser",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Chat Switchboard — Helm values
|
||||
# Switchboard Core — Helm values
|
||||
# helm install switchboard ./chart
|
||||
|
||||
# ── Images ─────────────────────────────────
|
||||
backend:
|
||||
image:
|
||||
repository: git.gobha.me/xcaliber/chat-switchboard
|
||||
repository: git.gobha.me/switchboard/core
|
||||
tag: "" # defaults to Chart.appVersion
|
||||
pullPolicy: IfNotPresent
|
||||
replicaCount: 2 # v0.32.0: multi-replica HA
|
||||
@@ -19,7 +19,7 @@ backend:
|
||||
|
||||
frontend:
|
||||
image:
|
||||
repository: git.gobha.me/xcaliber/chat-switchboard-frontend
|
||||
repository: git.gobha.me/switchboard/core
|
||||
tag: "" # defaults to Chart.appVersion
|
||||
pullPolicy: IfNotPresent
|
||||
replicaCount: 1
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "switchboard",
|
||||
"name": "Chat Switchboard",
|
||||
"name": "Switchboard Core",
|
||||
"enabled": true,
|
||||
"publicClient": false,
|
||||
"secret": "switchboard-secret",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# k8s/backend.yaml
|
||||
# ============================================
|
||||
# Chat Switchboard - Backend Deployment
|
||||
# Switchboard Core - Backend Deployment
|
||||
# ============================================
|
||||
# Secrets:
|
||||
# switchboard-db-credentials - POSTGRES_USER, POSTGRES_PASSWORD
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# k8s/frontend.yaml
|
||||
# ============================================
|
||||
# Chat Switchboard - Frontend Deployment
|
||||
# Switchboard Core - Frontend Deployment
|
||||
# ============================================
|
||||
# nginx serving the SPA + proxying page routes
|
||||
# to the backend for Go template rendering.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# k8s/ingress.yaml
|
||||
# ============================================
|
||||
# Chat Switchboard - Ingress
|
||||
# Switchboard Core - Ingress
|
||||
# ============================================
|
||||
# Path-based routing on a single domain:
|
||||
# switchboard.DOMAIN/ → production
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# k8s/middleware-retry.yaml
|
||||
# ============================================
|
||||
# Chat Switchboard - Traefik Retry Middleware
|
||||
# Switchboard Core - Traefik Retry Middleware
|
||||
# ============================================
|
||||
# Retries failed requests (502, connection reset) once before giving up.
|
||||
# Applies to API + WebSocket paths via the Ingress annotation.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# k8s/storage-pvc.yaml
|
||||
# ============================================
|
||||
# Chat Switchboard - Storage PVC
|
||||
# Switchboard Core - Storage PVC
|
||||
# ============================================
|
||||
# Persistent storage for file attachments, extraction queue,
|
||||
# and future knowledge base / compaction data.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Dashboard Package (v0.31.1)
|
||||
// Switchboard Core — Dashboard Package (v0.31.1)
|
||||
// ==========================================
|
||||
// SDK Exercise Surface. Exercises every sw.* primitive with ZERO
|
||||
// component CSS overrides. All styling comes from the SDK itself.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "full",
|
||||
"version": "0.31.1",
|
||||
"tier": "browser",
|
||||
"author": "Chat Switchboard",
|
||||
"author": "Switchboard Core",
|
||||
"description": "Project dashboard exercising all SDK primitives",
|
||||
"route": "/s/dashboard",
|
||||
"permissions": [],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Editor Surface (v0.25.0)
|
||||
Switchboard Core — Editor Surface (v0.25.0)
|
||||
==========================================
|
||||
Replaces editor-mode.css for the pane-based editor.
|
||||
Covers: topbar, bootstrap, and component-specific
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Editor Package (v0.31.0)
|
||||
// Switchboard Core — Editor Package (v0.31.0)
|
||||
// ==========================================
|
||||
// Installable .pkg that provides the code editor surface.
|
||||
// Mounts into #extension-mount (surface-extension template).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "full",
|
||||
"version": "0.31.0",
|
||||
"tier": "browser",
|
||||
"author": "Chat Switchboard",
|
||||
"author": "Switchboard Core",
|
||||
"description": "Code editor with workspace management, file tree, and AI assist",
|
||||
"route": "/s/editor",
|
||||
"layout": "editor",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
type: 'full',
|
||||
version: '0.31.1',
|
||||
tier: 'browser',
|
||||
author: 'Chat Switchboard',
|
||||
author: 'Switchboard Core',
|
||||
description: 'Project dashboard exercising all SDK primitives',
|
||||
route: '/s/dashboard',
|
||||
permissions: [],
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
type: 'full',
|
||||
version: '0.31.0',
|
||||
tier: 'browser',
|
||||
author: 'Chat Switchboard',
|
||||
author: 'Switchboard Core',
|
||||
description: 'Code editor with workspace management',
|
||||
route: '/s/editor',
|
||||
layout: 'editor',
|
||||
|
||||
@@ -368,7 +368,7 @@
|
||||
var totalMs = T.results.reduce(function (s, r) { return s + r.duration; }, 0);
|
||||
|
||||
var lines = [];
|
||||
lines.push('=== Chat Switchboard ICD Test Report ===');
|
||||
lines.push('=== Switchboard Core ICD Test Report ===');
|
||||
lines.push('Generated: ' + now);
|
||||
lines.push('Platform: v' + (T.manifest.version || '0.28.0'));
|
||||
lines.push('User: ' + T.user.username + ' (' + T.user.role + ')');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# Chat Switchboard - Database Bootstrap
|
||||
# Switchboard Core - Database Bootstrap
|
||||
# ============================================
|
||||
# Idempotent: safe to run on every CI build.
|
||||
# Creates the app role and database if they
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# Chat Switchboard - Database Migration Runner
|
||||
# Switchboard Core - Database Migration Runner
|
||||
# ============================================
|
||||
# NOTE: The Go backend auto-migrates on startup.
|
||||
# This script is for manual/emergency use only.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# Chat Switchboard - Schema Validation (v0.16)
|
||||
# Switchboard Core - Schema Validation (v0.16)
|
||||
# ============================================
|
||||
# Verifies the database schema is correct after
|
||||
# migration. Checks expected tables, key columns,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package crypto provides API key encryption primitives for Chat Switchboard.
|
||||
// Package crypto provides API key encryption primitives for Switchboard Core.
|
||||
//
|
||||
// Two-tier model:
|
||||
//
|
||||
|
||||
@@ -309,7 +309,7 @@ func TruncateAll(t *testing.T) {
|
||||
DB.Exec(`
|
||||
INSERT INTO global_settings (key, value) VALUES
|
||||
('registration', '{"enabled": true}'),
|
||||
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'),
|
||||
('site', '{"name": "Switchboard Core", "tagline": "Self-hosted extension platform"}'),
|
||||
('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'),
|
||||
('model_roles', '{"utility":{"primary":null,"fallback":null},"embedding":{"primary":null,"fallback":null},"generation":{"primary":null,"fallback":null}}')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
@@ -328,7 +328,7 @@ func TruncateAll(t *testing.T) {
|
||||
DB.Exec(`
|
||||
INSERT INTO global_settings (key, value) VALUES
|
||||
('registration', '{"enabled": true}'::jsonb),
|
||||
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb),
|
||||
('site', '{"name": "Switchboard Core", "tagline": "Self-hosted extension platform"}'::jsonb),
|
||||
('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'::jsonb),
|
||||
('model_roles', '{
|
||||
"utility": { "primary": null, "fallback": null },
|
||||
|
||||
@@ -53,7 +53,7 @@ func (h *AdminEmailHandler) TestEmail(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Get instance name
|
||||
instanceName := "Chat Switchboard"
|
||||
instanceName := "Switchboard Core"
|
||||
if branding, err := h.stores.GlobalConfig.Get(context.Background(), "branding"); err == nil {
|
||||
if name, ok := branding["instance_name"].(string); ok && name != "" {
|
||||
instanceName = name
|
||||
@@ -64,7 +64,7 @@ func (h *AdminEmailHandler) TestEmail(c *gin.Context) {
|
||||
htmlBody := `<!DOCTYPE html>
|
||||
<html><body style="font-family: sans-serif; padding: 20px;">
|
||||
<h2 style="color: #4a8af4;">` + instanceName + `</h2>
|
||||
<p>This is a test email from your Chat Switchboard instance.</p>
|
||||
<p>This is a test email from your Switchboard Core instance.</p>
|
||||
<p>If you received this, your SMTP configuration is working correctly.</p>
|
||||
</body></html>`
|
||||
textBody := instanceName + " — Test Email\n\nThis is a test email. Your SMTP configuration is working correctly."
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package logging provides structured logging configuration for
|
||||
// Chat Switchboard using the standard library's log/slog package.
|
||||
// Switchboard Core using the standard library's log/slog package.
|
||||
// Configured via LOG_FORMAT ("text"|"json") and LOG_LEVEL env vars.
|
||||
package logging
|
||||
|
||||
|
||||
@@ -109,21 +109,6 @@ func main() {
|
||||
// v0.33.0: Start Prometheus DB pool collector
|
||||
metrics.StartDBCollector(database.DB, 15*time.Second)
|
||||
|
||||
// Kernel maintenance: prune stale data every 6 hours
|
||||
go func() {
|
||||
ticker := time.NewTicker(6 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
if n, err := stores.Health.Prune(ctx, time.Now().UTC().Add(-7*24*time.Hour)); err != nil {
|
||||
log.Printf("⚠ maintenance prune failed: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("🧹 maintenance: pruned %d stale rows", n)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
// Bootstrap admin from env (K8s secret) — upserts on every restart
|
||||
handlers.BootstrapAdmin(cfg, stores, uekCache)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package metrics defines all Prometheus metrics for Chat Switchboard.
|
||||
// Package metrics defines all Prometheus metrics for Switchboard Core.
|
||||
// All metrics use the "switchboard_" prefix. Registered via promauto
|
||||
// so they are available on the default registry's /metrics endpoint.
|
||||
package metrics
|
||||
|
||||
@@ -53,7 +53,7 @@ func NewService(s store.NotificationStore, hub *events.Hub) *Service {
|
||||
return &Service{
|
||||
store: s,
|
||||
hub: hub,
|
||||
instanceName: "Chat Switchboard",
|
||||
instanceName: "Switchboard Core",
|
||||
retentionDays: 90,
|
||||
stopCleanup: make(chan struct{}),
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ Adjust your notification preferences in Settings.
|
||||
// RenderHTML renders an HTML email body for a notification.
|
||||
func RenderHTML(n *models.Notification, instanceName string) string {
|
||||
if instanceName == "" {
|
||||
instanceName = "Chat Switchboard"
|
||||
instanceName = "Switchboard Core"
|
||||
}
|
||||
data := emailData{
|
||||
Title: n.Title,
|
||||
@@ -73,7 +73,7 @@ func RenderHTML(n *models.Notification, instanceName string) string {
|
||||
// RenderText renders a plaintext email body for a notification.
|
||||
func RenderText(n *models.Notification, instanceName string) string {
|
||||
if instanceName == "" {
|
||||
instanceName = "Chat Switchboard"
|
||||
instanceName = "Switchboard Core"
|
||||
}
|
||||
data := emailData{
|
||||
Title: n.Title,
|
||||
@@ -92,7 +92,7 @@ func RenderText(n *models.Notification, instanceName string) string {
|
||||
// SubjectForNotification returns an email subject line.
|
||||
func SubjectForNotification(n *models.Notification, instanceName string) string {
|
||||
if instanceName == "" {
|
||||
instanceName = "Chat Switchboard"
|
||||
instanceName = "Switchboard Core"
|
||||
}
|
||||
prefix := "[" + instanceName + "] "
|
||||
switch {
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestRenderHTML_Basic(t *testing.T) {
|
||||
func TestRenderHTML_DefaultInstanceName(t *testing.T) {
|
||||
n := &models.Notification{Title: "Test"}
|
||||
html := RenderHTML(n, "")
|
||||
if !strings.Contains(html, "Chat Switchboard") {
|
||||
if !strings.Contains(html, "Switchboard Core") {
|
||||
t.Error("expected default instance name")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,7 +840,7 @@ func (e *Engine) loadFooter() FooterConfig {
|
||||
|
||||
// loadBranding reads instance branding from global settings.
|
||||
func (e *Engine) loadBranding() (name, logoURL, tagline string) {
|
||||
name = "Chat Switchboard" // default
|
||||
name = "Switchboard Core" // default
|
||||
if e.stores.GlobalConfig == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
|
||||
<title>{{block "title" .}}Chat Switchboard{{end}}</title>
|
||||
<title>{{block "title" .}}Switchboard Core{{end}}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}">
|
||||
<link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}">
|
||||
{{/* Decomposed from monolithic styles.css (v0.22.9) */}}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chat Switchboard</title>
|
||||
<title>Switchboard Core</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}">
|
||||
<link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Chat Switchboard — API Docs</title>
|
||||
<title>Switchboard Core — API Docs</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||
<style>
|
||||
/* ── Palette — WCAG AA 4.5:1 minimum contrast ────────────────── */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Admin Surfaces
|
||||
Switchboard Core — Admin Surfaces
|
||||
==========================================
|
||||
v0.25.0: Surface lifecycle management UI.
|
||||
========================================== */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — UserMenu Component CSS
|
||||
Switchboard Core — UserMenu Component CSS
|
||||
==========================================
|
||||
v0.31.1: Flyout container + item styles moved to primitives.css
|
||||
(.sw-menu-flyout). This file keeps only the trigger button,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
/* ==========================================
|
||||
Chat Switchboard
|
||||
Switchboard Core
|
||||
Clean utility layout · Open WebUI inspired
|
||||
========================================== */
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ==========================================
|
||||
* Chat Switchboard — Workflow Components CSS
|
||||
* Switchboard Core — Workflow Components CSS
|
||||
* ========================================== */
|
||||
|
||||
/* ── Stage list (admin builder) ────────── */
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chat Switchboard — Dev Gallery</title>
|
||||
<title>Switchboard Core — Dev Gallery</title>
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/sw-primitives.css">
|
||||
<link rel="stylesheet" href="css/sw-shell.css">
|
||||
@@ -640,7 +640,7 @@
|
||||
<${AppShell}
|
||||
banner=${banner}
|
||||
message=${msg}
|
||||
footer=${footerOn ? html`Chat Switchboard · Layer 1 Shell` : null}
|
||||
footer=${footerOn ? html`Switchboard Core · Layer 1 Shell` : null}
|
||||
>
|
||||
<div class="dev-root">
|
||||
<nav class="dev-nav">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chat Switchboard</title>
|
||||
<title>Switchboard Core</title>
|
||||
<script>window.__BASE__ = '%%BASE_PATH%%';</script>
|
||||
<script>window.__VERSION__ = '%%APP_VERSION%%'; if (window.__VERSION__.includes('%%')) window.__VERSION__ = 'dev';</script>
|
||||
<meta http-equiv="refresh" content="0;url=%%BASE_PATH%%/">
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — ChatHistory Component
|
||||
// ==========================================
|
||||
// Channel history select + new-chat button.
|
||||
// Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${ChatHistory} channelId=${id} onSelect=${fn} onNew=${fn} />`
|
||||
|
||||
const { useState, useEffect } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
const PLUS_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{ channelId: string|null, onSelect: (id: string) => void, onNew: () => void }} props
|
||||
*/
|
||||
export function ChatHistory({ channelId, onSelect, onNew }) {
|
||||
const [channels, setChannels] = useState([]);
|
||||
|
||||
function _load() {
|
||||
if (!window.sw?.api?.channels?.list) return;
|
||||
window.sw.api.channels.list({ page: 1, per_page: 20, channel_type: 'direct' })
|
||||
.then(resp => {
|
||||
setChannels(resp || []);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
useEffect(() => { _load(); }, [channelId]);
|
||||
|
||||
function _onChange(e) {
|
||||
const val = e.target.value;
|
||||
if (val) onSelect(val);
|
||||
else onNew();
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-pane__header-left">
|
||||
<select class="sw-chat-history__select"
|
||||
title="Switch chat"
|
||||
value=${channelId || ''}
|
||||
onChange=${_onChange}>
|
||||
<option value="">New conversation</option>
|
||||
${channels.map(ch => html`
|
||||
<option key=${ch.id} value=${ch.id}>${ch.title || 'Untitled'}</option>`)}
|
||||
</select>
|
||||
<button class="sw-chat-history__new-btn" title="New chat" onClick=${onNew}>
|
||||
${PLUS_SVG}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — CodeBlock Component
|
||||
// ==========================================
|
||||
// Enhanced code block: language badge, copy button.
|
||||
// Independently importable.
|
||||
//
|
||||
// Usage (from markdown post-processing):
|
||||
// Enhances <pre><code> blocks after markdown rendering.
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useCallback } = window.hooks;
|
||||
|
||||
/**
|
||||
* SVG icons for copy/check.
|
||||
*/
|
||||
const COPY_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>`;
|
||||
|
||||
const CHECK_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* CodeBlock — wraps a <pre><code> block with language badge + copy button.
|
||||
*
|
||||
* @param {{ code: string, language?: string }} props
|
||||
*/
|
||||
export function CodeBlock({ code, language }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const _copy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (_) {
|
||||
// Fallback
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = code;
|
||||
ta.style.cssText = 'position:fixed;left:-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, [code]);
|
||||
|
||||
const langLabel = language && language !== 'text' ? language : null;
|
||||
|
||||
return html`
|
||||
<div class="sw-code-block">
|
||||
<div class="sw-code-block__header">
|
||||
${langLabel && html`<span class="sw-code-block__lang">${langLabel}</span>`}
|
||||
<button class="sw-code-block__copy"
|
||||
title=${copied ? 'Copied!' : 'Copy code'}
|
||||
onClick=${_copy}
|
||||
aria-label="Copy code to clipboard">
|
||||
${copied ? CHECK_SVG : COPY_SVG}
|
||||
<span class="sw-code-block__copy-text">${copied ? 'Copied' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre class="sw-code-block__pre"><code class=${langLabel ? 'language-' + langLabel : ''}
|
||||
dangerouslySetInnerHTML=${{ __html: _escapeHtml(code) }} /></pre>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const _escMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
function _escapeHtml(text) {
|
||||
return text.replace(/[&<>"']/g, c => _escMap[c]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-process rendered markdown HTML to enhance code blocks.
|
||||
* Extracts language from class="language-xxx" and returns
|
||||
* an array of { type, code, language } blocks for Preact rendering.
|
||||
*
|
||||
* @param {string} html — rendered markdown HTML
|
||||
* @returns {{ segments: Array<{type: 'html'|'code', html?: string, code?: string, language?: string}> }}
|
||||
*/
|
||||
export function extractCodeBlocks(htmlStr) {
|
||||
// Match <pre><code class="language-xxx">...</code></pre> blocks
|
||||
const pattern = /<pre><code(?:\s+class="language-(\w+)")?>([\s\S]*?)<\/code><\/pre>/g;
|
||||
const segments = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = pattern.exec(htmlStr)) !== null) {
|
||||
// Push preceding HTML
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({ type: 'html', html: htmlStr.slice(lastIndex, match.index) });
|
||||
}
|
||||
// Push code block
|
||||
const language = match[1] || '';
|
||||
const code = _unescapeHtml(match[2]);
|
||||
segments.push({ type: 'code', code, language });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Push trailing HTML
|
||||
if (lastIndex < htmlStr.length) {
|
||||
segments.push({ type: 'html', html: htmlStr.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map language tag to file extension.
|
||||
* @param {string} lang
|
||||
* @returns {string}
|
||||
*/
|
||||
export function guessExtension(lang) {
|
||||
const map = {
|
||||
javascript: 'js', typescript: 'ts', python: 'py', ruby: 'rb',
|
||||
go: 'go', rust: 'rs', java: 'java', kotlin: 'kt', swift: 'swift',
|
||||
c: 'c', cpp: 'cpp', csharp: 'cs', php: 'php', bash: 'sh',
|
||||
shell: 'sh', zsh: 'sh', sql: 'sql', html: 'html', css: 'css',
|
||||
json: 'json', yaml: 'yaml', yml: 'yml', xml: 'xml', toml: 'toml',
|
||||
markdown: 'md', md: 'md', jsx: 'jsx', tsx: 'tsx', vue: 'vue',
|
||||
svelte: 'svelte', scss: 'scss', less: 'less', lua: 'lua',
|
||||
perl: 'pl', r: 'r', dart: 'dart', elixir: 'ex', erlang: 'erl',
|
||||
haskell: 'hs', scala: 'scala', zig: 'zig', nim: 'nim',
|
||||
};
|
||||
return map[(lang || '').toLowerCase()] || 'txt';
|
||||
}
|
||||
|
||||
function _unescapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — EmojiPicker Component
|
||||
// ==========================================
|
||||
// Categorized emoji selector popup with search and recents.
|
||||
// Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${EmojiPicker} open=${true} onSelect=${fn} onClose=${fn} />`
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useRef, useEffect, useCallback, useMemo } = window.hooks;
|
||||
|
||||
// ── Emoji data (compact) ─────────────────
|
||||
// Categories with popular emoji — not a full Unicode dump.
|
||||
// Keeps bundle small. Each entry is just the emoji character.
|
||||
const CATEGORIES = [
|
||||
{ key: 'recent', icon: '🕐', label: 'Recent', emoji: [] },
|
||||
{ key: 'smileys', icon: '😀', label: 'Smileys & People', emoji: [
|
||||
'😀','😃','😄','😁','😆','😅','🤣','😂','🙂','😊',
|
||||
'😇','🥰','😍','🤩','😘','😋','😛','😜','🤪','😝',
|
||||
'🤗','🤭','🤫','🤔','😐','😑','😶','😏','😒','🙄',
|
||||
'😬','😮💨','🤥','😌','😔','😪','🤤','😴','😷','🤒',
|
||||
'🤕','🤢','🤮','🥵','🥶','🥴','😵','🤯','🤠','🥳',
|
||||
'🥸','😎','🤓','🧐','😕','😟','🙁','😮','😯','😲',
|
||||
'😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱',
|
||||
'😖','😣','😞','😓','😩','😫','🥱','😤','😡','😠',
|
||||
'🤬','😈','👿','💀','☠️','💩','🤡','👹','👺','👻',
|
||||
'👽','👾','🤖','👋','🤚','🖐️','✋','🖖','👌','🤌',
|
||||
'🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','🖕',
|
||||
'👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌',
|
||||
'👐','🤲','🤝','🙏',
|
||||
]},
|
||||
{ key: 'nature', icon: '🌿', label: 'Nature', emoji: [
|
||||
'🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐨','🐯',
|
||||
'🦁','🐮','🐷','🐸','🐵','🐔','🐧','🐦','🐤','🦆',
|
||||
'🦅','🦉','🦇','🐺','🐗','🐴','🦄','🐝','🐛','🦋',
|
||||
'🐌','🐞','🐜','🪲','🪳','🦟','🦗','🕷️','🌸','💐',
|
||||
'🌷','🌹','🥀','🌺','🌻','🌼','🌱','🌲','🌳','🌴',
|
||||
'🌵','🍀','🍁','🍂','🍃','🍄',
|
||||
]},
|
||||
{ key: 'food', icon: '🍔', label: 'Food & Drink', emoji: [
|
||||
'🍇','🍈','🍉','🍊','🍋','🍌','🍍','🥭','🍎','🍐',
|
||||
'🍑','🍒','🍓','🫐','🥝','🍅','🫒','🥥','🥑','🍆',
|
||||
'🥔','🥕','🌽','🌶️','🫑','🥒','🥬','🧄','🧅','🍄',
|
||||
'🥜','🫘','🌰','🍞','🥐','🥖','🫓','🥨','🥯','🥞',
|
||||
'🧇','🧀','🍖','🍗','🥩','🥓','🍔','🍟','🍕','🌭',
|
||||
'🥪','🌮','🌯','🫔','🥙','🧆','🥚','🍳','🥘','🍲',
|
||||
'🫕','🥣','🥗','🍿','🧈','🍱','🍘','🍙','🍚','🍛',
|
||||
'🍜','🍝','🍠','🍢','🍣','🍤','🍥','🥮','🍡','🥟',
|
||||
'🥠','🥡','☕','🍵','🧃','🥤','🧋','🍺','🍻','🥂',
|
||||
'🍷','🥃','🍸','🍹','🧉','🍾','🫗',
|
||||
]},
|
||||
{ key: 'activity', icon: '⚽', label: 'Activities', emoji: [
|
||||
'⚽','🏀','🏈','⚾','🥎','🎾','🏐','🏉','🥏','🎱',
|
||||
'🏓','🏸','🏒','🥅','⛳','🏹','🎣','🤿','🥊','🥋',
|
||||
'🎿','⛷️','🏂','🪂','🏋️','🤸','🤺','⛹️','🤾','🏌️',
|
||||
'🏇','🧘','🏄','🏊','🤽','🚣','🧗','🚴','🚵','🎪',
|
||||
'🎭','🎨','🎬','🎤','🎧','🎼','🎹','🥁','🎷','🎺',
|
||||
'🎸','🪕','🎲','🎯','🎳','🎮','🕹️','🧩',
|
||||
]},
|
||||
{ key: 'objects', icon: '💡', label: 'Objects', emoji: [
|
||||
'⌚','📱','💻','⌨️','🖥️','🖨️','🖱️','🖲️','💾','💿',
|
||||
'📷','📹','🎥','📽️','🎞️','📞','📟','📠','📺','📻',
|
||||
'🎙️','⏱️','⏲️','⏰','🕰️','⌛','📡','🔋','🔌','💡',
|
||||
'🔦','🕯️','🪔','🧯','🛢️','💸','💵','💴','💶','💷',
|
||||
'🪙','💰','💳','💎','⚖️','🪜','🧰','🪛','🔧','🔨',
|
||||
'⛏️','🪚','🔩','⚙️','🪤','🧱','⛓️','🧲','🔫','💣',
|
||||
'🪓','🔪','🗡️','⚔️','🛡️','🚬','⚰️','🏺','🔮','📿',
|
||||
'🧿','💈','⚗️','🔭','🔬','🕳️','🩹','🩺','💊','💉',
|
||||
'🩸','🧬','🦠','🧫','🧪','🌡️','🧹','🪠','🧺','🧻',
|
||||
]},
|
||||
{ key: 'symbols', icon: '❤️', label: 'Symbols', emoji: [
|
||||
'❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔',
|
||||
'❣️','💕','💞','💓','💗','💖','💘','💝','💟','☮️',
|
||||
'✝️','☪️','🕉️','☸️','✡️','🔯','🕎','☯️','☦️','🛐',
|
||||
'⛎','♈','♉','♊','♋','♌','♍','♎','♏','♐',
|
||||
'♑','♒','♓','🆔','⚛️','🉑','☢️','☣️','📴','📳',
|
||||
'🈶','🈚','🈸','🈺','🈷️','✴️','🆚','💮','🉐','㊙️',
|
||||
'㊗️','🈴','🈵','🈹','🈲','🅰️','🅱️','🆎','🆑','🅾️',
|
||||
'🆘','❌','⭕','🛑','⛔','📛','🚫','💯','💢','♨️',
|
||||
'🚷','🚯','🚳','🚱','🔞','📵','🚭','❗','❓','‼️',
|
||||
'⁉️','🔅','🔆','〽️','⚠️','🚸','🔱','⚜️','🔰','♻️',
|
||||
'✅','🈯','💹','❎','🌐','💠','Ⓜ️','🌀','💤','🏧',
|
||||
'🎦','🈁','🔣','ℹ️','🔤','🔡','🔠','🆖','🆗','🆙',
|
||||
'🆒','🆕','🆓','0️⃣','1️⃣','2️⃣','3️⃣','4️⃣','5️⃣','6️⃣',
|
||||
'7️⃣','8️⃣','9️⃣','🔟','🔢','#️⃣','*️⃣','⏏️','▶️','⏸️',
|
||||
'⏹️','⏺️','⏭️','⏮️','⏩','⏪','🔀','🔁','🔂','◀️',
|
||||
'🔼','🔽','➡️','⬅️','⬆️','⬇️','↗️','↘️','↙️','↖️',
|
||||
'↕️','↔️','↩️','↪️','⤴️','⤵️','🔃','🔄','🔙','🔚',
|
||||
'🔛','🔜','🔝',
|
||||
]},
|
||||
];
|
||||
|
||||
const RECENTS_KEY = 'sw-emoji-recent';
|
||||
const MAX_RECENTS = 24;
|
||||
|
||||
function _loadRecents() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(RECENTS_KEY)) || [];
|
||||
} catch (_) { return []; }
|
||||
}
|
||||
|
||||
function _saveRecent(emoji) {
|
||||
const recents = _loadRecents().filter(e => e !== emoji);
|
||||
recents.unshift(emoji);
|
||||
if (recents.length > MAX_RECENTS) recents.length = MAX_RECENTS;
|
||||
try { localStorage.setItem(RECENTS_KEY, JSON.stringify(recents)); } catch (_) {}
|
||||
return recents;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ open: boolean, onSelect: (emoji: string) => void, onClose: () => void }} props
|
||||
*/
|
||||
export function EmojiPicker({ open, onSelect, onClose }) {
|
||||
const [activeCategory, setActiveCategory] = useState('smileys');
|
||||
const [search, setSearch] = useState('');
|
||||
const [recents, setRecents] = useState(_loadRecents);
|
||||
const searchRef = useRef(null);
|
||||
const panelRef = useRef(null);
|
||||
|
||||
// Focus search on open
|
||||
useEffect(() => {
|
||||
if (open && searchRef.current) {
|
||||
setTimeout(() => searchRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function _onKey(e) {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
|
||||
}
|
||||
document.addEventListener('keydown', _onKey, true);
|
||||
return () => document.removeEventListener('keydown', _onKey, true);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function _onClick(e) {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
setTimeout(() => document.addEventListener('click', _onClick), 0);
|
||||
return () => document.removeEventListener('click', _onClick);
|
||||
}, [open, onClose]);
|
||||
|
||||
const _pick = useCallback((emoji) => {
|
||||
setRecents(_saveRecent(emoji));
|
||||
onSelect(emoji);
|
||||
}, [onSelect]);
|
||||
|
||||
// Build active emoji list
|
||||
const activeEmoji = useMemo(() => {
|
||||
if (search) {
|
||||
// Flat search across all categories (emoji chars don't have searchable names,
|
||||
// so this just filters for exact substring — limited but functional)
|
||||
const all = CATEGORIES.flatMap(c => c.emoji);
|
||||
return all.filter(e => e.includes(search));
|
||||
}
|
||||
if (activeCategory === 'recent') return recents;
|
||||
const cat = CATEGORIES.find(c => c.key === activeCategory);
|
||||
return cat ? cat.emoji : [];
|
||||
}, [activeCategory, search, recents]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return html`
|
||||
<div class="sw-emoji-picker" ref=${panelRef} role="dialog" aria-label="Emoji picker">
|
||||
<div class="sw-emoji-picker__tabs">
|
||||
${CATEGORIES.map(c => html`
|
||||
<button key=${c.key}
|
||||
class=${'sw-emoji-picker__tab' + (activeCategory === c.key ? ' sw-emoji-picker__tab--active' : '')}
|
||||
title=${c.label}
|
||||
onClick=${() => { setActiveCategory(c.key); setSearch(''); }}
|
||||
type="button">
|
||||
${c.icon}
|
||||
</button>`)}
|
||||
</div>
|
||||
<input ref=${searchRef}
|
||||
class="sw-emoji-picker__search"
|
||||
type="text"
|
||||
placeholder="Search emoji..."
|
||||
value=${search}
|
||||
onInput=${e => setSearch(e.target.value)}
|
||||
aria-label="Search emoji" />
|
||||
<div class="sw-emoji-picker__grid" role="listbox">
|
||||
${activeEmoji.length === 0 && html`
|
||||
<div class="sw-emoji-picker__empty">
|
||||
${activeCategory === 'recent' ? 'No recent emoji' : 'No results'}
|
||||
</div>`}
|
||||
${activeEmoji.map(e => html`
|
||||
<button key=${e}
|
||||
class="sw-emoji-picker__emoji"
|
||||
onClick=${() => _pick(e)}
|
||||
role="option"
|
||||
type="button"
|
||||
title=${e}>
|
||||
${e}
|
||||
</button>`)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — Default Assembly
|
||||
// ==========================================
|
||||
// Wires useChat + all sub-components into the standard
|
||||
// 1-on-1 chat experience. Surfaces that need a different
|
||||
// layout import individual pieces instead.
|
||||
//
|
||||
// v0.37.10: Error banner, stop button, regenerate, pagination,
|
||||
// markdown toolbar, emoji picker, code block copy.
|
||||
//
|
||||
// Usage:
|
||||
// import { ChatPane } from './components/chat-pane/index.js';
|
||||
// html`<${ChatPane} handleRef=${ref} standalone=${true} getContext=${fn} />`
|
||||
//
|
||||
// Re-exports all pieces for kit consumers:
|
||||
// import { useChat, useStream, MessageList, MessageInput, ... } from './components/chat-pane/index.js';
|
||||
|
||||
import { useChat } from './use-chat.js';
|
||||
import { useStream } from './use-stream.js';
|
||||
import { MessageList } from './message-list.js';
|
||||
import { MessageBubble } from './message-bubble.js';
|
||||
import { MessageInput } from './message-input.js';
|
||||
import { ModelSelector } from './model-selector.js';
|
||||
import { ChatHistory } from './chat-history.js';
|
||||
import { renderMarkdown } from './markdown.js';
|
||||
import { CodeBlock, extractCodeBlocks } from './code-block.js';
|
||||
import { MessageActions } from './message-actions.js';
|
||||
import { EmojiPicker } from './emoji-picker.js';
|
||||
import { TypingIndicator } from './typing-indicator.js';
|
||||
|
||||
const { useRef, useEffect, useCallback } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
/**
|
||||
* Default ChatPane assembly.
|
||||
*
|
||||
* @param {{
|
||||
* channelId?: string,
|
||||
* standalone?: boolean,
|
||||
* getContext?: () => {path: string, content: string}|null,
|
||||
* onChannelChange?: (id: string) => void,
|
||||
* handleRef?: { current: any },
|
||||
* className?: string,
|
||||
* }} props
|
||||
*/
|
||||
export function ChatPane(props) {
|
||||
const {
|
||||
channelId: initialChannelId,
|
||||
standalone = true,
|
||||
getContext,
|
||||
onChannelChange,
|
||||
handleRef,
|
||||
className,
|
||||
} = props;
|
||||
|
||||
const inputHandle = useRef(null);
|
||||
|
||||
const chat = useChat({
|
||||
initialChannelId,
|
||||
getContext,
|
||||
onChannelChange,
|
||||
externalModel: props.modelId,
|
||||
});
|
||||
|
||||
// Expose imperative handle via handleRef prop
|
||||
// Intentional: no deps — handle methods must reference latest chat closures
|
||||
useEffect(() => {
|
||||
if (handleRef) {
|
||||
handleRef.current = {
|
||||
getChannel() { return chat.channelId; },
|
||||
setChannel(id) { chat.switchChannel(id); },
|
||||
focusInput() { inputHandle.current?.focus(); },
|
||||
getInputValue() { return inputHandle.current?.getValue() || ''; },
|
||||
setInputValue(v) { inputHandle.current?.setValue(v); },
|
||||
clearInput() { inputHandle.current?.clear(); },
|
||||
clear() { chat.newChat(); },
|
||||
stop() { chat.stop(); },
|
||||
setModel(id) { chat.setModel(id); },
|
||||
getModel() { return chat.model; },
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const _dismissError = useCallback(() => chat.clearError(), [chat.clearError]);
|
||||
|
||||
return html`
|
||||
<div class=${'sw-chat-pane' + (className ? ' ' + className : '')}>
|
||||
${standalone && html`
|
||||
<div class="sw-chat-pane__header">
|
||||
<${ChatHistory}
|
||||
channelId=${chat.channelId}
|
||||
onSelect=${chat.switchChannel}
|
||||
onNew=${chat.newChat} />
|
||||
<div class="sw-chat-pane__header-right">
|
||||
<${ModelSelector}
|
||||
value=${chat.model}
|
||||
onChange=${chat.setModel} />
|
||||
</div>
|
||||
</div>`}
|
||||
${chat.error && html`
|
||||
<div class="sw-chat-pane__error" role="alert">
|
||||
<span class="sw-chat-pane__error-text">${chat.error}</span>
|
||||
<button class="sw-chat-pane__error-dismiss"
|
||||
onClick=${_dismissError}
|
||||
aria-label="Dismiss error">\u00d7</button>
|
||||
</div>`}
|
||||
<${MessageList}
|
||||
messages=${chat.messages}
|
||||
streamContent=${chat.streamContent}
|
||||
streamThinking=${chat.streamThinking}
|
||||
streaming=${chat.streaming}
|
||||
hasMore=${chat.hasMore}
|
||||
loadingMore=${chat.loadingMore}
|
||||
onLoadMore=${chat.loadMore}
|
||||
onRegenerate=${chat.regenerate}
|
||||
onDelete=${chat.deleteMessage} />
|
||||
<${TypingIndicator} users=${chat.typingUsers} />
|
||||
<${MessageInput}
|
||||
handleRef=${inputHandle}
|
||||
onSend=${chat.sendMessage}
|
||||
onStop=${chat.stop}
|
||||
disabled=${chat.sending}
|
||||
streaming=${chat.streaming}
|
||||
channelId=${chat.channelId}
|
||||
onTyping=${chat.emitTyping} />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Kit re-exports ─────────────────────────
|
||||
export { useChat } from './use-chat.js';
|
||||
export { useStream } from './use-stream.js';
|
||||
export { MessageList } from './message-list.js';
|
||||
export { MessageBubble } from './message-bubble.js';
|
||||
export { MessageInput } from './message-input.js';
|
||||
export { ModelSelector } from './model-selector.js';
|
||||
export { ChatHistory } from './chat-history.js';
|
||||
export { renderMarkdown } from './markdown.js';
|
||||
export { CodeBlock, extractCodeBlocks } from './code-block.js';
|
||||
export { MessageActions } from './message-actions.js';
|
||||
export { EmojiPicker } from './emoji-picker.js';
|
||||
export { TypingIndicator } from './typing-indicator.js';
|
||||
@@ -1,90 +0,0 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — Markdown Renderer
|
||||
// ==========================================
|
||||
// Wraps window.marked + window.DOMPurify with fallback.
|
||||
// Independently importable utility.
|
||||
//
|
||||
// Note: This module uses innerHTML via DOMPurify (sanitized) and imperative
|
||||
// DOM patterns intentionally for rendering performance. This is a documented
|
||||
// exception from the "no raw DOM" convention (Critical Review P3-4).
|
||||
//
|
||||
// v0.37.10: Task list rendering, code block language extraction.
|
||||
//
|
||||
// Usage:
|
||||
// import { renderMarkdown } from './markdown.js';
|
||||
// const html = renderMarkdown('**bold** text');
|
||||
|
||||
let _lastInput = '';
|
||||
let _lastOutput = '';
|
||||
|
||||
const _escMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
|
||||
function _escapeHtml(text) {
|
||||
return text.replace(/[&<>"']/g, c => _escMap[c]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure marked with task list support if available.
|
||||
*/
|
||||
let _configured = false;
|
||||
function _ensureConfigured() {
|
||||
if (_configured) return;
|
||||
_configured = true;
|
||||
if (typeof marked === 'undefined') return;
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
});
|
||||
|
||||
// Custom renderer for task list items (marked v16 token-based API)
|
||||
marked.use({
|
||||
renderer: {
|
||||
listitem(token) {
|
||||
const body = this.parser.parse(token.tokens);
|
||||
if (token.task) {
|
||||
const checkbox = token.checked
|
||||
? '<input type="checkbox" checked disabled class="sw-task-checkbox" />'
|
||||
: '<input type="checkbox" disabled class="sw-task-checkbox" />';
|
||||
return '<li class="sw-task-item">' + checkbox + ' ' + body + '</li>\n';
|
||||
}
|
||||
return '<li>' + body + '</li>\n';
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown text to sanitized HTML.
|
||||
* Falls back to escaped plaintext when marked/DOMPurify unavailable.
|
||||
*
|
||||
* @param {string} text — raw markdown
|
||||
* @returns {string} sanitized HTML
|
||||
*/
|
||||
export function renderMarkdown(text) {
|
||||
if (!text) return '';
|
||||
if (text === _lastInput) return _lastOutput;
|
||||
|
||||
let result;
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
_ensureConfigured();
|
||||
result = DOMPurify.sanitize(marked.parse(text));
|
||||
} else {
|
||||
result = _escapeHtml(text);
|
||||
}
|
||||
|
||||
// Highlight @mentions as styled spans (runs after sanitization — safe)
|
||||
result = result.replace(/(^|[\s>])@([\w-]+)/g, '$1<span class="sw-mention">@$2</span>');
|
||||
|
||||
// Run pipe render filters (extensions can transform final HTML)
|
||||
if (window.sw?.pipe?._runRender) {
|
||||
const pipeCtx = window.sw.pipe._runRender({ html: result, raw: text });
|
||||
if (pipeCtx !== null && pipeCtx !== undefined) {
|
||||
result = pipeCtx.html;
|
||||
}
|
||||
}
|
||||
|
||||
_lastInput = text;
|
||||
_lastOutput = result;
|
||||
return result;
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MessageActions Component
|
||||
// ==========================================
|
||||
// Per-message action toolbar: regenerate, copy, delete.
|
||||
// Appears on hover/focus. Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${MessageActions} role="assistant" content=${text}
|
||||
// onRegenerate=${fn} onDelete=${fn} />`
|
||||
|
||||
const html = window.html;
|
||||
const { useState, useCallback } = window.hooks;
|
||||
|
||||
const REGEN_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="1 4 1 10 7 10"/>
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
|
||||
</svg>`;
|
||||
|
||||
const COPY_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>`;
|
||||
|
||||
const CHECK_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>`;
|
||||
|
||||
const DELETE_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>`;
|
||||
|
||||
const SAVE_SVG = html`
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||
<polyline points="17 21 17 13 7 13 7 21"/>
|
||||
<polyline points="7 3 7 8 15 8"/>
|
||||
</svg>`;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* role: string,
|
||||
* content: string,
|
||||
* onRegenerate?: () => void,
|
||||
* onDelete?: () => void,
|
||||
* onSave?: () => void,
|
||||
* hasCodeBlocks?: boolean,
|
||||
* }} props
|
||||
*/
|
||||
export function MessageActions({ role, content, onRegenerate, onDelete, onSave, hasCodeBlocks }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const _copy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content || '');
|
||||
} catch (_) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = content || '';
|
||||
ta.style.cssText = 'position:fixed;left:-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [content]);
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-msg__actions" role="toolbar" aria-label="Message actions">
|
||||
${role === 'assistant' && onRegenerate && html`
|
||||
<button class="sw-chat-msg__action-btn"
|
||||
title="Regenerate"
|
||||
onClick=${onRegenerate}
|
||||
aria-label="Regenerate response">
|
||||
${REGEN_SVG}
|
||||
</button>`}
|
||||
<button class="sw-chat-msg__action-btn"
|
||||
title=${copied ? 'Copied!' : 'Copy'}
|
||||
onClick=${_copy}
|
||||
aria-label="Copy message text">
|
||||
${copied ? CHECK_SVG : COPY_SVG}
|
||||
</button>
|
||||
${role === 'assistant' && hasCodeBlocks && onSave && html`
|
||||
<button class="sw-chat-msg__action-btn"
|
||||
title="Save to workspace"
|
||||
onClick=${onSave}
|
||||
aria-label="Save code to workspace">
|
||||
${SAVE_SVG}
|
||||
</button>`}
|
||||
${onDelete && html`
|
||||
<button class="sw-chat-msg__action-btn sw-chat-msg__action-btn--danger"
|
||||
title="Delete"
|
||||
onClick=${onDelete}
|
||||
aria-label="Delete message">
|
||||
${DELETE_SVG}
|
||||
</button>`}
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MessageBubble Component
|
||||
// ==========================================
|
||||
// Single message rendering with role styling, markdown,
|
||||
// action toolbar, and enhanced code blocks.
|
||||
// Independently importable.
|
||||
//
|
||||
// v0.37.10: Added message actions, timestamps, code block copy.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${MessageBubble} role="assistant" content=${text}
|
||||
// onRegenerate=${fn} onDelete=${fn} />`
|
||||
|
||||
import { renderMarkdown } from './markdown.js';
|
||||
import { CodeBlock, extractCodeBlocks, guessExtension } from './code-block.js';
|
||||
import { MessageActions } from './message-actions.js';
|
||||
|
||||
const html = window.html;
|
||||
const { useMemo, useCallback } = window.hooks;
|
||||
|
||||
/**
|
||||
* Format a relative time string from an ISO date.
|
||||
*/
|
||||
function _relTime(isoDate) {
|
||||
if (!isoDate) return '';
|
||||
try {
|
||||
const d = new Date(isoDate);
|
||||
const now = Date.now();
|
||||
const sec = Math.floor((now - d.getTime()) / 1000);
|
||||
if (sec < 60) return 'just now';
|
||||
if (sec < 3600) return Math.floor(sec / 60) + 'm ago';
|
||||
if (sec < 86400) return Math.floor(sec / 3600) + 'h ago';
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
} catch (_) { return ''; }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* role: string,
|
||||
* content: string,
|
||||
* thinking?: string,
|
||||
* id?: string,
|
||||
* created_at?: string,
|
||||
* streaming?: boolean,
|
||||
* onRegenerate?: () => void,
|
||||
* onDelete?: () => void,
|
||||
* }} props
|
||||
*/
|
||||
export function MessageBubble({ role, content, thinking, id, created_at, streaming, onRegenerate, onDelete, isMine, senderName }) {
|
||||
// isMine: true = right-aligned (current user), false = left-aligned (other user/assistant)
|
||||
const alignment = role === 'assistant' ? 'assistant'
|
||||
: (isMine === false ? 'other' : 'user');
|
||||
const cls = 'sw-chat-msg sw-chat-msg--' + alignment
|
||||
+ (streaming ? ' sw-chat-msg--streaming' : '');
|
||||
|
||||
// Parse markdown and extract code blocks for enhanced rendering
|
||||
const segments = useMemo(() => {
|
||||
const rendered = renderMarkdown(content || '');
|
||||
return extractCodeBlocks(rendered);
|
||||
}, [content]);
|
||||
|
||||
const thinkingHtml = useMemo(() => {
|
||||
if (!thinking) return null;
|
||||
return renderMarkdown(thinking);
|
||||
}, [thinking]);
|
||||
|
||||
const timeStr = _relTime(created_at);
|
||||
const hasCodeBlocks = useMemo(() => segments.some(s => s.type === 'code'), [segments]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!window.sw?.api?.workspaces?.getDefault) return;
|
||||
const codeBlocks = segments.filter(s => s.type === 'code');
|
||||
if (codeBlocks.length === 0) return;
|
||||
|
||||
try {
|
||||
const ws = await window.sw.api.workspaces.getDefault();
|
||||
const ts = Date.now();
|
||||
for (let i = 0; i < codeBlocks.length; i++) {
|
||||
const block = codeBlocks[i];
|
||||
const ext = guessExtension(block.language);
|
||||
const filename = codeBlocks.length === 1
|
||||
? `snippet-${ts}.${ext}`
|
||||
: `snippet-${ts}-${i + 1}.${ext}`;
|
||||
await window.sw.api.workspaces.writeFile(ws.id, filename, block.code);
|
||||
}
|
||||
if (window.sw?.toast) {
|
||||
window.sw.toast(`Saved ${codeBlocks.length} file${codeBlocks.length > 1 ? 's' : ''} to My Files`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
if (window.sw?.toast) {
|
||||
window.sw.toast('Failed to save: ' + (err.message || err), 'error');
|
||||
}
|
||||
}
|
||||
}, [segments]);
|
||||
|
||||
// While streaming, show thinking open if content hasn't started yet
|
||||
const thinkingOpen = streaming && thinking && !content;
|
||||
|
||||
return html`
|
||||
<div class=${cls}>
|
||||
${senderName && alignment === 'other' && html`
|
||||
<div class="sw-chat-msg__sender">${senderName}</div>`}
|
||||
${thinkingHtml && html`
|
||||
<details class="sw-chat-msg__thinking" open=${thinkingOpen || undefined}>
|
||||
<summary class="sw-chat-msg__thinking-toggle">Thinking\u2026</summary>
|
||||
<div class="sw-chat-msg__thinking-content"
|
||||
dangerouslySetInnerHTML=${{ __html: thinkingHtml }} />
|
||||
</details>`}
|
||||
<div class="sw-chat-msg__content">
|
||||
${segments.map((seg, i) =>
|
||||
seg.type === 'code'
|
||||
? html`<${CodeBlock} key=${i} code=${seg.code} language=${seg.language} />`
|
||||
: html`<span key=${i} dangerouslySetInnerHTML=${{ __html: seg.html }} />`
|
||||
)}
|
||||
</div>
|
||||
${!streaming && html`
|
||||
<div class="sw-chat-msg__footer">
|
||||
${timeStr && html`<span class="sw-chat-msg__time">${timeStr}</span>`}
|
||||
<${MessageActions}
|
||||
role=${role}
|
||||
content=${content}
|
||||
onRegenerate=${onRegenerate}
|
||||
onDelete=${onDelete}
|
||||
hasCodeBlocks=${hasCodeBlocks}
|
||||
onSave=${handleSave} />
|
||||
</div>`}
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MessageInput Component
|
||||
// ==========================================
|
||||
// CM6-powered chat input with markdown highlighting,
|
||||
// emoji picker, and stop button.
|
||||
// Independently importable.
|
||||
//
|
||||
// v0.37.10: Added MarkdownToolbar, EmojiPicker, stop overlay, aria.
|
||||
// v0.37.14: Replaced textarea + toolbar with CM.chatInput().
|
||||
//
|
||||
// Usage:
|
||||
// const inputHandle = useRef(null);
|
||||
// html`<${MessageInput} onSend=${fn} onStop=${fn} disabled=${false}
|
||||
// streaming=${false} handleRef=${inputHandle} />`
|
||||
|
||||
import { EmojiPicker } from './emoji-picker.js';
|
||||
|
||||
const { useRef, useState, useCallback, useEffect } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
const SEND_SVG = html`
|
||||
<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>`;
|
||||
|
||||
const STOP_SVG = html`
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
||||
</svg>`;
|
||||
|
||||
const EMOJI_SVG = html`
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
|
||||
<line x1="9" y1="9" x2="9.01" y2="9"/>
|
||||
<line x1="15" y1="9" x2="15.01" y2="9"/>
|
||||
</svg>`;
|
||||
|
||||
// Convert display name to @mention handle (mirrors Go HandleFromName)
|
||||
function _toHandle(name) {
|
||||
return (name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* onSend: (text: string) => void,
|
||||
* onStop?: () => void,
|
||||
* disabled?: boolean,
|
||||
* streaming?: boolean,
|
||||
* handleRef?: { current: any },
|
||||
* channelId?: string,
|
||||
* }} props
|
||||
*/
|
||||
export function MessageInput({ onSend, onStop, disabled, streaming, handleRef, channelId, onTyping }) {
|
||||
const containerRef = useRef(null);
|
||||
const editorRef = useRef(null);
|
||||
const taRef = useRef(null); // fallback textarea
|
||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||
const onSendRef = useRef(onSend);
|
||||
onSendRef.current = onSend;
|
||||
const disabledRef = useRef(disabled);
|
||||
disabledRef.current = disabled;
|
||||
const participantsCache = useRef({ channelId: null, list: null });
|
||||
const onTypingRef = useRef(onTyping);
|
||||
onTypingRef.current = onTyping;
|
||||
const typingTimerRef = useRef(null);
|
||||
const isTypingRef = useRef(false);
|
||||
|
||||
// ── CM6 editor lifecycle ────────────────────
|
||||
useEffect(() => {
|
||||
if (!window.CM?.chatInput || !containerRef.current) return;
|
||||
|
||||
// Build @mention completer bound to current channelId
|
||||
const mentionCompleter = async (query) => {
|
||||
if (!channelId || !window.sw?.api?.channels?.participants) return [];
|
||||
|
||||
// Fetch and cache participants per channel
|
||||
if (participantsCache.current.channelId !== channelId) {
|
||||
try {
|
||||
const resp = await window.sw.api.channels.participants(channelId);
|
||||
participantsCache.current = {
|
||||
channelId,
|
||||
list: (resp || []).map(p => ({
|
||||
label: p.display_name || p.participant_id,
|
||||
handle: _toHandle(p.display_name || p.participant_id),
|
||||
type: p.participant_type || 'user',
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[mention] Failed to fetch participants:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const q = query.toLowerCase();
|
||||
const results = participantsCache.current.list.filter(p =>
|
||||
p.handle.startsWith(q) || p.label.toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
// Add @all option
|
||||
if (!q || 'all'.startsWith(q)) {
|
||||
results.unshift({ label: 'All users', handle: 'all', type: 'all' });
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// Build extensions array with @mention support
|
||||
const extraExtensions = window.CM.mentionExtension
|
||||
? window.CM.mentionExtension({ completer: mentionCompleter })
|
||||
: [];
|
||||
|
||||
const editor = window.CM.chatInput(containerRef.current, {
|
||||
placeholder: 'Type a message\u2026',
|
||||
maxHeight: 160,
|
||||
extensions: extraExtensions,
|
||||
onSubmit: (text) => {
|
||||
const trimmed = text?.trim();
|
||||
if (trimmed && !disabledRef.current) {
|
||||
onSendRef.current(trimmed);
|
||||
editor.setValue('');
|
||||
// Stop typing on send
|
||||
if (isTypingRef.current) {
|
||||
isTypingRef.current = false;
|
||||
onTypingRef.current?.(false);
|
||||
}
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
|
||||
}
|
||||
},
|
||||
onChange: () => {
|
||||
// Emit typing start (debounced: stop after 3s of inactivity)
|
||||
if (!isTypingRef.current) {
|
||||
isTypingRef.current = true;
|
||||
onTypingRef.current?.(true);
|
||||
}
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
|
||||
typingTimerRef.current = setTimeout(() => {
|
||||
isTypingRef.current = false;
|
||||
onTypingRef.current?.(false);
|
||||
}, 3000);
|
||||
},
|
||||
});
|
||||
editorRef.current = editor;
|
||||
|
||||
return () => {
|
||||
editor.destroy();
|
||||
editorRef.current = null;
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
|
||||
if (isTypingRef.current) {
|
||||
isTypingRef.current = false;
|
||||
onTypingRef.current?.(false);
|
||||
}
|
||||
};
|
||||
}, [channelId]);
|
||||
|
||||
// ── Imperative handle ───────────────────────
|
||||
useEffect(() => {
|
||||
if (!handleRef) return;
|
||||
handleRef.current = {
|
||||
getValue() {
|
||||
if (editorRef.current) return editorRef.current.getValue();
|
||||
return taRef.current?.value || '';
|
||||
},
|
||||
setValue(val) {
|
||||
if (editorRef.current) { editorRef.current.setValue(val || ''); return; }
|
||||
if (taRef.current) { taRef.current.value = val || ''; _autoResize(); }
|
||||
},
|
||||
focus() {
|
||||
if (editorRef.current) { editorRef.current.focus(); return; }
|
||||
taRef.current?.focus();
|
||||
},
|
||||
clear() {
|
||||
if (editorRef.current) { editorRef.current.setValue(''); return; }
|
||||
if (taRef.current) { taRef.current.value = ''; _autoResize(); }
|
||||
},
|
||||
};
|
||||
return () => { handleRef.current = null; };
|
||||
}, [handleRef]);
|
||||
|
||||
// ── Fallback textarea helpers ───────────────
|
||||
function _autoResize() {
|
||||
const ta = taRef.current;
|
||||
if (!ta) return;
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
|
||||
}
|
||||
|
||||
const _onInput = useCallback(() => _autoResize(), []);
|
||||
|
||||
const _onKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const text = taRef.current?.value?.trim();
|
||||
if (text && !disabled) {
|
||||
onSend(text);
|
||||
if (taRef.current) { taRef.current.value = ''; _autoResize(); }
|
||||
}
|
||||
}
|
||||
}, [onSend, disabled]);
|
||||
|
||||
const _onClickSend = useCallback(() => {
|
||||
const text = editorRef.current
|
||||
? editorRef.current.getValue()?.trim()
|
||||
: taRef.current?.value?.trim();
|
||||
if (text && !disabled) {
|
||||
onSend(text);
|
||||
if (editorRef.current) editorRef.current.setValue('');
|
||||
else if (taRef.current) { taRef.current.value = ''; _autoResize(); }
|
||||
}
|
||||
}, [onSend, disabled]);
|
||||
|
||||
// ── Emoji ───────────────────────────────────
|
||||
const _insertEmoji = useCallback((emoji) => {
|
||||
if (editorRef.current) {
|
||||
const view = editorRef.current.getView();
|
||||
const pos = view.state.selection.main.head;
|
||||
view.dispatch({ changes: { from: pos, insert: emoji } });
|
||||
view.focus();
|
||||
return;
|
||||
}
|
||||
// Fallback: textarea
|
||||
const ta = taRef.current;
|
||||
if (!ta) return;
|
||||
ta.focus();
|
||||
const start = ta.selectionStart;
|
||||
const hasExecCommand = document.queryCommandSupported?.('insertText');
|
||||
if (hasExecCommand) {
|
||||
document.execCommand('insertText', false, emoji);
|
||||
} else {
|
||||
ta.value = ta.value.slice(0, start) + emoji + ta.value.slice(ta.selectionEnd);
|
||||
}
|
||||
const pos = start + emoji.length;
|
||||
ta.setSelectionRange(pos, pos);
|
||||
_autoResize();
|
||||
}, []);
|
||||
|
||||
const _toggleEmoji = useCallback(() => setEmojiOpen(v => !v), []);
|
||||
const _closeEmoji = useCallback(() => setEmojiOpen(false), []);
|
||||
|
||||
// ── Render ──────────────────────────────────
|
||||
const useCM = !!window.CM?.chatInput;
|
||||
|
||||
return html`
|
||||
<div class="sw-chat-pane__input-bar">
|
||||
<div class="sw-msg-input__wrap">
|
||||
${useCM
|
||||
? html`<div ref=${containerRef} class="sw-msg-input__cm-container" />`
|
||||
: html`<textarea
|
||||
ref=${taRef}
|
||||
class="sw-msg-input__textarea"
|
||||
placeholder="Type a message\u2026"
|
||||
rows="1"
|
||||
disabled=${disabled}
|
||||
onInput=${_onInput}
|
||||
onKeyDown=${_onKeyDown}
|
||||
aria-label="Message input"
|
||||
/>`}
|
||||
<div class="sw-msg-input__buttons">
|
||||
<div class="sw-msg-input__emoji-wrap">
|
||||
<button
|
||||
class="sw-msg-input__emoji-btn"
|
||||
title="Emoji"
|
||||
onClick=${_toggleEmoji}
|
||||
type="button"
|
||||
aria-label="Open emoji picker"
|
||||
aria-expanded=${emojiOpen}
|
||||
>${EMOJI_SVG}</button>
|
||||
<${EmojiPicker}
|
||||
open=${emojiOpen}
|
||||
onSelect=${_insertEmoji}
|
||||
onClose=${_closeEmoji} />
|
||||
</div>
|
||||
${streaming && onStop
|
||||
? html`<button
|
||||
class="sw-msg-input__stop"
|
||||
title="Stop generating"
|
||||
onClick=${onStop}
|
||||
aria-label="Stop generating"
|
||||
>${STOP_SVG}</button>`
|
||||
: html`<button
|
||||
class="sw-msg-input__send"
|
||||
title="Send"
|
||||
disabled=${disabled}
|
||||
onClick=${_onClickSend}
|
||||
aria-label="Send message"
|
||||
>${SEND_SVG}</button>`}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — MessageList Component
|
||||
// ==========================================
|
||||
// Scrollable message list with auto-scroll-to-bottom
|
||||
// and "load older" pagination.
|
||||
// Independently importable.
|
||||
//
|
||||
// v0.37.10: Added pagination (load more), regenerate/delete wiring.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${MessageList} messages=${msgs} streamContent=${text}
|
||||
// streaming=${true} hasMore=${true} loadingMore=${false}
|
||||
// onLoadMore=${fn} onRegenerate=${fn} onDelete=${fn} />`
|
||||
|
||||
import { MessageBubble } from './message-bubble.js';
|
||||
|
||||
const { useRef, useEffect, useCallback } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
const SCROLL_THRESHOLD = 60;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* messages: Array<{role: string, content: string, id?: string, created_at?: string}>,
|
||||
* streamContent?: string,
|
||||
* streamThinking?: string,
|
||||
* streaming?: boolean,
|
||||
* hasMore?: boolean,
|
||||
* loadingMore?: boolean,
|
||||
* onLoadMore?: () => void,
|
||||
* onRegenerate?: (msgId: string) => void,
|
||||
* onDelete?: (msgId: string) => void,
|
||||
* className?: string,
|
||||
* }} props
|
||||
*/
|
||||
export function MessageList({
|
||||
messages, streamContent, streamThinking, streaming, hasMore, loadingMore,
|
||||
onLoadMore, onRegenerate, onDelete, className,
|
||||
}) {
|
||||
const scrollRef = useRef(null);
|
||||
const wasAtBottom = useRef(true);
|
||||
const prevScrollHeightRef = useRef(0);
|
||||
|
||||
// Track scroll position
|
||||
function _onScroll() {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
wasAtBottom.current = (el.scrollHeight - el.scrollTop - el.clientHeight) < SCROLL_THRESHOLD;
|
||||
}
|
||||
|
||||
// Auto-scroll when new content arrives (only if was at bottom)
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el && wasAtBottom.current) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [messages, streamContent]);
|
||||
|
||||
// Preserve scroll position when prepending older messages
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const prevH = prevScrollHeightRef.current;
|
||||
if (prevH && el.scrollHeight > prevH && !wasAtBottom.current) {
|
||||
// Older messages were prepended — maintain relative position
|
||||
el.scrollTop += (el.scrollHeight - prevH);
|
||||
}
|
||||
prevScrollHeightRef.current = el.scrollHeight;
|
||||
}, [messages.length]);
|
||||
|
||||
const hasContent = messages.length > 0 || streaming;
|
||||
const myUserId = window.sw?.auth?.user?.id;
|
||||
|
||||
return html`
|
||||
<div class=${'sw-chat-pane__messages' + (className ? ' ' + className : '')}
|
||||
ref=${scrollRef}
|
||||
onScroll=${_onScroll}
|
||||
role="log"
|
||||
aria-label="Chat messages"
|
||||
aria-live="polite">
|
||||
${hasMore && html`
|
||||
<div class="sw-chat-pane__load-more">
|
||||
<button class="sw-chat-pane__load-more-btn"
|
||||
onClick=${onLoadMore}
|
||||
disabled=${loadingMore}
|
||||
aria-label="Load older messages">
|
||||
${loadingMore ? 'Loading\u2026' : 'Load older messages'}
|
||||
</button>
|
||||
</div>`}
|
||||
${!hasContent && html`
|
||||
<div class="sw-chat-welcome">
|
||||
<p class="sw-chat-welcome__title">Start a conversation</p>
|
||||
<p class="sw-chat-welcome__hint">Type a message below to get started.</p>
|
||||
</div>`}
|
||||
${messages.map((m, i) => {
|
||||
// Determine if this message was sent by the current user
|
||||
const senderId = m.user_id || m.participant_id;
|
||||
const isMine = m.role === 'user' && (!senderId || senderId === myUserId);
|
||||
return html`
|
||||
<${MessageBubble}
|
||||
key=${m.id || i}
|
||||
role=${m.role}
|
||||
content=${m.content}
|
||||
thinking=${m.thinking}
|
||||
id=${m.id}
|
||||
created_at=${m.created_at}
|
||||
isMine=${isMine}
|
||||
senderName=${m.role === 'user' && !isMine ? (m.display_name || m.sender_name || m.username || '') : ''}
|
||||
onRegenerate=${m.role === 'assistant' && m.id && onRegenerate ? () => onRegenerate(m.id) : undefined}
|
||||
onDelete=${m.id && onDelete ? () => onDelete(m.id) : undefined} />`;
|
||||
})}
|
||||
${streaming && html`
|
||||
<${MessageBubble} role="assistant" content=${streamContent}
|
||||
thinking=${streamThinking} streaming=${true} />`}
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — ModelSelector Component
|
||||
// ==========================================
|
||||
// Model dropdown loaded from sw.api.models.enabled().
|
||||
// Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${ModelSelector} value=${modelId} onChange=${fn} />`
|
||||
|
||||
const { useState, useEffect } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
const HEALTH_DOTS = { healthy: '\u{1F7E2}', degraded: '\u{1F7E1}', inactive: '\u{1F534}' };
|
||||
|
||||
/**
|
||||
* @param {{ value: string, onChange: (id: string) => void }} props
|
||||
*/
|
||||
export function ModelSelector({ value, onChange }) {
|
||||
const [models, setModels] = useState([]);
|
||||
const [health, setHealth] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.sw?.api?.models?.enabled) return;
|
||||
let cancelled = false;
|
||||
window.sw.api.models.enabled().then(resp => {
|
||||
if (cancelled) return;
|
||||
const list = (resp.data || []).filter(m => !m.hidden);
|
||||
setModels(list);
|
||||
if (!value && list.length && onChange) {
|
||||
const first = list[0];
|
||||
const id = first.isPersona ? (first.personaId || first.id) : first.id;
|
||||
onChange(id);
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
// Fetch provider health for admin users (graceful degrade for non-admin)
|
||||
if (window.sw?.api?.admin?.providers?.health) {
|
||||
window.sw.api.admin.providers.health().then(resp => {
|
||||
if (cancelled) return;
|
||||
const map = {};
|
||||
for (const p of (resp.data || resp || [])) {
|
||||
if (p.provider_name) map[p.provider_name] = p.status;
|
||||
}
|
||||
setHealth(map);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
function _onChange(e) {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
|
||||
return html`
|
||||
<select class="sw-model-selector__select"
|
||||
title="Select model"
|
||||
value=${value || ''}
|
||||
onChange=${_onChange}
|
||||
disabled=${!models.length}>
|
||||
${!models.length && html`
|
||||
<option value="" disabled selected>No models</option>`}
|
||||
${models.map(m => {
|
||||
const id = m.isPersona ? (m.personaId || m.id) : m.id;
|
||||
const dot = !m.isPersona && m.provider_name && health[m.provider_name]
|
||||
? (HEALTH_DOTS[health[m.provider_name]] || '') + ' '
|
||||
: '';
|
||||
const label = (m.isPersona ? '\ud83c\udfad ' : dot) + (m.name || m.id);
|
||||
return html`<option key=${id} value=${id}>${label}</option>`;
|
||||
})}
|
||||
</select>`;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — TypingIndicator Component
|
||||
// ==========================================
|
||||
// Shows "X is typing..." below the message list.
|
||||
// Handles 1, 2, or 3+ typers.
|
||||
//
|
||||
// v0.37.14: Initial implementation.
|
||||
//
|
||||
// Usage:
|
||||
// html`<${TypingIndicator} users=${[{id, name}]} />`
|
||||
|
||||
const html = window.html;
|
||||
|
||||
/**
|
||||
* @param {{ users: Array<{id: string, name: string}> }} props
|
||||
*/
|
||||
export function TypingIndicator({ users }) {
|
||||
if (!users || users.length === 0) return null;
|
||||
|
||||
let text;
|
||||
if (users.length === 1) {
|
||||
text = users[0].name + ' is typing';
|
||||
} else if (users.length === 2) {
|
||||
text = users[0].name + ' and ' + users[1].name + ' are typing';
|
||||
} else {
|
||||
text = users[0].name + ' and ' + (users.length - 1) + ' others are typing';
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="sw-typing-indicator" aria-live="polite">
|
||||
<span class="sw-typing-indicator__dots">
|
||||
<span class="sw-typing-indicator__dot" />
|
||||
<span class="sw-typing-indicator__dot" />
|
||||
<span class="sw-typing-indicator__dot" />
|
||||
</span>
|
||||
<span class="sw-typing-indicator__text">${text}</span>
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — useChat Hook
|
||||
// ==========================================
|
||||
// Core chat state machine: messages, channel CRUD, send/receive,
|
||||
// model selection. Independently importable.
|
||||
//
|
||||
// v0.37.10: Added regenerate(), deleteMessage(), pagination.
|
||||
//
|
||||
// Usage:
|
||||
// const chat = useChat({ onChannelChange });
|
||||
// chat.sendMessage('Hello');
|
||||
|
||||
import { useStream } from './use-stream.js';
|
||||
|
||||
const { useState, useRef, useCallback, useEffect } = window.hooks;
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/** Extract <think>…</think> blocks from raw content.
|
||||
* Returns { content, thinking } with tags stripped. */
|
||||
function extractThinking(raw) {
|
||||
if (!raw || !raw.includes('<think>')) return { content: raw, thinking: '' };
|
||||
let thinking = '';
|
||||
const content = raw.replace(/<think>([\s\S]*?)<\/think>/g, (_, t) => {
|
||||
thinking += (thinking ? '\n' : '') + t.trim();
|
||||
return '';
|
||||
});
|
||||
return { content: content.trim(), thinking };
|
||||
}
|
||||
|
||||
/** Apply extractThinking to an array of message objects. */
|
||||
function hydrateThinking(msgs) {
|
||||
return msgs.map(m => {
|
||||
const { content, thinking } = extractThinking(m.content);
|
||||
return thinking ? { ...m, content, thinking } : m;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ initialChannelId?: string, getContext?: () => {path: string, content: string}|null, onChannelChange?: (id: string) => void }} opts
|
||||
*/
|
||||
export function useChat(opts = {}) {
|
||||
const { initialChannelId, getContext, onChannelChange, externalModel } = opts;
|
||||
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [channelId, setChannelId] = useState(initialChannelId || null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [model, setModel] = useState('');
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const channelRef = useRef(channelId);
|
||||
const pageRef = useRef(1);
|
||||
const { streamContent, streamThinking, streaming, startStream, stopStream } = useStream();
|
||||
|
||||
// Keep ref in sync
|
||||
channelRef.current = channelId;
|
||||
|
||||
// Use external model (from parent surface) when provided
|
||||
const effectiveModel = externalModel || model;
|
||||
const modelRef = useRef(effectiveModel);
|
||||
modelRef.current = effectiveModel;
|
||||
|
||||
// ── Init model from session or first enabled ─────────────
|
||||
if (!model && window.sw?.auth?.session?.settings?.model) {
|
||||
setTimeout(() => setModel(window.sw.auth.session.settings.model), 0);
|
||||
}
|
||||
// Auto-load first enabled model if none set
|
||||
const modelInitRef = useRef(false);
|
||||
if (!effectiveModel && !modelInitRef.current && window.sw?.api?.models?.enabled) {
|
||||
modelInitRef.current = true;
|
||||
window.sw.api.models.enabled().then(resp => {
|
||||
const list = resp.data || [];
|
||||
const visible = list.filter(m => !m.hidden);
|
||||
if (visible.length && !model && !externalModel) {
|
||||
const first = visible[0];
|
||||
setModel(first.isPersona ? (first.personaId || first.id) : first.id);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ── Send Message ────────────────────────
|
||||
const sendMessage = useCallback(async (text) => {
|
||||
if (!text.trim() || sending) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
|
||||
// Resolve model: use current ref value, or fetch first enabled model on the fly
|
||||
let resolvedModel = modelRef.current;
|
||||
if (!resolvedModel && window.sw?.api?.models?.enabled) {
|
||||
try {
|
||||
const resp = await window.sw.api.models.enabled();
|
||||
const list = (resp.data || []).filter(m => !m.hidden);
|
||||
if (list.length) {
|
||||
const first = list[0];
|
||||
resolvedModel = first.isPersona ? (first.personaId || first.id) : first.id;
|
||||
setModel(resolvedModel);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
let cid = channelRef.current;
|
||||
|
||||
// Create channel if needed
|
||||
if (!cid) {
|
||||
try {
|
||||
const title = text.slice(0, 50) + (text.length > 50 ? '\u2026' : '');
|
||||
const resp = await window.sw.api.channels.create({ title, model_id: resolvedModel, channel_type: 'direct' });
|
||||
cid = resp.id;
|
||||
channelRef.current = cid;
|
||||
setChannelId(cid);
|
||||
if (onChannelChange) onChannelChange(cid);
|
||||
} catch (e) {
|
||||
setError('Failed to create chat: ' + e.message);
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Append user message optimistically
|
||||
const myId = window.sw?.auth?.user?.id;
|
||||
const userMsg = { id: 'tmp-' + Date.now(), role: 'user', content: text, user_id: myId };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
|
||||
try {
|
||||
// Build content with optional context injection
|
||||
let content = text;
|
||||
if (getContext) {
|
||||
const ctx = getContext();
|
||||
if (ctx) {
|
||||
content = text + '\n\n[Context: Currently editing ' + ctx.path + ']\n```\n' + ctx.content + '\n```';
|
||||
}
|
||||
}
|
||||
|
||||
// Run pipe pre-send filters (extensions can transform or block)
|
||||
if (window.sw?.pipe?._runPre) {
|
||||
const pipeCtx = window.sw.pipe._runPre({ content, channel: { id: cid }, model: resolvedModel });
|
||||
if (pipeCtx === null) { setSending(false); return; }
|
||||
content = pipeCtx.content;
|
||||
}
|
||||
|
||||
const data = { channel_id: cid, content, model: resolvedModel };
|
||||
const resp = await window.sw.api.channels.complete(data);
|
||||
// ai_mode off/mention_only returns JSON, not SSE — skip streaming
|
||||
const ct = resp.headers.get('content-type') || '';
|
||||
if (ct.includes('application/json')) {
|
||||
// Message persisted server-side, no AI response expected
|
||||
} else {
|
||||
await startStream(resp);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
setError('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
setSending(false);
|
||||
}, [sending, effectiveModel, getContext, onChannelChange, startStream]);
|
||||
|
||||
// Finalize: when streaming ends, capture the final content into messages
|
||||
const prevStreamingRef = useRef(false);
|
||||
if (prevStreamingRef.current && !streaming && streamContent) {
|
||||
prevStreamingRef.current = false;
|
||||
const msg = { role: 'assistant', content: streamContent };
|
||||
if (streamThinking) msg.thinking = streamThinking;
|
||||
setMessages(prev => [...prev, msg]);
|
||||
}
|
||||
prevStreamingRef.current = streaming;
|
||||
|
||||
// ── Regenerate ──────────────────────────
|
||||
const regenerate = useCallback(async (msgId) => {
|
||||
const cid = channelRef.current;
|
||||
if (!cid || !msgId || sending) return;
|
||||
|
||||
setSending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const resp = await window.sw.api.channels.regenerate(cid, msgId, { model: modelRef.current });
|
||||
// Remove the old assistant message being regenerated
|
||||
setMessages(prev => {
|
||||
const idx = prev.findIndex(m => m.id === msgId);
|
||||
return idx >= 0 ? prev.slice(0, idx) : prev;
|
||||
});
|
||||
await startStream(resp);
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
setError('Regenerate failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
setSending(false);
|
||||
}, [sending, effectiveModel, startStream]);
|
||||
|
||||
// ── Delete Message ──────────────────────
|
||||
const deleteMessage = useCallback(async (msgId) => {
|
||||
// Optimistic remove from UI
|
||||
setMessages(prev => prev.filter(m => m.id !== msgId));
|
||||
|
||||
// If API supports delete, fire it (no-op if not available)
|
||||
if (window.sw?.api?.channels?.deleteMessage) {
|
||||
try {
|
||||
await window.sw.api.channels.deleteMessage(channelRef.current, msgId);
|
||||
} catch (_) { /* swallow — already removed from UI */ }
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Switch Channel ──────────────────────
|
||||
const switchChannel = useCallback(async (id) => {
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setHasMore(false);
|
||||
pageRef.current = 1;
|
||||
channelRef.current = id;
|
||||
setChannelId(id);
|
||||
if (onChannelChange) onChannelChange(id);
|
||||
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const resp = await window.sw.api.channels.messages(id, { page: 1, per_page: PAGE_SIZE });
|
||||
const data = hydrateThinking(resp.data || resp || []);
|
||||
setMessages(data);
|
||||
setHasMore(data.length >= PAGE_SIZE);
|
||||
} catch (e) {
|
||||
// Channel deleted/not found — clear selection
|
||||
if ((e.status === 404 || e.message?.includes('404')) && onChannelChange) {
|
||||
channelRef.current = null;
|
||||
setChannelId(null);
|
||||
onChannelChange(null);
|
||||
return;
|
||||
}
|
||||
setError('Failed to load messages: ' + e.message);
|
||||
}
|
||||
|
||||
// Mark channel as read
|
||||
if (window.sw?.api?.channels?.markRead) {
|
||||
window.sw.api.channels.markRead(id).catch(() => {});
|
||||
}
|
||||
}, [onChannelChange]);
|
||||
|
||||
// ── Load More (pagination) ──────────────
|
||||
const loadMore = useCallback(async () => {
|
||||
const cid = channelRef.current;
|
||||
if (!cid || loadingMore || !hasMore) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
const nextPage = pageRef.current + 1;
|
||||
|
||||
try {
|
||||
const resp = await window.sw.api.channels.messages(cid, { page: nextPage, per_page: PAGE_SIZE });
|
||||
const data = hydrateThinking(resp.data || resp || []);
|
||||
if (data.length > 0) {
|
||||
pageRef.current = nextPage;
|
||||
// Prepend older messages
|
||||
setMessages(prev => [...data, ...prev]);
|
||||
setHasMore(data.length >= PAGE_SIZE);
|
||||
} else {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Failed to load older messages: ' + e.message);
|
||||
}
|
||||
|
||||
setLoadingMore(false);
|
||||
}, [loadingMore, hasMore]);
|
||||
|
||||
// ── New Chat ────────────────────────────
|
||||
const newChat = useCallback(() => {
|
||||
stopStream();
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setHasMore(false);
|
||||
pageRef.current = 1;
|
||||
channelRef.current = null;
|
||||
setChannelId(null);
|
||||
if (onChannelChange) onChannelChange(null);
|
||||
}, [stopStream, onChannelChange]);
|
||||
|
||||
// ── Stop ────────────────────────────────
|
||||
const stop = useCallback(() => {
|
||||
stopStream();
|
||||
setSending(false);
|
||||
}, [stopStream]);
|
||||
|
||||
// ── Clear Error ─────────────────────────
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
// ── Typing indicators ───────────────────
|
||||
const [typingUsers, setTypingUsers] = useState([]); // [{ id, name }]
|
||||
const typingTimers = useRef({}); // id → timeout
|
||||
|
||||
// ── WebSocket: listen for messages from other users ──
|
||||
useEffect(() => {
|
||||
if (!window.sw?.on) return;
|
||||
const handler = (evt) => {
|
||||
try {
|
||||
const data = typeof evt.payload === 'string' ? JSON.parse(evt.payload) : (evt.payload || evt);
|
||||
// Only append if it's for the active channel
|
||||
if (data.channel_id !== channelRef.current) return;
|
||||
// Skip messages from self (already added optimistically)
|
||||
const myId = window.sw?.auth?.user?.id;
|
||||
if (data.user_id && data.user_id === myId) return;
|
||||
const { content: cleanContent, thinking } = extractThinking(data.content);
|
||||
const msg = {
|
||||
id: data.id,
|
||||
role: data.role || 'user',
|
||||
content: cleanContent,
|
||||
user_id: data.user_id,
|
||||
participant_id: data.participant_id || data.user_id,
|
||||
participant_type: data.participant_type || 'user',
|
||||
display_name: data.display_name,
|
||||
created_at: data.created_at || new Date().toISOString(),
|
||||
};
|
||||
if (thinking) msg.thinking = thinking;
|
||||
setMessages(prev => prev.some(m => m.id && m.id === msg.id) ? prev : [...prev, msg]);
|
||||
// Clear typing for this user/persona when their message arrives
|
||||
const pid = data.participant_id || data.user_id;
|
||||
if (pid) _clearTyping(pid);
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
// Typing start handler (persona chaining + user typing)
|
||||
const typingStartHandler = (evt) => {
|
||||
try {
|
||||
const data = typeof evt.payload === 'string' ? JSON.parse(evt.payload) : (evt.payload || evt);
|
||||
if (data.channel_id !== channelRef.current) return;
|
||||
const pid = data.participant_id || data.user_id;
|
||||
if (!pid) return;
|
||||
const myId = window.sw?.auth?.user?.id;
|
||||
if (pid === myId) return;
|
||||
const name = data.display_name || pid;
|
||||
setTypingUsers(prev => {
|
||||
if (prev.some(t => t.id === pid)) return prev;
|
||||
return [...prev, { id: pid, name }];
|
||||
});
|
||||
// Auto-clear after 10s (safety net)
|
||||
if (typingTimers.current[pid]) clearTimeout(typingTimers.current[pid]);
|
||||
typingTimers.current[pid] = setTimeout(() => _clearTyping(pid), 10000);
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
// Typing stop handler
|
||||
const typingStopHandler = (evt) => {
|
||||
try {
|
||||
const data = typeof evt.payload === 'string' ? JSON.parse(evt.payload) : (evt.payload || evt);
|
||||
const pid = data.participant_id || data.user_id;
|
||||
if (pid) _clearTyping(pid);
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
// message.deleted handler (other clients' deletes)
|
||||
const deleteHandler = (evt) => {
|
||||
try {
|
||||
const data = typeof evt.payload === 'string' ? JSON.parse(evt.payload) : (evt.payload || evt);
|
||||
if (data.channel_id !== channelRef.current) return;
|
||||
setMessages(prev => prev.filter(m => m.id !== data.id));
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
window.sw.on('message.created', handler);
|
||||
window.sw.on('message.deleted', deleteHandler);
|
||||
window.sw.on('typing.start', typingStartHandler);
|
||||
window.sw.on('typing.stop', typingStopHandler);
|
||||
window.sw.on('chat.typing.start', typingStartHandler);
|
||||
window.sw.on('chat.typing.stop', typingStopHandler);
|
||||
return () => {
|
||||
window.sw.off?.('message.created', handler);
|
||||
window.sw.off?.('message.deleted', deleteHandler);
|
||||
window.sw.off?.('typing.start', typingStartHandler);
|
||||
window.sw.off?.('typing.stop', typingStopHandler);
|
||||
window.sw.off?.('chat.typing.start', typingStartHandler);
|
||||
window.sw.off?.('chat.typing.stop', typingStopHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function _clearTyping(pid) {
|
||||
if (typingTimers.current[pid]) {
|
||||
clearTimeout(typingTimers.current[pid]);
|
||||
delete typingTimers.current[pid];
|
||||
}
|
||||
setTypingUsers(prev => prev.filter(t => t.id !== pid));
|
||||
}
|
||||
|
||||
// Emit typing event (called from MessageInput via prop)
|
||||
const emitTyping = useCallback((isTyping) => {
|
||||
const cid = channelRef.current;
|
||||
if (!cid || !window.sw?.emit) return;
|
||||
const myId = window.sw?.auth?.user?.id;
|
||||
const myName = window.sw?.auth?.user?.display_name || window.sw?.auth?.user?.username || '';
|
||||
window.sw.emit(isTyping ? 'chat.typing.start' : 'chat.typing.stop', {
|
||||
channel_id: cid,
|
||||
participant_id: myId,
|
||||
participant_type: 'user',
|
||||
display_name: myName,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
channelId,
|
||||
sending,
|
||||
error,
|
||||
model,
|
||||
setModel,
|
||||
sendMessage,
|
||||
switchChannel,
|
||||
newChat,
|
||||
stop,
|
||||
clearError,
|
||||
// Streaming state (passthrough from useStream)
|
||||
streamContent,
|
||||
streamThinking,
|
||||
streaming,
|
||||
// v0.37.10: new methods
|
||||
regenerate,
|
||||
deleteMessage,
|
||||
loadMore,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
// v0.37.14: typing indicators
|
||||
typingUsers,
|
||||
emitTyping,
|
||||
};
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
// ==========================================
|
||||
// ChatPane Kit — useStream Hook
|
||||
// ==========================================
|
||||
// SSE ReadableStream parser with rAF-batched state updates.
|
||||
// Independently importable — no ChatPane dependency.
|
||||
//
|
||||
// Usage:
|
||||
// const { streamContent, streaming, startStream, stopStream } = useStream();
|
||||
// // Pass a Response from sw.api.channels.complete():
|
||||
// startStream(response);
|
||||
|
||||
const { useState, useRef, useCallback } = window.hooks;
|
||||
|
||||
/**
|
||||
* Hook that parses an SSE stream from a fetch Response.
|
||||
*
|
||||
* @returns {{ streamContent: string, streaming: boolean, startStream: (resp: Response) => void, stopStream: () => void }}
|
||||
*/
|
||||
export function useStream() {
|
||||
const [streamContent, setStreamContent] = useState('');
|
||||
const [streamThinking, setStreamThinking] = useState('');
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const contentRef = useRef('');
|
||||
const thinkingRef = useRef('');
|
||||
const abortRef = useRef(null);
|
||||
const rafRef = useRef(null);
|
||||
|
||||
const _flush = useCallback(() => {
|
||||
setStreamContent(contentRef.current);
|
||||
setStreamThinking(thinkingRef.current);
|
||||
rafRef.current = null;
|
||||
}, []);
|
||||
|
||||
const _scheduleFlush = useCallback(() => {
|
||||
if (!rafRef.current) {
|
||||
rafRef.current = requestAnimationFrame(_flush);
|
||||
}
|
||||
}, [_flush]);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
setStreamContent(contentRef.current);
|
||||
setStreamThinking(thinkingRef.current);
|
||||
setStreaming(false);
|
||||
}, []);
|
||||
|
||||
const startStream = useCallback(async (response) => {
|
||||
// Reset
|
||||
contentRef.current = '';
|
||||
thinkingRef.current = '';
|
||||
setStreamContent('');
|
||||
setStreamThinking('');
|
||||
setStreaming(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (controller.signal.aborted) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
try {
|
||||
const parsed = JSON.parse(data).choices?.[0]?.delta;
|
||||
if (parsed?.content) {
|
||||
let chunk = parsed.content;
|
||||
// Run pipe stream filters (extensions can transform or suppress chunks)
|
||||
if (window.sw?.pipe?._runStream) {
|
||||
const pipeCtx = window.sw.pipe._runStream({ content: chunk });
|
||||
if (pipeCtx === null) continue;
|
||||
chunk = pipeCtx.content;
|
||||
}
|
||||
contentRef.current += chunk;
|
||||
_scheduleFlush();
|
||||
}
|
||||
if (parsed?.reasoning_content) {
|
||||
thinkingRef.current += parsed.reasoning_content;
|
||||
_scheduleFlush();
|
||||
}
|
||||
} catch (_) { /* skip malformed JSON */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
contentRef.current += '\n\n**[Stream error: ' + e.message + ']**';
|
||||
}
|
||||
}
|
||||
|
||||
// Final flush
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
setStreamContent(contentRef.current);
|
||||
setStreamThinking(thinkingRef.current);
|
||||
setStreaming(false);
|
||||
abortRef.current = null;
|
||||
}, [_scheduleFlush]);
|
||||
|
||||
return { streamContent, streamThinking, streaming, startStream, stopStream };
|
||||
}
|
||||
@@ -356,7 +356,7 @@ export const debugEngine = {
|
||||
// ── Export ───────────────────────────────
|
||||
|
||||
exportText() {
|
||||
let text = `=== Chat Switchboard Debug Export ===\n`;
|
||||
let text = `=== Switchboard Core Debug Export ===\n`;
|
||||
text += `Exported: ${new Date().toISOString()}\n`;
|
||||
text += `URL: ${location.href}\n`;
|
||||
text += `ENV: ${window.__ENV__ || 'unknown'} | VERSION: ${window.__VERSION__ || 'unknown'} | BASE: ${window.__BASE__ || '(root)'}\n`;
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
// ==========================================
|
||||
// NotesPane Kit — Default Assembly
|
||||
// ==========================================
|
||||
// Wires useNotes + all sub-components into the standard
|
||||
// notes experience. Surfaces that need a different
|
||||
// layout import individual pieces instead.
|
||||
//
|
||||
// Usage:
|
||||
// import { NotesPane } from './components/notes-pane/index.js';
|
||||
// html`<${NotesPane} handleRef=${ref} standalone=${true} />`
|
||||
//
|
||||
// Re-exports all pieces for kit consumers:
|
||||
// import { useNotes, NoteList, NoteEditor, ... } from './components/notes-pane/index.js';
|
||||
|
||||
import { useNotes } from './use-notes.js';
|
||||
import { NoteList } from './note-list.js';
|
||||
import { NoteListItem } from './note-list-item.js';
|
||||
import { NoteEditor } from './note-editor.js';
|
||||
import { NoteReader } from './note-reader.js';
|
||||
import { QuickSwitcher, saveRecentNote } from './quick-switcher.js';
|
||||
import { SaveToNoteDialog } from './save-to-note.js';
|
||||
import { renderNoteMarkdown, attachWikilinkHandlers, resolveTransclusions, extractHeadings } from './markdown.js';
|
||||
|
||||
const { useState, useRef, useEffect, useCallback } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
/**
|
||||
* Default NotesPane assembly.
|
||||
*
|
||||
* @param {{
|
||||
* initialFolder?: string,
|
||||
* initialNoteId?: string,
|
||||
* standalone?: boolean,
|
||||
* onNoteChange?: (note: object|null) => void,
|
||||
* handleRef?: { current: any },
|
||||
* className?: string,
|
||||
* }} props
|
||||
*/
|
||||
export function NotesPane(props) {
|
||||
const {
|
||||
initialFolder,
|
||||
initialNoteId,
|
||||
standalone = true,
|
||||
onNoteChange,
|
||||
handleRef,
|
||||
className,
|
||||
} = props;
|
||||
|
||||
const notes = useNotes({
|
||||
initialFolder,
|
||||
initialNoteId,
|
||||
onNoteChange,
|
||||
standalone,
|
||||
enableGraph: true,
|
||||
enableQuickSwitcher: true,
|
||||
});
|
||||
|
||||
// ── Lazy-load graph component ───────────
|
||||
const [GraphComp, setGraphComp] = useState(null);
|
||||
useEffect(() => {
|
||||
if (notes.view === 'graph' && !GraphComp) {
|
||||
import('./note-graph.js').then(m => setGraphComp(() => m.NoteGraph));
|
||||
}
|
||||
}, [notes.view, GraphComp]);
|
||||
|
||||
const graphHandleRef = useRef(null);
|
||||
|
||||
// ── Graph data loading ──────────────────
|
||||
useEffect(() => {
|
||||
if (notes.view === 'graph' && !notes.graphData) {
|
||||
notes.loadGraph();
|
||||
}
|
||||
}, [notes.view]);
|
||||
|
||||
// ── Expose imperative handle ────────────
|
||||
useEffect(() => {
|
||||
if (handleRef) {
|
||||
handleRef.current = {
|
||||
getView() { return notes.view; },
|
||||
setView(v) { notes.setView(v); },
|
||||
openNote(id) { saveRecentNote({ id, title: '' }); notes.openNote(id); },
|
||||
newNote() { notes.newNote(); },
|
||||
openDailyNote() { notes.openDailyNote(); },
|
||||
refreshList() { notes.refreshList(); },
|
||||
getEditingId() { return notes.editingId; },
|
||||
openQuickSwitcher() { notes.setQuickSwitcherOpen(true); },
|
||||
// Surface-driven filter controls (v0.37.11)
|
||||
setFolder(f) { notes.setFolder(f); },
|
||||
setTagFilter(t) { notes.setTagFilter(t); },
|
||||
setSearchQuery(q) { notes.setSearchQuery(q); },
|
||||
getFolder() { return notes.folder; },
|
||||
getTagFilter() { return notes.tagFilter; },
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ── Quick switcher create-new handler ───
|
||||
const onQuickSwitcherCreate = useCallback((title) => {
|
||||
notes.newNote();
|
||||
// The editor will open blank — we can't pre-fill via state here
|
||||
// but the title will be empty for the user to type
|
||||
}, [notes.newNote]);
|
||||
|
||||
// ── View routing ────────────────────────
|
||||
const renderView = () => {
|
||||
switch (notes.view) {
|
||||
case 'list':
|
||||
return html`
|
||||
<${NoteList}
|
||||
notes=${notes.notes} loading=${notes.loading}
|
||||
hasMore=${notes.hasMore} loadMore=${notes.loadMore}
|
||||
folders=${notes.folders} folder=${notes.folder} setFolder=${notes.setFolder}
|
||||
sort=${notes.sort} setSort=${notes.setSort}
|
||||
searchQuery=${notes.searchQuery} setSearchQuery=${notes.setSearchQuery}
|
||||
tagFilter=${notes.tagFilter} setTagFilter=${notes.setTagFilter}
|
||||
selectMode=${notes.selectMode} selectedIds=${notes.selectedIds}
|
||||
enterSelectMode=${notes.enterSelectMode} exitSelectMode=${notes.exitSelectMode}
|
||||
toggleSelect=${notes.toggleSelect} toggleSelectAll=${notes.toggleSelectAll}
|
||||
bulkDelete=${notes.bulkDelete}
|
||||
onNoteClick=${(id) => { saveRecentNote({ id, title: '' }); notes.openNote(id); }}
|
||||
onNewNote=${notes.newNote}
|
||||
onDailyNote=${notes.openDailyNote}
|
||||
onGraphClick=${() => notes.setView('graph')}
|
||||
pinnedIds=${notes.pinnedIds} togglePin=${notes.togglePin}
|
||||
standalone=${standalone} />`;
|
||||
|
||||
case 'editor':
|
||||
return html`
|
||||
<${NoteEditor}
|
||||
currentNote=${notes.currentNote}
|
||||
editingId=${notes.editingId}
|
||||
folders=${notes.folders}
|
||||
onSave=${notes.saveNote}
|
||||
onCancel=${() => {
|
||||
if (notes.currentNote) {
|
||||
notes.setEditorMode('read');
|
||||
notes.setView('reader');
|
||||
} else {
|
||||
notes.setView('list');
|
||||
notes.refreshList();
|
||||
}
|
||||
}}
|
||||
onDelete=${notes.deleteNote} />`;
|
||||
|
||||
case 'reader':
|
||||
return html`
|
||||
<${NoteReader}
|
||||
note=${notes.currentNote}
|
||||
backlinks=${notes.backlinks}
|
||||
onEdit=${() => {
|
||||
notes.setEditorMode('edit');
|
||||
notes.setView('editor');
|
||||
}}
|
||||
onDelete=${notes.deleteNote}
|
||||
onCopy=${notes.copyNote}
|
||||
onNavigateLink=${notes.navigateToLink}
|
||||
onBack=${() => { notes.setView('list'); notes.refreshList(); }}
|
||||
isDailyNote=${notes.isDailyNote}
|
||||
onDailyPrev=${() => notes.navigateDaily(-1)}
|
||||
onDailyNext=${() => notes.navigateDaily(1)} />`;
|
||||
|
||||
case 'graph':
|
||||
if (!GraphComp) {
|
||||
return html`<div class="sw-notes-pane__loading">Loading graph\u2026</div>`;
|
||||
}
|
||||
return html`
|
||||
<${GraphComp}
|
||||
data=${notes.graphData}
|
||||
onNodeClick=${(id) => { notes.openNote(id); }}
|
||||
onGhostClick=${(title) => { notes.navigateToLink(title); }}
|
||||
onBack=${() => notes.setView('list')}
|
||||
handleRef=${graphHandleRef} />`;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class=${'sw-notes-pane' + (className ? ' ' + className : '')}>
|
||||
${renderView()}
|
||||
|
||||
<${QuickSwitcher}
|
||||
open=${notes.quickSwitcherOpen}
|
||||
onClose=${() => notes.setQuickSwitcherOpen(false)}
|
||||
onSelect=${(id) => notes.openNote(id)}
|
||||
onCreateNew=${onQuickSwitcherCreate} />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Kit re-exports ─────────────────────────
|
||||
export { useNotes } from './use-notes.js';
|
||||
export { NoteList } from './note-list.js';
|
||||
export { NoteListItem } from './note-list-item.js';
|
||||
export { NoteEditor } from './note-editor.js';
|
||||
export { NoteReader } from './note-reader.js';
|
||||
export { NoteGraph } from './note-graph.js';
|
||||
export { QuickSwitcher, saveRecentNote } from './quick-switcher.js';
|
||||
export { SaveToNoteDialog } from './save-to-note.js';
|
||||
export { renderNoteMarkdown, attachWikilinkHandlers, resolveTransclusions, extractHeadings } from './markdown.js';
|
||||
@@ -1,162 +0,0 @@
|
||||
// ==========================================
|
||||
// NotesPane Kit — Markdown with Wikilinks
|
||||
// ==========================================
|
||||
// Renders markdown content via marked + DOMPurify,
|
||||
// then post-processes wikilinks into clickable chips
|
||||
// and transclusion blocks.
|
||||
// Independently importable.
|
||||
//
|
||||
// NOTE (CR P3-17): Uses raw DOM (innerHTML, querySelector, addEventListener)
|
||||
// for post-render wikilink processing. This is intentional — Preact renders
|
||||
// the initial HTML, then this module post-processes specific elements for
|
||||
// interactive behavior. The lifecycle is managed by the parent component.
|
||||
|
||||
const WIKILINK_RE = /(!?)\[\[([^\[\]|]+?)(?:\|([^\]]+?))?\]\]/g;
|
||||
|
||||
/**
|
||||
* Render markdown with wikilink support.
|
||||
*
|
||||
* @param {string} content — raw markdown
|
||||
* @param {{ onLinkClick?: (title: string) => void }} opts
|
||||
* @returns {{ html: string, wikilinks: Array<{title: string, display: string, isTransclusion: boolean}> }}
|
||||
*/
|
||||
export function renderNoteMarkdown(content, opts = {}) {
|
||||
if (!content) return { html: '', wikilinks: [] };
|
||||
|
||||
// 1. Render markdown
|
||||
let rendered = '';
|
||||
if (window.marked) {
|
||||
try {
|
||||
rendered = window.marked.parse(content, { breaks: true, gfm: true });
|
||||
} catch {
|
||||
rendered = _escapeHtml(content).replace(/\n/g, '<br>');
|
||||
}
|
||||
} else {
|
||||
rendered = _escapeHtml(content).replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
// 2. Sanitize
|
||||
if (window.DOMPurify) {
|
||||
rendered = window.DOMPurify.sanitize(rendered, {
|
||||
ADD_ATTR: ['target', 'rel'],
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Extract and replace wikilinks
|
||||
const wikilinks = [];
|
||||
rendered = rendered.replace(WIKILINK_RE, (_match, bang, title, display) => {
|
||||
const titleTrimmed = title.trim();
|
||||
const displayText = display ? display.trim() : titleTrimmed;
|
||||
const isTransclusion = bang === '!';
|
||||
|
||||
wikilinks.push({ title: titleTrimmed, display: displayText, isTransclusion });
|
||||
|
||||
if (isTransclusion) {
|
||||
return `<div class="sw-notes-pane__transclusion" data-title="${_escapeAttr(titleTrimmed)}">` +
|
||||
`<div class="sw-notes-pane__transclusion-header">` +
|
||||
`<span class="sw-notes-pane__wikilink sw-notes-pane__wikilink--transclusion" ` +
|
||||
`data-link-title="${_escapeAttr(titleTrimmed)}">` +
|
||||
`\u2197 ${_escapeHtml(displayText)}</span></div>` +
|
||||
`<div class="sw-notes-pane__transclusion-content">Loading\u2026</div></div>`;
|
||||
}
|
||||
|
||||
return `<span class="sw-notes-pane__wikilink" ` +
|
||||
`data-link-title="${_escapeAttr(titleTrimmed)}">${_escapeHtml(displayText)}</span>`;
|
||||
});
|
||||
|
||||
return { html: rendered, wikilinks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach click handlers to wikilink elements inside a container.
|
||||
* @param {HTMLElement} container
|
||||
* @param {(title: string) => void} onLinkClick
|
||||
*/
|
||||
export function attachWikilinkHandlers(container, onLinkClick) {
|
||||
if (!container || !onLinkClick) return;
|
||||
container.querySelectorAll('[data-link-title]').forEach(el => {
|
||||
el.style.cursor = 'pointer';
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onLinkClick(el.dataset.linkTitle);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve transclusion blocks — load content for ![[Title]] embeds.
|
||||
* @param {HTMLElement} container
|
||||
* @param {(title: string) => void} onLinkClick
|
||||
*/
|
||||
export async function resolveTransclusions(container, onLinkClick) {
|
||||
const embeds = container?.querySelectorAll('.sw-notes-pane__transclusion');
|
||||
if (!embeds?.length) return;
|
||||
|
||||
const api = window.sw?.api?.notes;
|
||||
if (!api) return;
|
||||
|
||||
const cache = {};
|
||||
for (const el of embeds) {
|
||||
const title = el.dataset.title;
|
||||
const contentEl = el.querySelector('.sw-notes-pane__transclusion-content');
|
||||
if (!title || !contentEl) continue;
|
||||
|
||||
try {
|
||||
let note = cache[title.toLowerCase()];
|
||||
if (!note) {
|
||||
const resp = await api.searchTitles(title, 1);
|
||||
const match = (resp || []).find(
|
||||
n => n.title.toLowerCase() === title.toLowerCase()
|
||||
);
|
||||
if (!match) {
|
||||
contentEl.innerHTML = '<span style="color:var(--text-3)">Note not found</span>';
|
||||
continue;
|
||||
}
|
||||
note = await api.get(match.id);
|
||||
cache[title.toLowerCase()] = note;
|
||||
}
|
||||
// Render content (strip nested transclusions to prevent recursion)
|
||||
const safeContent = (note.content || '').replace(/!\[\[[^\]]+\]\]/g, (m) => {
|
||||
const inner = m.match(/!\[\[([^\]|]+)/)?.[1]?.trim() || '';
|
||||
return `[Embedded: ${inner}]`;
|
||||
});
|
||||
const { html } = renderNoteMarkdown(safeContent);
|
||||
contentEl.innerHTML = html;
|
||||
// Attach link handlers inside transclusion
|
||||
if (onLinkClick) attachWikilinkHandlers(contentEl, onLinkClick);
|
||||
} catch {
|
||||
contentEl.innerHTML = '<span style="color:var(--text-3)">Failed to load</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract headings from markdown content for outline/TOC.
|
||||
* @param {string} content — raw markdown
|
||||
* @returns {Array<{level: number, text: string, id: string}>}
|
||||
*/
|
||||
export function extractHeadings(content) {
|
||||
if (!content) return [];
|
||||
const headings = [];
|
||||
const lines = content.split('\n');
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^(#{1,3})\s+(.+)/);
|
||||
if (match) {
|
||||
const text = match[2].trim();
|
||||
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
|
||||
headings.push({ level: match[1].length, text, id });
|
||||
}
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
|
||||
function _escapeHtml(str) {
|
||||
const el = document.createElement('div');
|
||||
el.textContent = str;
|
||||
return el.innerHTML;
|
||||
}
|
||||
|
||||
function _escapeAttr(str) {
|
||||
return str.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
// ==========================================
|
||||
// NotesPane Kit — NoteEditor
|
||||
// ==========================================
|
||||
// Editor view: title, folder, tags, content with
|
||||
// CM6 integration (falls back to textarea).
|
||||
// Word/char count, keyboard shortcuts.
|
||||
// Independently importable.
|
||||
|
||||
const { useState, useRef, useCallback, useEffect } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* currentNote: object|null,
|
||||
* editingId: string|null,
|
||||
* folders: Array,
|
||||
* onSave: (fields: object) => Promise<boolean>,
|
||||
* onCancel: () => void,
|
||||
* onDelete: () => void,
|
||||
* }} props
|
||||
*/
|
||||
export function NoteEditor(props) {
|
||||
const { currentNote, editingId, folders, onSave, onCancel, onDelete } = props;
|
||||
|
||||
const [title, setTitle] = useState(currentNote?.title || '');
|
||||
const [folderPath, setFolderPath] = useState(currentNote?.folder_path || '');
|
||||
const [tags, setTags] = useState((currentNote?.tags || []).join(', '));
|
||||
const [content, setContent] = useState(currentNote?.content || '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const titleRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
const cmEditorRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
// ── Populate from note ──────────────────
|
||||
useEffect(() => {
|
||||
setTitle(currentNote?.title || '');
|
||||
setFolderPath(currentNote?.folder_path || '');
|
||||
setTags((currentNote?.tags || []).join(', '));
|
||||
setContent(currentNote?.content || '');
|
||||
// Destroy old CM editor if any
|
||||
if (cmEditorRef.current) {
|
||||
cmEditorRef.current.destroy();
|
||||
cmEditorRef.current = null;
|
||||
}
|
||||
}, [currentNote?.id]);
|
||||
|
||||
// ── Auto-focus title on new note ────────
|
||||
useEffect(() => {
|
||||
if (!editingId && titleRef.current) {
|
||||
titleRef.current.focus();
|
||||
}
|
||||
}, [editingId]);
|
||||
|
||||
// ── CM6 integration ─────────────────────
|
||||
useEffect(() => {
|
||||
const cmContainer = containerRef.current;
|
||||
if (!cmContainer) return;
|
||||
|
||||
if (window.CM?.noteEditor && !cmEditorRef.current) {
|
||||
cmContainer.innerHTML = '';
|
||||
cmEditorRef.current = window.CM.noteEditor(cmContainer, {
|
||||
value: content,
|
||||
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
|
||||
onChange: (val) => setContent(val),
|
||||
onLink: null, // handled by reader, not editor
|
||||
linkCompleter: async (query) => {
|
||||
if (!query || query.length < 1) return [];
|
||||
try {
|
||||
const resp = await window.sw.api.notes.searchTitles(query, 8);
|
||||
return (resp || []).map(n => ({ label: n.title, id: n.id }));
|
||||
} catch { return []; }
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (cmEditorRef.current) {
|
||||
cmEditorRef.current.destroy();
|
||||
cmEditorRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Get content (CM or textarea) ────────
|
||||
const getContent = useCallback(() => {
|
||||
if (cmEditorRef.current) return cmEditorRef.current.getValue();
|
||||
return content;
|
||||
}, [content]);
|
||||
|
||||
// ── Word/char count ─────────────────────
|
||||
const wordCount = content ? content.trim().split(/\s+/).filter(Boolean).length : 0;
|
||||
const charCount = content ? content.length : 0;
|
||||
|
||||
// ── Save handler ────────────────────────
|
||||
const handleSave = useCallback(async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
const tagsArr = tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
const ok = await onSave({
|
||||
title: title.trim(),
|
||||
content: getContent(),
|
||||
folder_path: folderPath.trim(),
|
||||
tags: tagsArr,
|
||||
});
|
||||
setSaving(false);
|
||||
}, [title, folderPath, tags, getContent, onSave, saving]);
|
||||
|
||||
// ── Keyboard shortcuts ──────────────────
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [handleSave, onCancel]);
|
||||
|
||||
return html`
|
||||
<div class="sw-notes-pane__editor-header">
|
||||
<button class="sw-notes-pane__toolbar-btn" onClick=${onCancel}>← Back</button>
|
||||
<span class="sw-notes-pane__editor-header-spacer" />
|
||||
${editingId && html`
|
||||
<button class="sw-notes-pane__toolbar-btn"
|
||||
style="color:var(--danger);border-color:var(--danger)"
|
||||
onClick=${onDelete}>Delete</button>`}
|
||||
<button class="sw-notes-pane__toolbar-btn"
|
||||
style="color:var(--success);border-color:var(--success)"
|
||||
onClick=${handleSave} disabled=${saving}>
|
||||
${saving ? 'Saving\u2026' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sw-notes-pane__editor">
|
||||
<input class="sw-notes-pane__editor-title" type="text"
|
||||
ref=${titleRef} placeholder="Note title"
|
||||
value=${title} onInput=${e => setTitle(e.target.value)} />
|
||||
|
||||
<div class="sw-notes-pane__editor-meta">
|
||||
<input type="text" placeholder="Folder (e.g. /work/project)"
|
||||
value=${folderPath} onInput=${e => setFolderPath(e.target.value)}
|
||||
list="sw-notes-folders" />
|
||||
<datalist id="sw-notes-folders">
|
||||
${folders.map(f => html`<option key=${f.path} value=${f.path} />`)}
|
||||
</datalist>
|
||||
<input type="text" placeholder="Tags (comma-separated)"
|
||||
value=${tags} onInput=${e => setTags(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div class="sw-notes-pane__editor-content" ref=${containerRef}>
|
||||
${!window.CM?.noteEditor && html`
|
||||
<textarea class="sw-notes-pane__editor-textarea"
|
||||
ref=${contentRef}
|
||||
placeholder="Write your note in markdown\u2026"
|
||||
value=${content}
|
||||
onInput=${e => setContent(e.target.value)} />`}
|
||||
</div>
|
||||
|
||||
<div class="sw-notes-pane__editor-footer">
|
||||
<span>${wordCount} words · ${charCount} chars</span>
|
||||
<span style="color:var(--text-3)">Ctrl+S to save · Esc to cancel</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,561 +0,0 @@
|
||||
// ==========================================
|
||||
// NotesPane Kit — NoteGraph (Canvas)
|
||||
// ==========================================
|
||||
// Force-directed graph visualization of note connections.
|
||||
// Self-contained Preact component with physics sim,
|
||||
// pan/zoom/drag, hover highlighting, folder colors.
|
||||
// Lazy-loadable via dynamic import().
|
||||
// Independently importable.
|
||||
|
||||
const { useRef, useEffect, useState, useCallback } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
// ── Simulation Parameters ───────────────────
|
||||
const SIM = {
|
||||
repulsion: 800,
|
||||
attraction: 0.006,
|
||||
edgeLength: 130,
|
||||
damping: 0.88,
|
||||
centerGravity: 0.012,
|
||||
maxVelocity: 8,
|
||||
ticksPerFrame: 3,
|
||||
coolThreshold: 0.5,
|
||||
};
|
||||
|
||||
const BASE_RADIUS = 6;
|
||||
|
||||
const FOLDER_COLORS = [
|
||||
'#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6',
|
||||
'#8b5cf6', '#ef4444', '#06b6d4', '#84cc16', '#f97316',
|
||||
];
|
||||
|
||||
function _clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
|
||||
|
||||
function _withAlpha(hex, alpha) {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* data: { nodes: Array, edges: Array, unresolved?: Array } | null,
|
||||
* onNodeClick: (id: string) => void,
|
||||
* onGhostClick: (title: string) => void,
|
||||
* onBack: () => void,
|
||||
* handleRef?: { current: any },
|
||||
* }} props
|
||||
*/
|
||||
export function NoteGraph(props) {
|
||||
const { data, onNodeClick, onGhostClick, onBack, handleRef } = props;
|
||||
|
||||
const canvasRef = useRef(null);
|
||||
const wrapRef = useRef(null);
|
||||
const stateRef = useRef(null);
|
||||
const [showOrphans, setShowOrphans] = useState(true);
|
||||
const [focusNodeId, setFocusNodeId] = useState(null);
|
||||
const [stats, setStats] = useState({ nodes: 0, edges: 0 });
|
||||
|
||||
// ── Prepare graph data with physics state ──
|
||||
useEffect(() => {
|
||||
if (!data || !canvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const rect = wrapRef.current.getBoundingClientRect();
|
||||
canvas.width = Math.round(rect.width);
|
||||
canvas.height = Math.round(rect.height);
|
||||
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
|
||||
// Add physics state to nodes
|
||||
const nodeMap = {};
|
||||
const folderColorMap = {};
|
||||
|
||||
function folderColor(path) {
|
||||
if (!path || path === '/') return '#6b7280';
|
||||
if (!folderColorMap[path]) {
|
||||
const idx = Object.keys(folderColorMap).length % FOLDER_COLORS.length;
|
||||
folderColorMap[path] = FOLDER_COLORS[idx];
|
||||
}
|
||||
return folderColorMap[path];
|
||||
}
|
||||
|
||||
const nodes = data.nodes.map(n => {
|
||||
const node = {
|
||||
...n,
|
||||
x: cx + (Math.random() - 0.5) * canvas.width * 0.5,
|
||||
y: cy + (Math.random() - 0.5) * canvas.height * 0.5,
|
||||
vx: 0, vy: 0, ax: 0, ay: 0,
|
||||
pinned: false, isGhost: false,
|
||||
};
|
||||
nodeMap[n.id] = node;
|
||||
return node;
|
||||
});
|
||||
|
||||
// Resolve edge references
|
||||
const edges = data.edges.map(e => ({
|
||||
...e,
|
||||
sourceNode: nodeMap[e.source],
|
||||
targetNode: nodeMap[e.target],
|
||||
}));
|
||||
|
||||
// Ghost nodes for unresolved links
|
||||
const ghostNodes = [];
|
||||
if (data.unresolved) {
|
||||
const ghostMap = {};
|
||||
for (const d of data.unresolved) {
|
||||
const key = d.title.toLowerCase();
|
||||
if (!ghostMap[key]) {
|
||||
const ghost = {
|
||||
id: '__ghost_' + key, title: d.title,
|
||||
folder_path: '', tags: [], link_count: 0,
|
||||
x: cx + (Math.random() - 0.5) * canvas.width * 0.4,
|
||||
y: cy + (Math.random() - 0.5) * canvas.height * 0.4,
|
||||
vx: 0, vy: 0, ax: 0, ay: 0,
|
||||
pinned: false, isGhost: true,
|
||||
};
|
||||
ghostMap[key] = ghost;
|
||||
ghostNodes.push(ghost);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setStats({ nodes: nodes.length, edges: edges.length });
|
||||
|
||||
const state = {
|
||||
nodes, edges, ghostNodes, nodeMap, folderColorMap, folderColor,
|
||||
panX: 0, panY: 0, zoom: 1,
|
||||
animId: null, canvas, ctx: canvas.getContext('2d'),
|
||||
hoveredNode: null, dragNode: null,
|
||||
isDragging: false, lastMouse: null,
|
||||
showOrphans: true,
|
||||
focusNodeId: null,
|
||||
onNodeClick, onGhostClick,
|
||||
onFocusNode: (id) => setFocusNodeId(id),
|
||||
};
|
||||
|
||||
stateRef.current = state;
|
||||
|
||||
// Start simulation
|
||||
_startSim(state, onNodeClick);
|
||||
|
||||
// Attach listeners
|
||||
const cleanup = _attachListeners(state, onNodeClick);
|
||||
|
||||
// Resize observer
|
||||
let roFrame = null;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (roFrame) return;
|
||||
roFrame = requestAnimationFrame(() => {
|
||||
roFrame = null;
|
||||
const r = wrapRef.current.getBoundingClientRect();
|
||||
const w = Math.round(r.width);
|
||||
const h = Math.round(r.height);
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
_render(state);
|
||||
}
|
||||
});
|
||||
});
|
||||
ro.observe(wrapRef.current);
|
||||
|
||||
return () => {
|
||||
if (state.animId) cancelAnimationFrame(state.animId);
|
||||
state.animId = null;
|
||||
cleanup();
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [data, onNodeClick]);
|
||||
|
||||
// ── Sync showOrphans + focusNodeId into state ──
|
||||
useEffect(() => {
|
||||
if (stateRef.current) {
|
||||
stateRef.current.showOrphans = showOrphans;
|
||||
stateRef.current.focusNodeId = focusNodeId;
|
||||
_render(stateRef.current);
|
||||
}
|
||||
}, [showOrphans, focusNodeId]);
|
||||
|
||||
// ── Expose imperative handle ────────────
|
||||
useEffect(() => {
|
||||
if (handleRef) {
|
||||
handleRef.current = {
|
||||
resetZoom() {
|
||||
const s = stateRef.current;
|
||||
if (!s) return;
|
||||
s.panX = 0; s.panY = 0; s.zoom = 1;
|
||||
_render(s);
|
||||
},
|
||||
toggleOrphans() {
|
||||
setShowOrphans(p => !p);
|
||||
},
|
||||
focusNode(id) {
|
||||
setFocusNodeId(id);
|
||||
},
|
||||
clearFocus() {
|
||||
setFocusNodeId(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ── Focus mode label ───────────────────
|
||||
const focusLabel = focusNodeId && stateRef.current
|
||||
? (stateRef.current.nodes.find(n => n.id === focusNodeId)?.title || 'Unknown')
|
||||
: null;
|
||||
|
||||
return html`
|
||||
<div class="sw-notes-pane__graph">
|
||||
<div class="sw-notes-pane__graph-toolbar">
|
||||
<button class="sw-notes-pane__toolbar-btn" onClick=${onBack}>← Back</button>
|
||||
<span class="sw-notes-pane__graph-stats">
|
||||
${stats.nodes} notes · ${stats.edges} links
|
||||
</span>
|
||||
<span class="sw-notes-pane__toolbar-spacer" />
|
||||
${focusNodeId && html`
|
||||
<span style="font-size:11px;color:var(--accent)">Focus: ${focusLabel}</span>
|
||||
<button class="sw-notes-pane__toolbar-btn" onClick=${() => setFocusNodeId(null)}>
|
||||
Show All
|
||||
</button>`}
|
||||
<button class="sw-notes-pane__toolbar-btn"
|
||||
onClick=${() => setShowOrphans(p => !p)}>
|
||||
Orphans ${showOrphans ? 'On' : 'Off'}
|
||||
</button>
|
||||
<button class="sw-notes-pane__toolbar-btn"
|
||||
onClick=${() => handleRef?.current?.resetZoom()}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<div class="sw-notes-pane__graph-canvas-wrap" ref=${wrapRef}>
|
||||
<canvas ref=${canvasRef} />
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Simulation ──────────────────────────────
|
||||
|
||||
function _allNodes(state) {
|
||||
const nodes = [...state.nodes];
|
||||
if (state.showOrphans !== false && state.ghostNodes) {
|
||||
nodes.push(...state.ghostNodes);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function _startSim(state, onNodeClick) {
|
||||
function frame() {
|
||||
for (let t = 0; t < SIM.ticksPerFrame; t++) _tick(state);
|
||||
_render(state);
|
||||
|
||||
let totalKE = 0;
|
||||
for (const n of _allNodes(state)) {
|
||||
totalKE += n.vx * n.vx + n.vy * n.vy;
|
||||
}
|
||||
if (totalKE > SIM.coolThreshold || state.dragNode) {
|
||||
state.animId = requestAnimationFrame(frame);
|
||||
} else {
|
||||
state.animId = null;
|
||||
_render(state);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.animId) cancelAnimationFrame(state.animId);
|
||||
state.animId = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function _tick(state) {
|
||||
const nodes = _allNodes(state);
|
||||
const edges = state.edges;
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
for (const n of nodes) { n.ax = 0; n.ay = 0; }
|
||||
|
||||
// Repulsion
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const dx = nodes[j].x - nodes[i].x;
|
||||
const dy = nodes[j].y - nodes[i].y;
|
||||
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
||||
const f = SIM.repulsion / (dist * dist);
|
||||
const fx = (dx / dist) * f;
|
||||
const fy = (dy / dist) * f;
|
||||
nodes[i].ax -= fx; nodes[i].ay -= fy;
|
||||
nodes[j].ax += fx; nodes[j].ay += fy;
|
||||
}
|
||||
}
|
||||
|
||||
// Attraction (edges)
|
||||
for (const e of edges) {
|
||||
const s = e.sourceNode, t = e.targetNode;
|
||||
if (!s || !t) continue;
|
||||
const dx = t.x - s.x, dy = t.y - s.y;
|
||||
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
||||
const f = SIM.attraction * (dist - SIM.edgeLength);
|
||||
const fx = (dx / dist) * f;
|
||||
const fy = (dy / dist) * f;
|
||||
s.ax += fx; s.ay += fy;
|
||||
t.ax -= fx; t.ay -= fy;
|
||||
}
|
||||
|
||||
// Center gravity + integration
|
||||
const cx = state.canvas.width / 2, cy = state.canvas.height / 2;
|
||||
for (const n of nodes) {
|
||||
if (n.pinned) continue;
|
||||
n.ax += (cx - n.x) * SIM.centerGravity;
|
||||
n.ay += (cy - n.y) * SIM.centerGravity;
|
||||
n.vx = _clamp((n.vx + n.ax) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
|
||||
n.vy = _clamp((n.vy + n.ay) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity);
|
||||
n.x += n.vx;
|
||||
n.y += n.vy;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering ───────────────────────────────
|
||||
|
||||
function _render(state) {
|
||||
const { ctx, canvas, panX, panY, zoom, hoveredNode, focusNodeId } = state;
|
||||
if (!ctx) return;
|
||||
|
||||
const nodes = _allNodes(state);
|
||||
const edges = state.edges;
|
||||
|
||||
// Build focus neighbor set for focus mode
|
||||
const focusNeighborIds = new Set();
|
||||
if (focusNodeId) {
|
||||
focusNeighborIds.add(focusNodeId);
|
||||
for (const e of edges) {
|
||||
if (e.source === focusNodeId) focusNeighborIds.add(e.target);
|
||||
if (e.target === focusNodeId) focusNeighborIds.add(e.source);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.translate(panX, panY);
|
||||
ctx.scale(zoom, zoom);
|
||||
|
||||
// Edges
|
||||
for (const e of edges) {
|
||||
const s = e.sourceNode, t = e.targetNode;
|
||||
if (!s || !t) continue;
|
||||
|
||||
// Focus mode: hide edges not touching the focus neighborhood
|
||||
if (focusNodeId && !focusNeighborIds.has(e.source) && !focusNeighborIds.has(e.target)) continue;
|
||||
const isFocusDimmed = focusNodeId && !(focusNeighborIds.has(e.source) && focusNeighborIds.has(e.target));
|
||||
|
||||
const isHighlighted = hoveredNode && (s.id === hoveredNode.id || t.id === hoveredNode.id);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.x, s.y);
|
||||
ctx.lineTo(t.x, t.y);
|
||||
|
||||
if (e.is_transclusion) {
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.strokeStyle = isHighlighted ? 'rgba(99,102,241,0.8)'
|
||||
: isFocusDimmed ? 'rgba(99,102,241,0.08)' : 'rgba(99,102,241,0.3)';
|
||||
} else {
|
||||
ctx.setLineDash([]);
|
||||
ctx.strokeStyle = isHighlighted ? 'rgba(160,160,160,0.8)'
|
||||
: isFocusDimmed ? 'rgba(120,120,120,0.06)' : 'rgba(120,120,120,0.25)';
|
||||
}
|
||||
ctx.lineWidth = isHighlighted ? 2 : 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Nodes
|
||||
for (const n of nodes) {
|
||||
if (n.isGhost && !state.showOrphans) continue;
|
||||
|
||||
const r = n.isGhost ? BASE_RADIUS * 0.6 : BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2;
|
||||
const isHovered = hoveredNode && n.id === hoveredNode.id;
|
||||
const isNeighbor = hoveredNode && _isNeighbor(state, hoveredNode, n);
|
||||
const isFocusVisible = !focusNodeId || focusNeighborIds.has(n.id);
|
||||
const isDimmed = (hoveredNode && !isHovered && !isNeighbor) || !isFocusVisible;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||||
|
||||
if (n.isGhost) {
|
||||
ctx.fillStyle = isDimmed ? 'rgba(200,50,50,0.1)' : 'rgba(200,50,50,0.4)';
|
||||
ctx.setLineDash([2, 2]);
|
||||
ctx.strokeStyle = 'rgba(200,50,50,0.5)';
|
||||
} else if (isHovered) {
|
||||
ctx.fillStyle = '#6366f1';
|
||||
} else {
|
||||
const color = state.folderColor(n.folder_path);
|
||||
ctx.fillStyle = isDimmed ? _withAlpha(color, 0.15) : _withAlpha(color, 0.7);
|
||||
ctx.strokeStyle = isDimmed ? 'rgba(255,255,255,0.05)' : 'rgba(255,255,255,0.15)';
|
||||
}
|
||||
|
||||
ctx.fill();
|
||||
if (!n.isGhost) {
|
||||
ctx.setLineDash([]);
|
||||
ctx.lineWidth = isHovered ? 2 : 1;
|
||||
ctx.stroke();
|
||||
} else {
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// Labels
|
||||
if (zoom > 0.5 && (isHovered || isNeighbor || !hoveredNode)) {
|
||||
const fontSize = Math.max(10, 11 / zoom);
|
||||
ctx.font = `${n.isGhost ? 'italic ' : ''}${fontSize}px var(--font, system-ui, sans-serif)`;
|
||||
ctx.fillStyle = isDimmed ? 'rgba(200,200,200,0.2)' : 'rgba(220,220,220,0.9)';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
const label = n.title.length > 30 ? n.title.slice(0, 28) + '\u2026' : n.title;
|
||||
ctx.fillText(label, n.x, n.y + r + 4);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function _isNeighbor(state, hovered, candidate) {
|
||||
for (const e of state.edges) {
|
||||
if ((e.source === hovered.id && e.target === candidate.id) ||
|
||||
(e.target === hovered.id && e.source === candidate.id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Interaction ─────────────────────────────
|
||||
|
||||
function _attachListeners(state, onNodeClick) {
|
||||
const canvas = state.canvas;
|
||||
|
||||
function toGraph(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (e.clientX - rect.left - state.panX) / state.zoom,
|
||||
y: (e.clientY - rect.top - state.panY) / state.zoom,
|
||||
};
|
||||
}
|
||||
|
||||
function hitTest(gx, gy) {
|
||||
const nodes = _allNodes(state);
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
const n = nodes[i];
|
||||
const r = n.isGhost ? BASE_RADIUS * 0.6 : BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2;
|
||||
const dx = n.x - gx, dy = n.y - gy;
|
||||
if (dx * dx + dy * dy <= (r + 4) * (r + 4)) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const DRAG_THRESHOLD = 5; // px — must move at least this far to count as drag
|
||||
|
||||
const onMouseDown = (e) => {
|
||||
const g = toGraph(e);
|
||||
const node = hitTest(g.x, g.y);
|
||||
if (node) {
|
||||
state.dragNode = node;
|
||||
if (!node.isGhost) node.pinned = true;
|
||||
state.isDragging = false;
|
||||
}
|
||||
state.lastMouse = { x: e.clientX, y: e.clientY };
|
||||
state.mouseDownPos = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
const g = toGraph(e);
|
||||
if (state.dragNode) {
|
||||
// Only count as drag if moved past threshold
|
||||
if (!state.isDragging && state.mouseDownPos) {
|
||||
const dx = e.clientX - state.mouseDownPos.x;
|
||||
const dy = e.clientY - state.mouseDownPos.y;
|
||||
if (dx * dx + dy * dy < DRAG_THRESHOLD * DRAG_THRESHOLD) return;
|
||||
}
|
||||
state.isDragging = true;
|
||||
state.dragNode.x = g.x;
|
||||
state.dragNode.y = g.y;
|
||||
state.dragNode.vx = 0;
|
||||
state.dragNode.vy = 0;
|
||||
if (!state.animId) _startSim(state, onNodeClick);
|
||||
} else if (state.lastMouse && e.buttons === 1) {
|
||||
state.panX += e.clientX - state.lastMouse.x;
|
||||
state.panY += e.clientY - state.lastMouse.y;
|
||||
state.lastMouse = { x: e.clientX, y: e.clientY };
|
||||
_render(state);
|
||||
} else {
|
||||
const node = hitTest(g.x, g.y);
|
||||
if (node !== state.hoveredNode) {
|
||||
state.hoveredNode = node;
|
||||
canvas.style.cursor = node ? 'pointer' : 'default';
|
||||
_render(state);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = (e) => {
|
||||
if (state.dragNode) {
|
||||
const clicked = state.dragNode;
|
||||
clicked.pinned = false;
|
||||
if (!state.isDragging) {
|
||||
if (clicked.isGhost && state.onGhostClick) {
|
||||
// Ghost node → create note with that title
|
||||
state.onGhostClick(clicked.title);
|
||||
} else if (!clicked.isGhost) {
|
||||
// Single click → focus on this node (show neighbors)
|
||||
if (state.onFocusNode) {
|
||||
state.onFocusNode(clicked.id === state.focusNodeId ? null : clicked.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.dragNode = null;
|
||||
} else if (!state.isDragging) {
|
||||
// Clicked empty space → clear focus
|
||||
if (state.onFocusNode && state.focusNodeId) {
|
||||
state.onFocusNode(null);
|
||||
}
|
||||
}
|
||||
state.lastMouse = null;
|
||||
state.isDragging = false;
|
||||
};
|
||||
|
||||
// Double-click → open note
|
||||
const onDblClick = (e) => {
|
||||
const g = toGraph(e);
|
||||
const node = hitTest(g.x, g.y);
|
||||
if (node && !node.isGhost && onNodeClick) {
|
||||
onNodeClick(node.id);
|
||||
} else if (node && node.isGhost && state.onGhostClick) {
|
||||
state.onGhostClick(node.title);
|
||||
}
|
||||
};
|
||||
|
||||
const onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY > 0 ? 0.92 : 1.08;
|
||||
const newZoom = _clamp(state.zoom * factor, 0.15, 4.0);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
state.panX = mx - (mx - state.panX) * (newZoom / state.zoom);
|
||||
state.panY = my - (my - state.panY) * (newZoom / state.zoom);
|
||||
state.zoom = newZoom;
|
||||
_render(state);
|
||||
};
|
||||
|
||||
canvas.addEventListener('mousedown', onMouseDown);
|
||||
canvas.addEventListener('mousemove', onMouseMove);
|
||||
canvas.addEventListener('mouseup', onMouseUp);
|
||||
canvas.addEventListener('dblclick', onDblClick);
|
||||
canvas.addEventListener('wheel', onWheel, { passive: false });
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener('mousedown', onMouseDown);
|
||||
canvas.removeEventListener('mousemove', onMouseMove);
|
||||
canvas.removeEventListener('mouseup', onMouseUp);
|
||||
canvas.removeEventListener('dblclick', onDblClick);
|
||||
canvas.removeEventListener('wheel', onWheel);
|
||||
};
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
// ==========================================
|
||||
// NotesPane Kit — NoteListItem
|
||||
// ==========================================
|
||||
// Single note row in the list view.
|
||||
// Independently importable.
|
||||
|
||||
const html = window.html;
|
||||
|
||||
function _relativeTime(iso) {
|
||||
if (!iso) return '';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return 'just now';
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return m + 'm ago';
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return h + 'h ago';
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 30) return d + 'd ago';
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
const el = document.createElement('span');
|
||||
el.textContent = s;
|
||||
return el.innerHTML;
|
||||
}
|
||||
|
||||
function _highlightHeadline(headline) {
|
||||
const escaped = _esc(headline);
|
||||
return escaped.replace(/\*\*(.+?)\*\*/g, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* note: object,
|
||||
* isSearch?: boolean,
|
||||
* onClick: (id: string) => void,
|
||||
* selectMode?: boolean,
|
||||
* selected?: boolean,
|
||||
* onToggleSelect?: (id: string) => void,
|
||||
* pinned?: boolean,
|
||||
* onTogglePin?: (id: string) => void,
|
||||
* }} props
|
||||
*/
|
||||
export function NoteListItem(props) {
|
||||
const { note, isSearch, onClick, selectMode, selected, onToggleSelect, pinned, onTogglePin } = props;
|
||||
|
||||
const preview = isSearch && note.headline
|
||||
? html`<div class="sw-notes-pane__item-preview"
|
||||
dangerouslySetInnerHTML=${{ __html: _highlightHeadline(note.headline) }} />`
|
||||
: html`<div class="sw-notes-pane__item-preview">
|
||||
${(note.preview || note.content || '').slice(0, 120)}
|
||||
</div>`;
|
||||
|
||||
const tags = (note.tags || []).map(t =>
|
||||
html`<span class="sw-notes-pane__item-tag" key=${t}>${t}</span>`
|
||||
);
|
||||
|
||||
const folderBadge = note.folder_path && note.folder_path !== '/'
|
||||
? html`<span class="sw-notes-pane__item-folder">${note.folder_path}</span>`
|
||||
: null;
|
||||
|
||||
const onItemClick = (e) => {
|
||||
if (selectMode && onToggleSelect) {
|
||||
onToggleSelect(note.id);
|
||||
} else {
|
||||
onClick(note.id);
|
||||
}
|
||||
};
|
||||
|
||||
const onCheckChange = (e) => {
|
||||
e.stopPropagation();
|
||||
if (onToggleSelect) onToggleSelect(note.id);
|
||||
};
|
||||
|
||||
const onPinClick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (onTogglePin) onTogglePin(note.id);
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class=${'sw-notes-pane__item' + (selected ? ' sw-notes-pane__item--selected' : '')}
|
||||
onClick=${onItemClick}>
|
||||
${selectMode && html`
|
||||
<input type="checkbox" class="sw-notes-pane__item-checkbox"
|
||||
checked=${selected} onChange=${onCheckChange} />`}
|
||||
<div class="sw-notes-pane__item-body">
|
||||
<div class="sw-notes-pane__item-header">
|
||||
<span class="sw-notes-pane__item-title">${note.title}</span>
|
||||
<span class="sw-notes-pane__item-time">${_relativeTime(note.updated_at)}</span>
|
||||
</div>
|
||||
${preview}
|
||||
${(folderBadge || tags.length > 0) && html`
|
||||
<div class="sw-notes-pane__item-meta">${folderBadge}${tags}</div>`}
|
||||
</div>
|
||||
${onTogglePin && html`
|
||||
<button class=${'sw-notes-pane__item-pin' + (pinned ? ' sw-notes-pane__item-pin--active' : '')}
|
||||
onClick=${onPinClick} title=${pinned ? 'Unpin' : 'Pin'}>
|
||||
${pinned ? '\u{1F4CC}' : '\u{1F4CC}'}
|
||||
</button>`}
|
||||
</div>`;
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
// ==========================================
|
||||
// NotesPane Kit — NoteList
|
||||
// ==========================================
|
||||
// List view: toolbar, search, filters, tag cloud,
|
||||
// scrollable note list with pinned section,
|
||||
// selection bar, and pagination.
|
||||
// Independently importable.
|
||||
|
||||
import { NoteListItem } from './note-list-item.js';
|
||||
|
||||
const { useState, useRef, useCallback, useEffect } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* notes: Array, loading: boolean, hasMore: boolean, loadMore: () => void,
|
||||
* folders: Array, folder: string, setFolder: (f: string) => void,
|
||||
* sort: string, setSort: (s: string) => void,
|
||||
* searchQuery: string, setSearchQuery: (q: string) => void,
|
||||
* tagFilter: string, setTagFilter: (t: string) => void,
|
||||
* selectMode: boolean, selectedIds: Set, enterSelectMode: () => void,
|
||||
* exitSelectMode: () => void, toggleSelect: (id: string) => void,
|
||||
* toggleSelectAll: (checked: boolean) => void, bulkDelete: () => void,
|
||||
* onNoteClick: (id: string) => void, onNewNote: () => void,
|
||||
* onDailyNote: () => void, onGraphClick: () => void,
|
||||
* pinnedIds: Set, togglePin: (id: string) => void,
|
||||
* }} props
|
||||
*/
|
||||
export function NoteList(props) {
|
||||
const {
|
||||
notes, loading, hasMore, loadMore,
|
||||
folders, folder, setFolder,
|
||||
sort, setSort,
|
||||
searchQuery, setSearchQuery,
|
||||
tagFilter, setTagFilter,
|
||||
selectMode, selectedIds, enterSelectMode, exitSelectMode,
|
||||
toggleSelect, toggleSelectAll, bulkDelete,
|
||||
onNoteClick, onNewNote, onDailyNote, onGraphClick,
|
||||
pinnedIds, togglePin,
|
||||
standalone = true,
|
||||
} = props;
|
||||
|
||||
// When standalone=false, the surface sidebar owns search/filter/tag controls
|
||||
const showFilters = standalone !== false;
|
||||
|
||||
const searchTimerRef = useRef(null);
|
||||
const searchInputRef = useRef(null);
|
||||
|
||||
const onSearchInput = useCallback((e) => {
|
||||
const val = e.target.value;
|
||||
clearTimeout(searchTimerRef.current);
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
if (val.trim().length >= 2 || val.trim().length === 0) {
|
||||
setSearchQuery(val.trim());
|
||||
}
|
||||
}, 300);
|
||||
}, [setSearchQuery]);
|
||||
|
||||
// ── Tag cloud: collect tags from visible notes ──
|
||||
const tagCounts = {};
|
||||
for (const n of notes) {
|
||||
for (const t of (n.tags || [])) {
|
||||
tagCounts[t] = (tagCounts[t] || 0) + 1;
|
||||
}
|
||||
}
|
||||
const topTags = Object.entries(tagCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 15);
|
||||
|
||||
// ── Split pinned vs unpinned ──
|
||||
const isSearch = searchQuery && searchQuery.length >= 2;
|
||||
const pinnedNotes = isSearch ? [] : notes.filter(n => pinnedIds.has(n.id));
|
||||
const unpinnedNotes = isSearch ? notes : notes.filter(n => !pinnedIds.has(n.id));
|
||||
|
||||
return html`
|
||||
<div class="sw-notes-pane__toolbar">
|
||||
<button class="sw-notes-pane__toolbar-btn" onClick=${onNewNote} title="New note">+ New</button>
|
||||
<button class="sw-notes-pane__toolbar-btn" onClick=${onDailyNote} title="Daily note">Today</button>
|
||||
${onGraphClick && html`
|
||||
<button class="sw-notes-pane__toolbar-btn" onClick=${onGraphClick} title="Graph view">Graph</button>`}
|
||||
<span class="sw-notes-pane__toolbar-spacer" />
|
||||
<button class="sw-notes-pane__toolbar-btn" onClick=${() => selectMode ? exitSelectMode() : enterSelectMode()}
|
||||
title=${selectMode ? 'Cancel selection' : 'Select mode'}>
|
||||
${selectMode ? 'Cancel' : 'Select'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${showFilters && html`
|
||||
<div class="sw-notes-pane__search-row">
|
||||
<input class="sw-notes-pane__search-input" type="text"
|
||||
placeholder="Search notes\u2026" autocomplete="off"
|
||||
ref=${searchInputRef} onInput=${onSearchInput}
|
||||
value=${searchQuery} />
|
||||
</div>`}
|
||||
|
||||
${showFilters && html`
|
||||
<div class="sw-notes-pane__filter-row">
|
||||
<select class="sw-notes-pane__filter-select"
|
||||
value=${folder} onChange=${e => { setFolder(e.target.value); }}>
|
||||
<option value="">All folders</option>
|
||||
${folders.map(f => html`
|
||||
<option key=${f.path} value=${f.path}>${f.path} (${f.count})</option>`)}
|
||||
</select>
|
||||
<select class="sw-notes-pane__filter-select"
|
||||
value=${sort} onChange=${e => setSort(e.target.value)}>
|
||||
<option value="updated_desc">Last updated</option>
|
||||
<option value="created_desc">Created</option>
|
||||
<option value="alpha">Alphabetical</option>
|
||||
</select>
|
||||
</div>`}
|
||||
|
||||
${showFilters && topTags.length > 0 && html`
|
||||
<div class="sw-notes-pane__tag-cloud">
|
||||
${topTags.map(([tag, count]) => html`
|
||||
<span key=${tag}
|
||||
class=${'sw-notes-pane__tag-chip' + (tagFilter === tag ? ' sw-notes-pane__tag-chip--active' : '')}
|
||||
onClick=${() => setTagFilter(tagFilter === tag ? '' : tag)}>
|
||||
${tag}
|
||||
</span>`)}
|
||||
</div>`}
|
||||
|
||||
<div class="sw-notes-pane__list">
|
||||
${loading && notes.length === 0 && html`
|
||||
<div class="sw-notes-pane__loading">Loading\u2026</div>`}
|
||||
|
||||
${!loading && notes.length === 0 && html`
|
||||
<div class="sw-notes-pane__list-empty">
|
||||
${isSearch ? 'No results found' : (folder ? 'No notes in this folder' : 'No notes yet. Create one!')}
|
||||
</div>`}
|
||||
|
||||
${pinnedNotes.length > 0 && html`
|
||||
<div class="sw-notes-pane__pinned-divider">Pinned</div>
|
||||
${pinnedNotes.map(n => html`
|
||||
<${NoteListItem} key=${n.id} note=${n} isSearch=${isSearch}
|
||||
onClick=${onNoteClick} selectMode=${selectMode}
|
||||
selected=${selectedIds.has(n.id)} onToggleSelect=${toggleSelect}
|
||||
pinned=${true} onTogglePin=${togglePin} />`)}
|
||||
${unpinnedNotes.length > 0 && html`
|
||||
<div class="sw-notes-pane__pinned-divider">Notes</div>`}`}
|
||||
|
||||
${unpinnedNotes.map(n => html`
|
||||
<${NoteListItem} key=${n.id} note=${n} isSearch=${isSearch}
|
||||
onClick=${onNoteClick} selectMode=${selectMode}
|
||||
selected=${selectedIds.has(n.id)} onToggleSelect=${toggleSelect}
|
||||
pinned=${false} onTogglePin=${togglePin} />`)}
|
||||
|
||||
${hasMore && html`
|
||||
<button class="sw-notes-pane__load-more" onClick=${loadMore}>
|
||||
Load more\u2026
|
||||
</button>`}
|
||||
</div>
|
||||
|
||||
${selectMode && html`
|
||||
<div class="sw-notes-pane__selection-bar">
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
checked=${selectedIds.size === notes.length && notes.length > 0}
|
||||
onChange=${e => toggleSelectAll(e.target.checked)} />
|
||||
All
|
||||
</label>
|
||||
<span>${selectedIds.size} selected</span>
|
||||
<button class="sw-notes-pane__toolbar-btn" onClick=${bulkDelete}
|
||||
style="color:var(--danger);border-color:var(--danger)">
|
||||
Delete
|
||||
</button>
|
||||
<button class="sw-notes-pane__toolbar-btn" onClick=${exitSelectMode}>Cancel</button>
|
||||
</div>`}
|
||||
`;
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
// ==========================================
|
||||
// NotesPane Kit — NoteReader
|
||||
// ==========================================
|
||||
// Read-mode view: rendered markdown with wikilink chips,
|
||||
// transclusion embeds, outline/TOC, backlinks panel,
|
||||
// unlinked mentions, daily note navigation.
|
||||
// Independently importable.
|
||||
|
||||
import { renderNoteMarkdown, attachWikilinkHandlers, resolveTransclusions, extractHeadings } from './markdown.js';
|
||||
|
||||
const { useState, useRef, useCallback, useEffect } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* note: object,
|
||||
* backlinks: Array,
|
||||
* onEdit: () => void,
|
||||
* onDelete: () => void,
|
||||
* onCopy: () => void,
|
||||
* onNavigateLink: (title: string) => void,
|
||||
* onBack: () => void,
|
||||
* isDailyNote: boolean,
|
||||
* onDailyPrev: () => void,
|
||||
* onDailyNext: () => void,
|
||||
* }} props
|
||||
*/
|
||||
export function NoteReader(props) {
|
||||
const {
|
||||
note, backlinks, onEdit, onDelete, onCopy, onNavigateLink,
|
||||
onBack, isDailyNote, onDailyPrev, onDailyNext,
|
||||
} = props;
|
||||
|
||||
const contentRef = useRef(null);
|
||||
const [backlinksOpen, setBacklinksOpen] = useState(true);
|
||||
const [unlinkedMentions, setUnlinkedMentions] = useState([]);
|
||||
|
||||
// ── Render content + wikilinks ──────────
|
||||
useEffect(() => {
|
||||
if (!contentRef.current || !note) return;
|
||||
const { html: rendered } = renderNoteMarkdown(note.content || '');
|
||||
contentRef.current.innerHTML = rendered;
|
||||
|
||||
// Attach wikilink click handlers
|
||||
attachWikilinkHandlers(contentRef.current, onNavigateLink);
|
||||
|
||||
// Resolve transclusions
|
||||
resolveTransclusions(contentRef.current, onNavigateLink);
|
||||
|
||||
// Add IDs to headings for outline scroll-to
|
||||
contentRef.current.querySelectorAll('h1, h2, h3').forEach(el => {
|
||||
const id = el.textContent.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '');
|
||||
el.id = 'heading-' + id;
|
||||
});
|
||||
}, [note?.id, note?.content, onNavigateLink]);
|
||||
|
||||
// ── Extract headings for outline ────────
|
||||
const headings = note ? extractHeadings(note.content || '') : [];
|
||||
|
||||
// ── Find unlinked mentions ──────────────
|
||||
useEffect(() => {
|
||||
if (!note?.content) { setUnlinkedMentions([]); return; }
|
||||
// Debounce: fetch note titles and check for unlinked mentions
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const api = window.sw?.api?.notes;
|
||||
if (!api) return;
|
||||
// Get all note titles (limited)
|
||||
const resp = await api.list({ limit: 200, offset: 0 });
|
||||
const allNotes = resp.data || [];
|
||||
const content = note.content.toLowerCase();
|
||||
|
||||
// Find titles mentioned in content but not wikilinked
|
||||
const linked = new Set();
|
||||
const linkRe = /\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]/g;
|
||||
let m;
|
||||
while ((m = linkRe.exec(note.content)) !== null) {
|
||||
linked.add(m[1].trim().toLowerCase());
|
||||
}
|
||||
|
||||
const mentions = allNotes
|
||||
.filter(n => n.id !== note.id)
|
||||
.filter(n => {
|
||||
const t = n.title.toLowerCase();
|
||||
return t.length > 2 && content.includes(t) && !linked.has(t);
|
||||
})
|
||||
.map(n => n.title)
|
||||
.slice(0, 10);
|
||||
|
||||
setUnlinkedMentions(mentions);
|
||||
} catch { setUnlinkedMentions([]); }
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [note?.id, note?.content]);
|
||||
|
||||
if (!note) return null;
|
||||
|
||||
const folderBadge = note.folder_path && note.folder_path !== '/'
|
||||
? html`<span class="sw-notes-pane__item-folder">${note.folder_path}</span>`
|
||||
: null;
|
||||
|
||||
const tagChips = (note.tags || []).map(t =>
|
||||
html`<span class="sw-notes-pane__item-tag" key=${t}>${t}</span>`
|
||||
);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const dailyDate = isDailyNote ? note.title.replace('Daily \u2014 ', '') : null;
|
||||
|
||||
const scrollToHeading = (id) => {
|
||||
const el = contentRef.current?.querySelector('#heading-' + id);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="sw-notes-pane__editor-header">
|
||||
<button class="sw-notes-pane__toolbar-btn" onClick=${onBack}>← Back</button>
|
||||
<span class="sw-notes-pane__editor-header-spacer" />
|
||||
<button class="sw-notes-pane__toolbar-btn" onClick=${onCopy} title="Copy to clipboard">Copy</button>
|
||||
<button class="sw-notes-pane__toolbar-btn" onClick=${onEdit}>Edit</button>
|
||||
<button class="sw-notes-pane__toolbar-btn"
|
||||
style="color:var(--danger);border-color:var(--danger)"
|
||||
onClick=${onDelete}>Delete</button>
|
||||
</div>
|
||||
|
||||
<div class=${'sw-notes-pane__reader' + (headings.length > 2 ? ' sw-notes-pane__reader--with-outline' : '')}>
|
||||
${headings.length > 2 && html`
|
||||
<div class="sw-notes-pane__outline-sidebar">
|
||||
<div class="sw-notes-pane__outline-title">Outline</div>
|
||||
${headings.map(h => html`
|
||||
<a key=${h.id}
|
||||
class=${'sw-notes-pane__outline-item sw-notes-pane__outline-item--h' + h.level}
|
||||
onClick=${() => scrollToHeading(h.id)}>
|
||||
${h.text}
|
||||
</a>`)}
|
||||
</div>`}
|
||||
|
||||
<div class="sw-notes-pane__reader-scroll">
|
||||
<h1 class="sw-notes-pane__reader-title">${note.title}</h1>
|
||||
|
||||
${(folderBadge || tagChips.length > 0) && html`
|
||||
<div class="sw-notes-pane__reader-meta">${folderBadge}${tagChips}</div>`}
|
||||
|
||||
${isDailyNote && html`
|
||||
<div class="sw-notes-pane__daily-nav">
|
||||
<button class="sw-notes-pane__toolbar-btn" onClick=${onDailyPrev}>‹ Prev</button>
|
||||
<span>${dailyDate}</span>
|
||||
<button class="sw-notes-pane__toolbar-btn" onClick=${onDailyNext}
|
||||
disabled=${dailyDate >= today}>Next ›</button>
|
||||
</div>`}
|
||||
|
||||
<div class="sw-notes-pane__reader-content" ref=${contentRef} />
|
||||
|
||||
${backlinks.length > 0 && html`
|
||||
<div class="sw-notes-pane__backlinks">
|
||||
<div class="sw-notes-pane__backlinks-header"
|
||||
onClick=${() => setBacklinksOpen(p => !p)}>
|
||||
${backlinksOpen ? '▾' : '▸'} Backlinks
|
||||
<span class="sw-notes-pane__backlinks-badge">${backlinks.length}</span>
|
||||
</div>
|
||||
${backlinksOpen && html`
|
||||
<div class="sw-notes-pane__backlinks-list">
|
||||
${backlinks.map(bl => html`
|
||||
<div key=${bl.id} class="sw-notes-pane__backlink-item"
|
||||
onClick=${() => onNavigateLink(bl.title)}>
|
||||
<span>${bl.title}</span>
|
||||
${bl.folder_path && bl.folder_path !== '/' && html`
|
||||
<span class="sw-notes-pane__backlink-folder">${bl.folder_path}</span>`}
|
||||
</div>`)}
|
||||
</div>`}
|
||||
</div>`}
|
||||
|
||||
${unlinkedMentions.length > 0 && html`
|
||||
<div class="sw-notes-pane__unlinked">
|
||||
<div class="sw-notes-pane__unlinked-title">Unlinked Mentions</div>
|
||||
${unlinkedMentions.map(title => html`
|
||||
<span key=${title} class="sw-notes-pane__unlinked-item"
|
||||
onClick=${() => onNavigateLink(title)} title="Navigate to ${title}">
|
||||
${title}
|
||||
</span>`)}
|
||||
</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
// ==========================================
|
||||
// NotesPane Kit — QuickSwitcher
|
||||
// ==========================================
|
||||
// Cmd+K / Ctrl+K style quick-open overlay.
|
||||
// Fuzzy title search, keyboard nav, recent notes,
|
||||
// create-on-enter. Obsidian's signature UX.
|
||||
// Independently importable.
|
||||
|
||||
const { useState, useRef, useCallback, useEffect } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
const RECENTS_KEY = 'sw_notes_recent';
|
||||
const MAX_RECENTS = 5;
|
||||
|
||||
function _loadRecents() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(RECENTS_KEY) || '[]').slice(0, MAX_RECENTS);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function _saveRecent(note) {
|
||||
try {
|
||||
const recents = _loadRecents().filter(r => r.id !== note.id);
|
||||
recents.unshift({ id: note.id, title: note.title, folder_path: note.folder_path });
|
||||
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, MAX_RECENTS)));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* open: boolean,
|
||||
* onClose: () => void,
|
||||
* onSelect: (id: string) => void,
|
||||
* onCreateNew: (title: string) => void,
|
||||
* }} props
|
||||
*/
|
||||
export function QuickSwitcher(props) {
|
||||
const { open, onClose, onSelect, onCreateNew } = props;
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [recents, setRecents] = useState([]);
|
||||
const inputRef = useRef(null);
|
||||
const searchTimerRef = useRef(null);
|
||||
|
||||
// ── Load recents on open ────────────────
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setActiveIndex(0);
|
||||
setRecents(_loadRecents());
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// ── Search ──────────────────────────────
|
||||
useEffect(() => {
|
||||
clearTimeout(searchTimerRef.current);
|
||||
if (!query || query.length < 1) {
|
||||
setResults([]);
|
||||
setActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
searchTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const resp = await window.sw.api.notes.searchTitles(query, 10);
|
||||
setResults(resp || []);
|
||||
setActiveIndex(0);
|
||||
} catch { setResults([]); }
|
||||
}, 150);
|
||||
}, [query]);
|
||||
|
||||
// ── Visible items ───────────────────────
|
||||
const items = query ? results : recents;
|
||||
const hasExactMatch = results.some(r => r.title.toLowerCase() === query.toLowerCase());
|
||||
const showCreate = query && !hasExactMatch;
|
||||
|
||||
// ── Keyboard navigation ─────────────────
|
||||
const onKeyDown = useCallback((e) => {
|
||||
const totalItems = items.length + (showCreate ? 1 : 0);
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex(i => (i + 1) % Math.max(totalItems, 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex(i => (i - 1 + totalItems) % Math.max(totalItems, 1));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex < items.length) {
|
||||
_select(items[activeIndex]);
|
||||
} else if (showCreate) {
|
||||
onCreateNew(query);
|
||||
onClose();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}, [items, activeIndex, showCreate, query, onClose, onCreateNew]);
|
||||
|
||||
const _select = (note) => {
|
||||
_saveRecent(note);
|
||||
onSelect(note.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return html`
|
||||
<div class="sw-notes-pane__switcher-overlay"
|
||||
onClick=${(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div class="sw-notes-pane__switcher">
|
||||
<input class="sw-notes-pane__switcher-input"
|
||||
ref=${inputRef}
|
||||
placeholder="Search notes\u2026"
|
||||
value=${query}
|
||||
onInput=${e => setQuery(e.target.value)}
|
||||
onKeyDown=${onKeyDown} />
|
||||
|
||||
<div class="sw-notes-pane__switcher-results">
|
||||
${!query && recents.length > 0 && html`
|
||||
<div class="sw-notes-pane__switcher-section">Recent</div>`}
|
||||
|
||||
${query && results.length === 0 && html`
|
||||
<div style="padding:12px 14px;color:var(--text-3);font-size:13px">
|
||||
No notes found
|
||||
</div>`}
|
||||
|
||||
${items.map((note, i) => html`
|
||||
<div key=${note.id}
|
||||
class=${'sw-notes-pane__switcher-item' + (i === activeIndex ? ' sw-notes-pane__switcher-item--active' : '')}
|
||||
onClick=${() => _select(note)}
|
||||
onMouseEnter=${() => setActiveIndex(i)}>
|
||||
<span class="sw-notes-pane__switcher-item-title">${note.title}</span>
|
||||
${note.folder_path && note.folder_path !== '/' && html`
|
||||
<span class="sw-notes-pane__switcher-item-folder">${note.folder_path}</span>`}
|
||||
</div>`)}
|
||||
|
||||
${showCreate && html`
|
||||
<div class=${'sw-notes-pane__switcher-create' + (activeIndex === items.length ? ' sw-notes-pane__switcher-item--active' : '')}
|
||||
onClick=${() => { onCreateNew(query); onClose(); }}
|
||||
onMouseEnter=${() => setActiveIndex(items.length)}>
|
||||
+ Create "${query}"
|
||||
</div>`}
|
||||
</div>
|
||||
|
||||
<div class="sw-notes-pane__switcher-hint">
|
||||
↑↓ navigate · Enter select · Esc close
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Re-export for external use (e.g., saving recents from openNote)
|
||||
export { _saveRecent as saveRecentNote };
|
||||
@@ -1,188 +0,0 @@
|
||||
// ==========================================
|
||||
// NotesPane Kit — SaveToNoteDialog
|
||||
// ==========================================
|
||||
// "Save to note" dialog: create-new or append-to-existing.
|
||||
// Independently importable by any surface (ChatPane, etc.).
|
||||
|
||||
import { renderNoteMarkdown } from './markdown.js';
|
||||
|
||||
const { useState, useRef, useCallback, useEffect } = window.hooks;
|
||||
const html = window.html;
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* open: boolean,
|
||||
* onClose: () => void,
|
||||
* content: string,
|
||||
* sourceChannelId?: string,
|
||||
* sourceMessageId?: string,
|
||||
* defaultTitle?: string,
|
||||
* }} props
|
||||
*/
|
||||
export function SaveToNoteDialog(props) {
|
||||
const { open, onClose, content, sourceChannelId, sourceMessageId, defaultTitle } = props;
|
||||
|
||||
const [mode, setMode] = useState('create'); // 'create' | 'append'
|
||||
const [title, setTitle] = useState(defaultTitle || '');
|
||||
const [folderPath, setFolderPath] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [selectedNote, setSelectedNote] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const searchTimerRef = useRef(null);
|
||||
const titleInputRef = useRef(null);
|
||||
|
||||
// ── Reset on open ───────────────────────
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMode('create');
|
||||
setTitle(defaultTitle || '');
|
||||
setFolderPath('');
|
||||
setTags('');
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
setSelectedNote(null);
|
||||
setTimeout(() => titleInputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open, defaultTitle]);
|
||||
|
||||
// ── Search for append target ────────────
|
||||
useEffect(() => {
|
||||
clearTimeout(searchTimerRef.current);
|
||||
if (mode !== 'append' || searchQuery.length < 2) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
searchTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const resp = await window.sw.api.notes.searchTitles(searchQuery, 8);
|
||||
setSearchResults(resp || []);
|
||||
} catch { setSearchResults([]); }
|
||||
}, 250);
|
||||
}, [searchQuery, mode]);
|
||||
|
||||
// ── Save ────────────────────────────────
|
||||
const handleSave = useCallback(async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
const api = window.sw.api.notes;
|
||||
const toast = window.sw?.toast;
|
||||
|
||||
try {
|
||||
if (mode === 'append') {
|
||||
if (!selectedNote) {
|
||||
toast?.('Select a note to append to', 'warning');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
await api.update(selectedNote.id, {
|
||||
content: '\n\n---\n\n' + content,
|
||||
mode: 'append',
|
||||
});
|
||||
toast?.(`Appended to "${selectedNote.title}"`, 'success');
|
||||
} else {
|
||||
if (!title.trim()) {
|
||||
toast?.('Title is required', 'warning');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const tagsArr = tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
await api.create({
|
||||
title: title.trim(),
|
||||
content,
|
||||
folder_path: folderPath.trim(),
|
||||
tags: tagsArr,
|
||||
source_channel_id: sourceChannelId,
|
||||
source_message_id: sourceMessageId,
|
||||
});
|
||||
toast?.('Note created', 'success');
|
||||
}
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast?.(e.message, 'error');
|
||||
}
|
||||
setSaving(false);
|
||||
}, [mode, title, folderPath, tags, content, selectedNote, sourceChannelId, sourceMessageId, onClose, saving]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Preview
|
||||
const previewContent = content ? content.slice(0, 300) + (content.length > 300 ? '\u2026' : '') : '';
|
||||
const { html: previewHtml } = renderNoteMarkdown(previewContent);
|
||||
|
||||
return html`
|
||||
<div class="sw-notes-pane__switcher-overlay" onClick=${(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div class="sw-notes-pane__switcher" style="width:min(480px,90vw)">
|
||||
<div style="padding:14px">
|
||||
<h3 style="margin:0 0 10px;font-size:15px;color:var(--text)">Save to Note</h3>
|
||||
|
||||
<div class="sw-notes-pane__save-mode">
|
||||
<label>
|
||||
<input type="radio" name="saveMode" value="create"
|
||||
checked=${mode === 'create'}
|
||||
onChange=${() => setMode('create')} />
|
||||
Create new
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="saveMode" value="append"
|
||||
checked=${mode === 'append'}
|
||||
onChange=${() => setMode('append')} />
|
||||
Append to existing
|
||||
</label>
|
||||
</div>
|
||||
|
||||
${mode === 'create' && html`
|
||||
<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:10px">
|
||||
<input class="sw-notes-pane__search-input" type="text"
|
||||
ref=${titleInputRef} placeholder="Title"
|
||||
value=${title} onInput=${e => setTitle(e.target.value)} />
|
||||
<div style="display:flex;gap:6px">
|
||||
<input class="sw-notes-pane__search-input" type="text"
|
||||
placeholder="Folder (e.g. /work)" style="flex:1"
|
||||
value=${folderPath} onInput=${e => setFolderPath(e.target.value)} />
|
||||
<input class="sw-notes-pane__search-input" type="text"
|
||||
placeholder="Tags (comma-separated)" style="flex:1"
|
||||
value=${tags} onInput=${e => setTags(e.target.value)} />
|
||||
</div>
|
||||
</div>`}
|
||||
|
||||
${mode === 'append' && html`
|
||||
<div style="margin-bottom:10px">
|
||||
<input class="sw-notes-pane__search-input" type="text"
|
||||
placeholder="Search notes to append to\u2026"
|
||||
value=${searchQuery}
|
||||
onInput=${e => setSearchQuery(e.target.value)} />
|
||||
${searchResults.length > 0 && html`
|
||||
<div class="sw-notes-pane__save-search-results">
|
||||
${searchResults.map(n => html`
|
||||
<div key=${n.id}
|
||||
class=${'sw-notes-pane__save-search-item' + (selectedNote?.id === n.id ? ' sw-notes-pane__save-search-item--selected' : '')}
|
||||
onClick=${() => { setSelectedNote(n); setSearchQuery(n.title); }}>
|
||||
${n.title}
|
||||
</div>`)}
|
||||
</div>`}
|
||||
</div>`}
|
||||
|
||||
<div style="margin-bottom:10px">
|
||||
<div style="font-size:11px;color:var(--text-3);margin-bottom:4px">
|
||||
Preview (${content?.length || 0} chars)
|
||||
</div>
|
||||
<div class="sw-notes-pane__save-preview"
|
||||
dangerouslySetInnerHTML=${{ __html: previewHtml }} />
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:flex-end;gap:6px">
|
||||
<button class="sw-notes-pane__toolbar-btn" onClick=${onClose}>Cancel</button>
|
||||
<button class="sw-notes-pane__toolbar-btn"
|
||||
style="color:var(--accent);border-color:var(--accent)"
|
||||
onClick=${handleSave} disabled=${saving}>
|
||||
${saving ? 'Saving\u2026' : (mode === 'append' ? 'Append' : 'Create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
// ==========================================
|
||||
// NotesPane Kit — useNotes Hook
|
||||
// ==========================================
|
||||
// Core state machine: views, CRUD, pagination, search,
|
||||
// folders, sort, selection, backlinks, daily notes,
|
||||
// wikilink navigation, pinning, quick switcher.
|
||||
// Independently importable.
|
||||
//
|
||||
// Usage:
|
||||
// const notes = useNotes({ onNoteChange });
|
||||
// notes.openNote(id);
|
||||
|
||||
const { useState, useRef, useCallback, useEffect } = window.hooks;
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const PINS_KEY = 'sw_notes_pinned';
|
||||
|
||||
function _loadPins() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PINS_KEY);
|
||||
return raw ? new Set(JSON.parse(raw)) : new Set();
|
||||
} catch { return new Set(); }
|
||||
}
|
||||
|
||||
function _savePins(set) {
|
||||
try { localStorage.setItem(PINS_KEY, JSON.stringify([...set])); } catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* initialFolder?: string,
|
||||
* initialNoteId?: string,
|
||||
* onNoteChange?: (note: object|null) => void,
|
||||
* standalone?: boolean,
|
||||
* enableGraph?: boolean,
|
||||
* enableQuickSwitcher?: boolean,
|
||||
* }} opts
|
||||
*/
|
||||
export function useNotes(opts = {}) {
|
||||
const {
|
||||
initialFolder,
|
||||
initialNoteId,
|
||||
onNoteChange,
|
||||
enableGraph = true,
|
||||
enableQuickSwitcher = true,
|
||||
} = opts;
|
||||
|
||||
// ── View state ──────────────────────────
|
||||
const [view, _setView] = useState(initialNoteId ? 'reader' : 'list');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// ── List state ──────────────────────────
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [folder, setFolder] = useState(initialFolder || '');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sort, setSort] = useState('updated_desc');
|
||||
const [tagFilter, setTagFilter] = useState('');
|
||||
const pageRef = useRef(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [folders, setFolders] = useState([]);
|
||||
|
||||
// ── Selection state ─────────────────────
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState(new Set());
|
||||
|
||||
// ── Editor/Reader state ─────────────────
|
||||
const [currentNote, setCurrentNote] = useState(null);
|
||||
const [editingId, setEditingId] = useState(initialNoteId || null);
|
||||
const [editorMode, setEditorMode] = useState('read'); // 'read' | 'edit' | 'preview'
|
||||
const [backlinks, setBacklinks] = useState([]);
|
||||
|
||||
// ── Graph state ─────────────────────────
|
||||
const [graphData, setGraphData] = useState(null);
|
||||
const graphDirtyRef = useRef(true);
|
||||
|
||||
// ── Quick Switcher ──────────────────────
|
||||
const [quickSwitcherOpen, setQuickSwitcherOpen] = useState(false);
|
||||
|
||||
// ── Pinning ─────────────────────────────
|
||||
const [pinnedIds, setPinnedIds] = useState(_loadPins);
|
||||
|
||||
// ── API shortcut ────────────────────────
|
||||
const api = () => window.sw.api.notes;
|
||||
const toast = (msg, v) => window.sw?.toast?.(msg, v);
|
||||
const confirm = (msg) => window.sw?.confirm?.(msg) ?? Promise.resolve(window.confirm(msg));
|
||||
|
||||
// ── View switching ──────────────────────
|
||||
const setView = useCallback((v) => {
|
||||
_setView(v);
|
||||
if (v === 'list') {
|
||||
setCurrentNote(null);
|
||||
setEditingId(null);
|
||||
setBacklinks([]);
|
||||
if (onNoteChange) onNoteChange(null);
|
||||
}
|
||||
}, [onNoteChange]);
|
||||
|
||||
// ── Load notes list ─────────────────────
|
||||
const _loadList = useCallback(async (append = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
let result, isSearch = false;
|
||||
if (searchQuery && searchQuery.length >= 2) {
|
||||
isSearch = true;
|
||||
const resp = await api().search(searchQuery);
|
||||
result = resp.data || [];
|
||||
setHasMore(false);
|
||||
} else {
|
||||
const page = append ? pageRef.current : 1;
|
||||
if (!append) pageRef.current = 1;
|
||||
const offset = (page - 1) * PAGE_SIZE;
|
||||
const resp = await api().list({ limit: PAGE_SIZE, offset, folder, tag: tagFilter, sort });
|
||||
result = resp.data || [];
|
||||
setHasMore(result.length >= PAGE_SIZE);
|
||||
}
|
||||
if (append) {
|
||||
setNotes(prev => [...prev, ...result]);
|
||||
} else {
|
||||
setNotes(result);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [searchQuery, folder, tagFilter, sort]);
|
||||
|
||||
const refreshList = useCallback(() => {
|
||||
pageRef.current = 1;
|
||||
_loadList(false);
|
||||
}, [_loadList]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
pageRef.current += 1;
|
||||
_loadList(true);
|
||||
}, [_loadList]);
|
||||
|
||||
// ── Load folders ────────────────────────
|
||||
const _loadFolders = useCallback(async () => {
|
||||
try {
|
||||
const data = await api().folders();
|
||||
setFolders(data.folders || data || []);
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
// ── Initial load ────────────────────────
|
||||
const didMountRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!didMountRef.current) {
|
||||
didMountRef.current = true;
|
||||
_loadList();
|
||||
_loadFolders();
|
||||
if (initialNoteId) {
|
||||
_openNoteById(initialNoteId);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Reload on filter changes ────────────
|
||||
const prevFiltersRef = useRef({ folder, sort, tagFilter, searchQuery });
|
||||
useEffect(() => {
|
||||
const prev = prevFiltersRef.current;
|
||||
if (prev.folder !== folder || prev.sort !== sort ||
|
||||
prev.tagFilter !== tagFilter || prev.searchQuery !== searchQuery) {
|
||||
prevFiltersRef.current = { folder, sort, tagFilter, searchQuery };
|
||||
if (didMountRef.current) {
|
||||
pageRef.current = 1;
|
||||
_loadList(false);
|
||||
}
|
||||
}
|
||||
}, [folder, sort, tagFilter, searchQuery, _loadList]);
|
||||
|
||||
// ── Selection ───────────────────────────
|
||||
const enterSelectMode = useCallback(() => {
|
||||
setSelectMode(true);
|
||||
setSelectedIds(new Set());
|
||||
}, []);
|
||||
|
||||
const exitSelectMode = useCallback(() => {
|
||||
setSelectMode(false);
|
||||
setSelectedIds(new Set());
|
||||
}, []);
|
||||
|
||||
const toggleSelect = useCallback((id) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback((checked) => {
|
||||
if (checked) {
|
||||
setSelectedIds(new Set(notes.map(n => n.id)));
|
||||
} else {
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
}, [notes]);
|
||||
|
||||
const bulkDelete = useCallback(async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
const count = selectedIds.size;
|
||||
if (!await confirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
|
||||
try {
|
||||
const resp = await api().bulkDelete([...selectedIds]);
|
||||
toast(`Deleted ${resp.deleted || count} note${count !== 1 ? 's' : ''}`, 'success');
|
||||
exitSelectMode();
|
||||
graphDirtyRef.current = true;
|
||||
refreshList();
|
||||
_loadFolders();
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
}, [selectedIds, exitSelectMode, refreshList, _loadFolders]);
|
||||
|
||||
// ── Open note ───────────────────────────
|
||||
const _openNoteById = useCallback(async (id) => {
|
||||
try {
|
||||
const note = await api().get(id);
|
||||
setCurrentNote(note);
|
||||
setEditingId(id);
|
||||
setEditorMode('read');
|
||||
_setView('reader');
|
||||
if (onNoteChange) onNoteChange(note);
|
||||
// Load backlinks
|
||||
try {
|
||||
const bl = await api().backlinks(id);
|
||||
setBacklinks(bl || []);
|
||||
} catch { setBacklinks([]); }
|
||||
} catch (e) {
|
||||
toast('Failed to load note: ' + e.message, 'error');
|
||||
}
|
||||
}, [onNoteChange]);
|
||||
|
||||
const openNote = useCallback((id) => _openNoteById(id), [_openNoteById]);
|
||||
|
||||
const newNote = useCallback(() => {
|
||||
setCurrentNote(null);
|
||||
setEditingId(null);
|
||||
setEditorMode('edit');
|
||||
setBacklinks([]);
|
||||
_setView('editor');
|
||||
if (onNoteChange) onNoteChange(null);
|
||||
}, [onNoteChange]);
|
||||
|
||||
// ── Save note ───────────────────────────
|
||||
const saveNote = useCallback(async (fields) => {
|
||||
const { title, content, folder_path, tags } = fields;
|
||||
if (!title) { toast('Title is required', 'warning'); return false; }
|
||||
if (!content) { toast('Content is required', 'warning'); return false; }
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
const updated = await api().update(editingId, {
|
||||
title, content, folder_path, tags, mode: 'replace',
|
||||
});
|
||||
setCurrentNote(updated);
|
||||
toast('Note updated', 'success');
|
||||
setEditorMode('read');
|
||||
_setView('reader');
|
||||
// Notify sidebar to refresh folders/tags (folder or tags may have changed)
|
||||
sw.emit('note.updated', { id: editingId }, { localOnly: true });
|
||||
// Reload backlinks
|
||||
try {
|
||||
const bl = await api().backlinks(editingId);
|
||||
setBacklinks(bl || []);
|
||||
} catch {}
|
||||
} else {
|
||||
const created = await api().create({ title, content, folder_path, tags });
|
||||
setCurrentNote(created);
|
||||
setEditingId(created.id);
|
||||
toast('Note created', 'success');
|
||||
setEditorMode('read');
|
||||
_setView('reader');
|
||||
// Notify sidebar to refresh folders/tags
|
||||
sw.emit('note.created', { id: created.id }, { localOnly: true });
|
||||
}
|
||||
graphDirtyRef.current = true;
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
return false;
|
||||
}
|
||||
}, [editingId]);
|
||||
|
||||
// ── Delete note ─────────────────────────
|
||||
const deleteNote = useCallback(async () => {
|
||||
if (!editingId) return;
|
||||
if (!await confirm('Delete this note?')) return;
|
||||
try {
|
||||
await api().del(editingId);
|
||||
toast('Note deleted', 'success');
|
||||
graphDirtyRef.current = true;
|
||||
setView('list');
|
||||
refreshList();
|
||||
_loadFolders();
|
||||
sw.emit('note.deleted', { id: editingId }, { localOnly: true });
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
}, [editingId, setView, refreshList, _loadFolders]);
|
||||
|
||||
// ── Copy note ───────────────────────────
|
||||
const copyNote = useCallback(() => {
|
||||
if (!currentNote) return;
|
||||
const text = `# ${currentNote.title || ''}\n\n${currentNote.content || ''}`;
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => toast('Copied to clipboard', 'success'))
|
||||
.catch(() => toast('Copy failed', 'error'));
|
||||
}, [currentNote]);
|
||||
|
||||
// ── Wikilink navigation ─────────────────
|
||||
const navigateToLink = useCallback(async (title) => {
|
||||
try {
|
||||
const resp = await api().searchTitles(title, 1);
|
||||
const match = (resp || []).find(
|
||||
n => n.title.toLowerCase() === title.toLowerCase()
|
||||
);
|
||||
if (match) {
|
||||
await _openNoteById(match.id);
|
||||
} else {
|
||||
if (await confirm(`Note "${title}" not found. Create it?`)) {
|
||||
const created = await api().create({
|
||||
title,
|
||||
content: `# ${title}\n\n`,
|
||||
folder_path: '/',
|
||||
tags: [],
|
||||
});
|
||||
await _openNoteById(created.id);
|
||||
graphDirtyRef.current = true;
|
||||
}
|
||||
}
|
||||
} catch (e) { toast('Failed to navigate: ' + e.message, 'error'); }
|
||||
}, [_openNoteById]);
|
||||
|
||||
// ── Daily Notes ─────────────────────────
|
||||
const _isDailyNote = useCallback((note) => {
|
||||
return note && /^Daily \u2014 \d{4}-\d{2}-\d{2}$/.test(note.title);
|
||||
}, []);
|
||||
|
||||
const isDailyNote = _isDailyNote(currentNote);
|
||||
|
||||
const openDailyNote = useCallback(async () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const title = `Daily \u2014 ${today}`;
|
||||
try {
|
||||
const resp = await api().searchTitles(title, 1);
|
||||
const existing = (resp || []).find(
|
||||
n => n.title.toLowerCase() === title.toLowerCase()
|
||||
);
|
||||
if (existing) {
|
||||
await _openNoteById(existing.id);
|
||||
} else {
|
||||
const content = `# ${today}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
|
||||
const created = await api().create({
|
||||
title, content, folder_path: '/daily/', tags: ['daily'],
|
||||
});
|
||||
await _openNoteById(created.id);
|
||||
graphDirtyRef.current = true;
|
||||
}
|
||||
} catch (e) { toast('Failed to open daily note: ' + e.message, 'error'); }
|
||||
}, [_openNoteById]);
|
||||
|
||||
const navigateDaily = useCallback(async (offset) => {
|
||||
if (!currentNote || !_isDailyNote(currentNote)) return;
|
||||
const dateStr = currentNote.title.replace('Daily \u2014 ', '');
|
||||
const d = new Date(dateStr + 'T12:00:00');
|
||||
d.setDate(d.getDate() + offset);
|
||||
const newDate = d.toISOString().slice(0, 10);
|
||||
const title = `Daily \u2014 ${newDate}`;
|
||||
try {
|
||||
const resp = await api().searchTitles(title, 1);
|
||||
const existing = (resp || []).find(
|
||||
n => n.title.toLowerCase() === title.toLowerCase()
|
||||
);
|
||||
if (existing) {
|
||||
await _openNoteById(existing.id);
|
||||
} else {
|
||||
const content = `# ${newDate}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
|
||||
const created = await api().create({
|
||||
title, content, folder_path: '/daily/', tags: ['daily'],
|
||||
});
|
||||
await _openNoteById(created.id);
|
||||
graphDirtyRef.current = true;
|
||||
}
|
||||
} catch (e) { toast('Failed to navigate: ' + e.message, 'error'); }
|
||||
}, [currentNote, _isDailyNote, _openNoteById]);
|
||||
|
||||
// ── Graph ───────────────────────────────
|
||||
const loadGraph = useCallback(async () => {
|
||||
try {
|
||||
const data = await api().graph();
|
||||
setGraphData(data);
|
||||
graphDirtyRef.current = false;
|
||||
return data;
|
||||
} catch (e) {
|
||||
toast('Failed to load graph: ' + e.message, 'error');
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const invalidateGraph = useCallback(() => {
|
||||
graphDirtyRef.current = true;
|
||||
}, []);
|
||||
|
||||
// ── Pinning ─────────────────────────────
|
||||
const togglePin = useCallback((id) => {
|
||||
setPinnedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
_savePins(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Keyboard shortcuts ──────────────────
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
// Quick switcher: Ctrl/Cmd + K
|
||||
if (enableQuickSwitcher && (e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setQuickSwitcherOpen(prev => !prev);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + N: new note
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
|
||||
// DOM presence check gates shortcut to notes surface (CR P3-19).
|
||||
// If notes and chat are ever composed on the same page, revisit
|
||||
// with a focus-based check or sw.shell.activeSurface flag.
|
||||
if (document.querySelector('.sw-notes-pane')) {
|
||||
e.preventDefault();
|
||||
newNote();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + D: daily note
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
|
||||
if (document.querySelector('.sw-notes-pane')) {
|
||||
e.preventDefault();
|
||||
openDailyNote();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [enableQuickSwitcher, newNote, openDailyNote]);
|
||||
|
||||
return {
|
||||
// View
|
||||
view, setView,
|
||||
// List
|
||||
notes, loading, error,
|
||||
folder, setFolder,
|
||||
searchQuery, setSearchQuery,
|
||||
sort, setSort,
|
||||
hasMore, loadMore,
|
||||
folders, refreshList,
|
||||
tagFilter, setTagFilter,
|
||||
// Selection
|
||||
selectMode, selectedIds,
|
||||
enterSelectMode, exitSelectMode,
|
||||
toggleSelect, toggleSelectAll,
|
||||
bulkDelete,
|
||||
// Editor/Reader
|
||||
currentNote, editingId,
|
||||
editorMode, setEditorMode,
|
||||
openNote, newNote,
|
||||
saveNote, deleteNote, copyNote,
|
||||
backlinks, navigateToLink,
|
||||
// Daily
|
||||
openDailyNote, isDailyNote, navigateDaily,
|
||||
// Graph
|
||||
graphData, loadGraph, invalidateGraph,
|
||||
// Quick Switcher
|
||||
quickSwitcherOpen, setQuickSwitcherOpen,
|
||||
// Pinning
|
||||
pinnedIds, togglePin,
|
||||
// Misc
|
||||
clearError: useCallback(() => setError(null), []),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — SDK: Auth Module
|
||||
// Switchboard Core — SDK: Auth Module
|
||||
// ==========================================
|
||||
// Owns token storage, login/logout/refresh lifecycle,
|
||||
// and the permissions/teams/policies cache.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — SDK: RBAC Gate
|
||||
// Switchboard Core — SDK: RBAC Gate
|
||||
// ==========================================
|
||||
// Synchronous permission checking for render paths.
|
||||
//
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — SDK: Event Bus
|
||||
// Switchboard Core — SDK: Event Bus
|
||||
// ==========================================
|
||||
// Labeled event bus with WebSocket bridge.
|
||||
// Port of events.js as ES module with factory pattern.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — SDK: Pipe/Filter Pipeline
|
||||
// Switchboard Core — SDK: Pipe/Filter Pipeline
|
||||
// ==========================================
|
||||
// Three-stage pipeline: pre-send, stream, render.
|
||||
// Verbatim port of switchboard-sdk.js pipe code.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — SDK: REST Client
|
||||
// Switchboard Core — SDK: REST Client
|
||||
// ==========================================
|
||||
// Internal HTTP engine for the new SDK.
|
||||
// All domain clients and auth module use this.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — SDK: Theme Control
|
||||
// Switchboard Core — SDK: Theme Control
|
||||
// ==========================================
|
||||
// Theme management: localStorage + data-theme + system pref.
|
||||
// Port of Theme from ui-primitives.js as ES module.
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* Admin > Routing > Capabilities
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
const CAP_FIELDS = ['supports_vision', 'supports_tools', 'supports_thinking', 'context_window', 'max_output_tokens'];
|
||||
|
||||
export default function CapabilitiesSection() {
|
||||
const [overrides, setOverrides] = useState([]);
|
||||
const [models, setModels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(null); // model_id string or null
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [o, m] = await Promise.all([
|
||||
sw.api.admin.models.overrides(),
|
||||
sw.api.admin.models.list().catch(() => []),
|
||||
]);
|
||||
setOverrides(o.data || []);
|
||||
setModels(m || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function saveOverride(modelId, field, value) {
|
||||
try {
|
||||
await sw.api.admin.models.setCapability(modelId, { [field]: value });
|
||||
sw.toast('Override saved', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteOverride(modelId, overrideId) {
|
||||
try {
|
||||
await sw.api.admin.models.delCapability(modelId, overrideId);
|
||||
sw.toast('Override removed', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">
|
||||
Override auto-detected model capabilities. ${overrides.length} active overrides.
|
||||
</p>
|
||||
|
||||
<div class="admin-list">
|
||||
${overrides.length === 0 && html`<div class="empty-hint">No capability overrides</div>`}
|
||||
${overrides.map(o => html`
|
||||
<div class="admin-surface-row" key=${o.id} style="flex-direction:column;align-items:stretch;">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<strong style="flex:1;">${o.model_id || o.display_name || o.id}</strong>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteOverride(o.model_id, o.id)}>Remove</button>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:12px;margin-top:4px;">
|
||||
${Object.entries(o).filter(([k]) => CAP_FIELDS.includes(k) && o[k] != null).map(([k, v]) =>
|
||||
html`<span key=${k} class="badge" style="margin-right:4px;">${k}: ${String(v)}</span>`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top:24px;">
|
||||
<h4>Add Override</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Model</label>
|
||||
<select value=${editing || ''} onChange=${e => setEditing(e.target.value || null)}>
|
||||
<option value="">Select model\u2026</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>
|
||||
${editing && html`
|
||||
<div style="margin-top:8px;">
|
||||
${CAP_FIELDS.map(field => {
|
||||
const isBool = field.startsWith('supports_');
|
||||
return html`
|
||||
<div key=${field} class="form-row" style="margin-bottom:4px;align-items:center;">
|
||||
<span style="width:180px;font-size:12px;">${field}</span>
|
||||
${isBool
|
||||
? html`
|
||||
<button class="btn-small btn-primary" onClick=${() => saveOverride(editing, field, true)}>true</button>
|
||||
<button class="btn-small" onClick=${() => saveOverride(editing, field, false)}>false</button>
|
||||
`
|
||||
: html`
|
||||
<input type="number" style="width:120px;" placeholder="value"
|
||||
onKeyDown=${e => { if (e.key === 'Enter') saveOverride(editing, field, parseInt(e.target.value)); }} />
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* Admin > System > Channels (Archived + Retention)
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function ChannelsSection() {
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [retentionTTL, setRetentionTTL] = useState(0);
|
||||
const [ttlDraft, setTTLDraft] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.channels.archived();
|
||||
setChannels(data.data || []);
|
||||
setTotal(data.total || 0);
|
||||
const ttl = data.retention_ttl_days || 0;
|
||||
setRetentionTTL(ttl);
|
||||
setTTLDraft(String(ttl));
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function saveTTL() {
|
||||
const val = parseInt(ttlDraft, 10);
|
||||
if (isNaN(val) || val < 0) { sw.toast('TTL must be 0 or more', 'error'); return; }
|
||||
try {
|
||||
await sw.api.admin.settings.update('retention_ttl_days', { value: { value: val } });
|
||||
setRetentionTTL(val);
|
||||
sw.toast('Retention TTL saved', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function purge(id, title) {
|
||||
const ok = await sw.confirm(`Permanently delete "${title || id}"? This cannot be undone.`, true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.admin.channels.purge(id);
|
||||
sw.toast('Channel purged', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="stat-cards-grid" style="margin-bottom:16px;">
|
||||
<div class="stat-card"><div class="stat-value">${total}</div><div class="stat-label">Archived</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${retentionTTL || 'Off'}</div><div class="stat-label">Retention TTL (days)</div></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-section" style="margin-bottom:20px;">
|
||||
<h4 style="margin:0 0 8px;">Retention Policy</h4>
|
||||
<p class="text-muted" style="font-size:12px;margin:0 0 8px;">
|
||||
Channels are archived on delete and purged after this many days.
|
||||
Only Personal (BYOK) provider channels are exempt. Set to 0 to disable retention.
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<input type="number" min="0" value=${ttlDraft}
|
||||
onInput=${e => setTTLDraft(e.target.value)}
|
||||
style="width:80px;padding:6px 8px;background:var(--bg-2);color:var(--text);border:1px solid var(--border);border-radius:4px;"
|
||||
placeholder="0" />
|
||||
<span class="text-muted" style="font-size:12px;">days</span>
|
||||
<button class="btn-small" onClick=${saveTTL}
|
||||
disabled=${String(retentionTTL) === ttlDraft}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 style="margin:0 0 8px;">Archived Channels</h4>
|
||||
<div class="admin-list">
|
||||
${channels.length === 0 && html`<div class="empty-hint">No archived channels</div>`}
|
||||
${channels.map(c => html`
|
||||
<div class="admin-surface-row" key=${c.id}>
|
||||
<strong style="flex:1;">${c.title || c.id}</strong>
|
||||
<span class="text-muted" style="font-size:11px;">${c.archived_at ? new Date(c.archived_at).toLocaleDateString() : ''}</span>
|
||||
<button class="btn-small btn-danger" onClick=${() => purge(c.id, c.title)}>Purge</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* Admin > Monitoring > Dashboard
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback, useRef } = hooks;
|
||||
|
||||
export default function DashboardSection() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const d = await sw.api.admin.dashboard();
|
||||
setData(d);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
timerRef.current = setInterval(load, 10000);
|
||||
return () => clearInterval(timerRef.current);
|
||||
}, []);
|
||||
|
||||
function formatUptime(seconds) {
|
||||
if (!seconds) return '\u2014';
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function formatBytes(b) {
|
||||
if (!b) return '0 B';
|
||||
if (b < 1048576) return (b / 1024).toFixed(0) + ' KB';
|
||||
if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB';
|
||||
return (b / 1073741824).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
if (!data) return html`<div class="empty-hint">No dashboard data</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">Auto-refreshes every 10s</p>
|
||||
|
||||
<div class="stat-cards-grid" style="margin-bottom:16px;">
|
||||
<div class="stat-card"><div class="stat-value">${formatUptime(data.uptime_seconds)}</div><div class="stat-label">Uptime</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${data.active_connections ?? '\u2014'}</div><div class="stat-label">Connections</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${data.goroutines ?? '\u2014'}</div><div class="stat-label">Goroutines</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${formatBytes(data.heap_alloc)}</div><div class="stat-label">Heap</div></div>
|
||||
</div>
|
||||
|
||||
${data.db_pool && html`
|
||||
<div class="stat-cards-grid" style="margin-bottom:16px;">
|
||||
<div class="stat-card"><div class="stat-value">${data.db_pool.open || 0}</div><div class="stat-label">DB Open</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${data.db_pool.in_use || 0}</div><div class="stat-label">DB In Use</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${data.db_pool.idle || 0}</div><div class="stat-label">DB Idle</div></div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${data.providers && data.providers.length > 0 && html`
|
||||
<h4 style="margin:16px 0 8px;">Provider Health</h4>
|
||||
<div class="stat-cards-grid">
|
||||
${data.providers.map(p => html`
|
||||
<div class="dashboard-provider-card" key=${p.name || p.id}>
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
|
||||
<strong style="font-size:13px;">${p.name || p.id}</strong>
|
||||
<span class="badge ${p.status === 'healthy' ? 'badge-active' : p.status === 'degraded' ? 'badge-pending' : 'badge-inactive'}">${p.status}</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-3);">${Math.round(p.avg_latency_ms || 0)}ms avg</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${data.recent_errors && data.recent_errors.length > 0 && html`
|
||||
<h4 style="margin:16px 0 8px;">Recent Errors</h4>
|
||||
<div class="admin-list">
|
||||
${data.recent_errors.map((e, i) => html`
|
||||
<div class="admin-surface-row" key=${i} style="font-size:12px;color:var(--danger);">${e}</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Admin > Routing > Health
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback, useRef } = hooks;
|
||||
|
||||
export default function HealthSection() {
|
||||
const [providers, setProviders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.providers.health();
|
||||
setProviders(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
timerRef.current = setInterval(load, 30000);
|
||||
return () => clearInterval(timerRef.current);
|
||||
}, []);
|
||||
|
||||
function statusClass(s) {
|
||||
if (s === 'healthy') return 'badge-active';
|
||||
if (s === 'degraded') return 'badge-pending';
|
||||
return 'badge-inactive';
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">Auto-refreshes every 30s</p>
|
||||
<div class="stat-cards-grid">
|
||||
${providers.length === 0 && html`<div class="empty-hint">No providers</div>`}
|
||||
${providers.map(p => html`
|
||||
<div class="dashboard-provider-card" key=${p.provider_config_id}>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<strong>${p.provider_config_name || p.provider_config_id}</strong>
|
||||
<span class="badge ${statusClass(p.status)}">${p.status}</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-2);">
|
||||
<div>Error rate: ${((p.error_rate || 0) * 100).toFixed(1)}%</div>
|
||||
<div>Avg latency: ${Math.round(p.avg_latency_ms || 0)}ms</div>
|
||||
<div>Timeout rate: ${((p.timeout_rate || 0) * 100).toFixed(1)}%</div>
|
||||
<div>Rate limits: ${p.rate_limit_count || 0}</div>
|
||||
${p.last_error && html`<div style="color:var(--danger);margin-top:4px;font-size:11px;">Last error: ${p.last_error}</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -19,23 +19,17 @@ import { DialogStack } from '../../shell/dialog-stack.js';
|
||||
// ── Category → Section mapping ──────────────
|
||||
const ADMIN_SECTIONS = {
|
||||
people: ['users', 'teams', 'groups'],
|
||||
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
|
||||
workflows: ['workflows', 'tasks'],
|
||||
routing: ['health', 'routing', 'capabilities'],
|
||||
system: ['settings', 'storage', 'packages', 'connections', 'channels', 'broadcast'],
|
||||
monitoring: ['dashboard', 'usage', 'audit', 'stats'],
|
||||
workflows: ['workflows'],
|
||||
system: ['settings', 'storage', 'packages', 'connections', 'broadcast'],
|
||||
monitoring: ['audit'],
|
||||
};
|
||||
|
||||
const ADMIN_LABELS = {
|
||||
users: 'Users', teams: 'Teams', groups: 'Groups',
|
||||
providers: 'Providers', models: 'Models', personas: 'Personas',
|
||||
roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
|
||||
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
|
||||
workflows: 'Workflows', tasks: 'Tasks',
|
||||
workflows: 'Workflows',
|
||||
settings: 'Settings', storage: 'Storage', packages: 'Packages',
|
||||
connections: 'Connections',
|
||||
channels: 'Channels', broadcast: 'Broadcast',
|
||||
dashboard: 'Dashboard', usage: 'Usage', audit: 'Audit', stats: 'Stats',
|
||||
connections: 'Connections', broadcast: 'Broadcast',
|
||||
audit: 'Audit',
|
||||
};
|
||||
|
||||
// ── v0.38.3: Extension config sections ──────
|
||||
@@ -54,11 +48,9 @@ for (const cs of _configSections) {
|
||||
|
||||
const CATEGORY_META = {
|
||||
people: { label: 'People', first: 'users', icon: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2|C9 7 4' },
|
||||
ai: { label: 'AI', first: 'providers', icon: 'R4 4 16 16 2|L9 9 9.01 9|L15 9 15.01 9|M8 14s1.5 2 4 2 4-2 4-2' },
|
||||
workflows: { label: 'Workflows', first: 'workflows', icon: 'P16 3 21 3 21 8|L4 20 21 3|P21 16 21 21 16 21|L15 15 21 21|L4 4 9 9' },
|
||||
routing: { label: 'Routing', first: 'health', icon: 'G13 2 3 14 12 14 11 22 21 10 12 10 13 2' },
|
||||
system: { label: 'System', first: 'settings', icon: 'C12 12 3|M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09' },
|
||||
monitoring: { label: 'Monitoring', first: 'dashboard', icon: 'L18 20 18 10|L12 20 12 4|L6 20 6 14' },
|
||||
monitoring: { label: 'Monitoring', first: 'audit', icon: 'L18 20 18 10|L12 20 12 4|L6 20 6 14' },
|
||||
};
|
||||
|
||||
// ── Lazy section imports ────────────────────
|
||||
@@ -68,27 +60,13 @@ const sectionModules = {
|
||||
users: () => import(`./users.js${_v}`),
|
||||
teams: () => import(`./teams.js${_v}`),
|
||||
groups: () => import(`./groups.js${_v}`),
|
||||
providers: () => import(`./providers.js${_v}`),
|
||||
models: () => import(`./models.js${_v}`),
|
||||
personas: () => import(`./personas.js${_v}`),
|
||||
roles: () => import(`./roles.js${_v}`),
|
||||
knowledgeBases: () => import(`./knowledge.js${_v}`),
|
||||
memory: () => import(`./memory.js${_v}`),
|
||||
workflows: () => import(`./workflows.js${_v}`),
|
||||
tasks: () => import(`./tasks.js${_v}`),
|
||||
health: () => import(`./health.js${_v}`),
|
||||
routing: () => import(`./routing.js${_v}`),
|
||||
capabilities: () => import(`./capabilities.js${_v}`),
|
||||
settings: () => import(`./settings.js${_v}`),
|
||||
storage: () => import(`./storage.js${_v}`),
|
||||
packages: () => import(`./packages.js${_v}`),
|
||||
connections: () => import(`./connections.js${_v}`),
|
||||
channels: () => import(`./channels.js${_v}`),
|
||||
broadcast: () => import(`./broadcast.js${_v}`),
|
||||
dashboard: () => import(`./dashboard.js${_v}`),
|
||||
usage: () => import(`./usage.js${_v}`),
|
||||
audit: () => import(`./audit.js${_v}`),
|
||||
stats: () => import(`./stats.js${_v}`),
|
||||
};
|
||||
|
||||
// v0.38.3: Register dynamic section loaders for extension config sections
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* Admin > AI > Knowledge Bases
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function KnowledgeSection() {
|
||||
const [kbs, setKbs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [docs, setDocs] = useState([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.knowledge.list();
|
||||
setKbs(resp.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function openDetail(kb) {
|
||||
setDetail(kb);
|
||||
try {
|
||||
const d = await sw.api.knowledge.documents(kb.id);
|
||||
setDocs(d.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function uploadDoc() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.txt,.md,.csv,.html';
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
await sw.api.knowledge.upload(detail.id, file);
|
||||
sw.toast('Document uploaded', 'success');
|
||||
const d = await sw.api.knowledge.documents(detail.id);
|
||||
setDocs(d.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
async function deleteDoc(docId) {
|
||||
const ok = await sw.confirm('Delete this document?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.knowledge.delDoc(detail.id, docId);
|
||||
sw.toast('Document deleted', 'success');
|
||||
setDocs(prev => prev.filter(d => d.id !== docId));
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteKb(id) {
|
||||
const ok = await sw.confirm('Delete this knowledge base and all documents?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.knowledge.del(id);
|
||||
sw.toast('Knowledge base deleted', 'success');
|
||||
setDetail(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const cls = status === 'ready' ? 'badge-active' : status === 'error' ? 'badge-inactive' : 'badge-pending';
|
||||
return html`<span class="badge ${cls}">${status}</span>`;
|
||||
}
|
||||
|
||||
function formatBytes(b) {
|
||||
if (!b) return '0 B';
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
|
||||
return (b / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
if (detail) return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button class="btn-small" onClick=${() => setDetail(null)}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">${detail.name}</h4>
|
||||
${statusBadge(detail.status || 'active')}
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-small" onClick=${uploadDoc}>Upload Document</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteKb(detail.id)}>Delete KB</button>
|
||||
</div>
|
||||
|
||||
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">
|
||||
${detail.document_count || 0} docs \u2022 ${detail.chunk_count || 0} chunks \u2022 ${formatBytes(detail.total_bytes)}
|
||||
\u2022 scope: ${detail.scope || 'personal'}
|
||||
</p>
|
||||
|
||||
<div class="admin-list">
|
||||
${docs.length === 0 && html`<div class="empty-hint">No documents</div>`}
|
||||
${docs.map(d => html`
|
||||
<div class="admin-surface-row" key=${d.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${d.filename}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:11px;">${formatBytes(d.size_bytes)}</span>
|
||||
${' '}${statusBadge(d.status || 'pending')}
|
||||
</div>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteDoc(d.id)}>Delete</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="admin-list">
|
||||
${kbs.length === 0 && html`<div class="empty-hint">No knowledge bases</div>`}
|
||||
${kbs.map(k => html`
|
||||
<div class="admin-surface-row" key=${k.id} style="cursor:pointer;" onClick=${() => openDetail(k)}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${k.name}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:12px;">${k.document_count || 0} docs</span>
|
||||
</div>
|
||||
<span class="badge">${k.scope || 'personal'}</span>
|
||||
${statusBadge(k.status || 'active')}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* Admin > AI > Memory
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function MemorySection() {
|
||||
const [pending, setPending] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.memories.pending();
|
||||
setPending(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function bulkApprove() {
|
||||
const ids = pending.map(m => m.id);
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
const result = await sw.api.admin.memories.bulkApprove(ids);
|
||||
sw.toast(`Approved ${result.count || ids.length} memories`, 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function approveSingle(id) {
|
||||
try {
|
||||
await sw.api.memory.approve(id);
|
||||
sw.toast('Memory approved', 'success');
|
||||
setPending(prev => prev.filter(m => m.id !== id));
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function rejectSingle(id) {
|
||||
try {
|
||||
await sw.api.memory.reject(id);
|
||||
sw.toast('Memory rejected', 'success');
|
||||
setPending(prev => prev.filter(m => m.id !== id));
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<span class="text-muted" style="font-size:12px;">${pending.length} pending</span>
|
||||
<div style="flex:1"></div>
|
||||
${pending.length > 0 && html`<button class="btn-small btn-primary" onClick=${bulkApprove}>Approve All</button>`}
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
${pending.length === 0 && html`<div class="empty-hint">No pending memories</div>`}
|
||||
${pending.map(m => html`
|
||||
<div class="admin-surface-row" key=${m.id} style="flex-direction:column;align-items:stretch;">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<strong style="flex:1;">${m.key}</strong>
|
||||
<span class="badge">${m.scope || 'user'}</span>
|
||||
<span class="text-muted" style="font-size:11px;">conf: ${(m.confidence || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:12px;margin:4px 0;">${m.value}</div>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="btn-small btn-primary" onClick=${() => approveSingle(m.id)}>Approve</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => rejectSingle(m.id)}>Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Admin > AI > Models
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function ModelsSection() {
|
||||
const [models, setModels] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.models.list();
|
||||
setModels(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function fetchModels() {
|
||||
setFetching(true);
|
||||
try {
|
||||
const result = await sw.api.admin.models.fetch();
|
||||
sw.toast(`Synced: ${result.added || 0} added, ${result.updated || 0} updated, ${result.total || 0} total`, 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setFetching(false); }
|
||||
}
|
||||
|
||||
async function setVisibility(id, visibility) {
|
||||
try {
|
||||
await sw.api.admin.models.update(id, { visibility });
|
||||
setModels(prev => prev.map(m => m.id === id ? { ...m, visibility } : m));
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function bulkSetVisibility(visibility) {
|
||||
try {
|
||||
await sw.api.admin.models.bulkUpdate(visibility);
|
||||
sw.toast(`All models set to ${visibility}`, 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
const filtered = models.filter(m => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return (m.model_id || m.id || '').toLowerCase().includes(q) ||
|
||||
(m.display_name || '').toLowerCase().includes(q) ||
|
||||
(m.provider_name || '').toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button class="btn-small btn-primary" onClick=${fetchModels} disabled=${fetching}>
|
||||
${fetching ? 'Fetching\u2026' : 'Fetch Models'}
|
||||
</button>
|
||||
<span class="text-muted" style="font-size:12px;">${models.length} models</span>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-small" onClick=${() => bulkSetVisibility('enabled')}>Enable All</button>
|
||||
<button class="btn-small" onClick=${() => bulkSetVisibility('disabled')}>Disable All</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-search" style="margin-bottom:12px;">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--text-3);flex-shrink:0;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input placeholder="Search models\u2026" value=${search} onInput=${e => setSearch(e.target.value)} autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
${filtered.length === 0 && html`<div class="empty-hint">No models found</div>`}
|
||||
${filtered.map(m => html`
|
||||
<div class="admin-surface-row" key=${m.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${m.display_name || m.model_id || m.id}</strong>
|
||||
${m.display_name && m.model_id && html`<span class="text-muted" style="margin-left:8px;font-size:11px;">${m.model_id}</span>`}
|
||||
${m.provider_name && html`<span class="badge" style="margin-left:6px;">${m.provider_name}</span>`}
|
||||
</div>
|
||||
<select value=${m.visibility || 'disabled'} onChange=${e => setVisibility(m.id, e.target.value)}
|
||||
style="font-size:11px;padding:2px 4px;">
|
||||
<option value="enabled">enabled</option>
|
||||
<option value="disabled">disabled</option>
|
||||
<option value="team">team only</option>
|
||||
</select>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* Admin > AI > Personas
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function PersonasSection() {
|
||||
const [personas, setPersonas] = useState([]);
|
||||
const [models, setModels] = useState([]);
|
||||
const [kbs, setKbs] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(null); // null | 'new' | persona object
|
||||
const [editKbs, setEditKbs] = useState([]);
|
||||
const [editToolGrants, setEditToolGrants] = useState([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [p, m, k] = await Promise.all([
|
||||
sw.api.admin.personas.list(),
|
||||
sw.api.admin.models.list().catch(() => []),
|
||||
sw.api.knowledge.list().catch(() => []),
|
||||
]);
|
||||
setPersonas(p || []);
|
||||
setModels(m || []);
|
||||
setKbs(k.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function startEdit(persona) {
|
||||
setEditing(persona);
|
||||
if (persona !== 'new') {
|
||||
try {
|
||||
const [kbData, tgData] = await Promise.all([
|
||||
sw.api.admin.personas.kbs(persona.id).catch(() => []),
|
||||
sw.api.admin.personas.toolGrants(persona.id).catch(() => ({ tool_names: [] })),
|
||||
]);
|
||||
setEditKbs(Array.isArray(kbData) ? kbData.map(k => k.kb_id || k.id) : kbData.kb_ids || []);
|
||||
setEditToolGrants(tgData.tool_names || []);
|
||||
} catch { setEditKbs([]); setEditToolGrants([]); }
|
||||
} else {
|
||||
setEditKbs([]);
|
||||
setEditToolGrants([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function savePersona(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = {
|
||||
name: form.pname.value.trim(),
|
||||
handle: form.handle.value.trim(),
|
||||
description: form.description.value.trim(),
|
||||
icon: form.icon.value.trim(),
|
||||
system_prompt: form.system_prompt.value,
|
||||
base_model_id: form.model.value || null,
|
||||
temperature: form.temperature.value ? parseFloat(form.temperature.value) : null,
|
||||
max_tokens: form.max_tokens.value ? parseInt(form.max_tokens.value) : null,
|
||||
memory_enabled: form.memory_enabled.checked,
|
||||
};
|
||||
try {
|
||||
let id;
|
||||
if (editing === 'new') {
|
||||
const res = await sw.api.admin.personas.create(data);
|
||||
id = res.id;
|
||||
sw.toast('Persona created', 'success');
|
||||
} else {
|
||||
id = editing.id;
|
||||
await sw.api.admin.personas.update(id, data);
|
||||
sw.toast('Persona updated', 'success');
|
||||
}
|
||||
// Save KB bindings
|
||||
if (id) {
|
||||
await sw.api.admin.personas.setKbs(id, { kb_ids: editKbs }).catch(() => {});
|
||||
await sw.api.admin.personas.setToolGrants(id, { tool_names: editToolGrants }).catch(() => {});
|
||||
}
|
||||
setEditing(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deletePersona(id) {
|
||||
const ok = await sw.confirm('Delete this persona?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.admin.personas.del(id);
|
||||
sw.toast('Persona deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function uploadAvatar(id) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
await sw.api.admin.personas.avatar(id, reader.result);
|
||||
sw.toast('Avatar uploaded', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
function toggleKb(kbId) {
|
||||
setEditKbs(prev => prev.includes(kbId) ? prev.filter(k => k !== kbId) : [...prev, kbId]);
|
||||
}
|
||||
|
||||
const filtered = personas.filter(p => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return (p.name || '').toLowerCase().includes(q) || (p.handle || '').toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
if (editing) {
|
||||
const isNew = editing === 'new';
|
||||
const p = isNew ? {} : editing;
|
||||
return html`
|
||||
<form onSubmit=${savePersona}>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">${isNew ? 'Create Persona' : `Edit: ${p.name}`}</h4>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label><input name="pname" value=${p.name || ''} placeholder="Code Reviewer" /></div>
|
||||
<div class="form-group"><label>Handle</label><input name="handle" value=${p.handle || ''} placeholder="code-reviewer" /></div>
|
||||
<div class="form-group" style="width:60px;"><label>Icon</label><input name="icon" value=${p.icon || ''} placeholder="\ud83d\udd0d" /></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Description</label><input name="description" value=${p.description || ''} placeholder="Optional description" /></div>
|
||||
<div class="form-group"><label>System Prompt</label>
|
||||
<textarea name="system_prompt" rows="4" placeholder="System prompt\u2026">${p.system_prompt || ''}</textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Model</label>
|
||||
<select name="model">
|
||||
<option value="">Default</option>
|
||||
${models.map(m => html`<option key=${m.id} value=${m.model_id || m.id} selected=${p.base_model_id === (m.model_id || m.id)}>${m.display_name || m.model_id || m.id}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="width:100px;"><label>Temperature</label>
|
||||
<input name="temperature" type="number" step="0.1" min="0" max="2" value=${p.temperature ?? ''} placeholder="0.7" />
|
||||
</div>
|
||||
<div class="form-group" style="width:100px;"><label>Max Tokens</label>
|
||||
<input name="max_tokens" type="number" min="1" value=${p.max_tokens ?? ''} placeholder="4096" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="toggle-label" style="margin:8px 0;">
|
||||
<input type="checkbox" name="memory_enabled" checked=${!!p.memory_enabled} />
|
||||
<span class="toggle-track"></span>
|
||||
<span>Memory enabled</span>
|
||||
</label>
|
||||
|
||||
${!isNew && html`
|
||||
<div style="margin:12px 0;">
|
||||
<button type="button" class="btn-small" onClick=${() => uploadAvatar(p.id)}>Upload Avatar</button>
|
||||
${p.avatar && html`<button type="button" class="btn-small btn-danger" style="margin-left:6px;"
|
||||
onClick=${async () => { await sw.api.admin.personas.delAvatar(p.id); sw.toast('Avatar removed', 'success'); load(); }}>Remove Avatar</button>`}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="settings-section" style="margin:12px 0;">
|
||||
<h5 style="margin:0 0 8px;">Knowledge Base Bindings</h5>
|
||||
${kbs.length === 0
|
||||
? html`<span class="text-muted" style="font-size:12px;">No knowledge bases available</span>`
|
||||
: kbs.map(k => html`
|
||||
<label class="toggle-label" key=${k.id}>
|
||||
<input type="checkbox" checked=${editKbs.includes(k.id)} onChange=${() => toggleKb(k.id)} />
|
||||
<span class="toggle-track"></span>
|
||||
<span>${k.name}</span>
|
||||
</label>
|
||||
`)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="margin-top:16px;gap:8px;">
|
||||
<button type="submit" class="btn-small btn-primary">${isNew ? 'Create' : 'Save'}</button>
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<div class="admin-search">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--text-3);flex-shrink:0;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input placeholder="Search personas\u2026" value=${search} onInput=${e => setSearch(e.target.value)} autocomplete="off" />
|
||||
</div>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-md btn-primary" onClick=${() => startEdit('new')}>+ Add</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
${filtered.length === 0 && html`<div class="empty-hint">No personas</div>`}
|
||||
${filtered.map(p => html`
|
||||
<div class="admin-surface-row" key=${p.id}>
|
||||
<span style="font-size:18px;margin-right:6px;">${p.icon || '\ud83e\udd16'}</span>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${p.name}</strong>
|
||||
<span class="text-muted" style="margin-left:6px;font-size:11px;">@${p.handle || '?'}</span>
|
||||
${p.scope && html`<span class="badge" style="margin-left:6px;">${p.scope}</span>`}
|
||||
</div>
|
||||
<div class="admin-actions-cell">
|
||||
<button class="btn-small" onClick=${() => startEdit(p)}>Edit</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deletePersona(p.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* Admin > AI > Providers
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function ProvidersSection() {
|
||||
const [configs, setConfigs] = useState([]);
|
||||
const [providerTypes, setProviderTypes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(null); // null | 'new' | config object
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [c, t] = await Promise.all([
|
||||
sw.api.admin.configs.list(),
|
||||
sw.api.admin.providers.types(),
|
||||
]);
|
||||
setConfigs(c || []);
|
||||
setProviderTypes(t || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function saveProvider(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = {
|
||||
name: form.pname.value.trim(),
|
||||
provider: form.provider.value,
|
||||
endpoint: form.endpoint.value.trim(),
|
||||
api_key: form.api_key.value || undefined,
|
||||
is_private: form.is_private.checked,
|
||||
};
|
||||
try {
|
||||
if (editing === 'new') {
|
||||
await sw.api.admin.configs.create(data);
|
||||
sw.toast('Provider created', 'success');
|
||||
} else {
|
||||
await sw.api.admin.configs.update(editing.id, data);
|
||||
sw.toast('Provider updated', 'success');
|
||||
}
|
||||
setEditing(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function syncModels(id) {
|
||||
try {
|
||||
const result = await sw.api.admin.models.fetch();
|
||||
sw.toast(`Synced: ${result.added || 0} added, ${result.updated || 0} updated`, 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function testProvider(cfg) {
|
||||
sw.toast(`Testing ${cfg.name}\u2026`, 'info');
|
||||
try {
|
||||
const result = await sw.api.admin.models.fetch();
|
||||
sw.toast(`${cfg.name}: connection OK`, 'success');
|
||||
} catch (e) {
|
||||
sw.toast(`${cfg.name}: connection failed \u2014 ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProvider(id) {
|
||||
const ok = await sw.confirm('Delete this provider config?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.admin.configs.del(id);
|
||||
sw.toast('Provider deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-md btn-primary" onClick=${() => setEditing('new')}>+ Add</button>
|
||||
</div>
|
||||
|
||||
${editing && html`
|
||||
<form class="admin-inline-form" style="margin-bottom:16px;padding:12px;border:1px solid var(--border);border-radius:var(--radius);" onSubmit=${saveProvider}>
|
||||
<h4 style="margin:0 0 12px;">${editing === 'new' ? 'Add Provider' : 'Edit Provider'}</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label>
|
||||
<input name="pname" placeholder="My OpenAI" value=${editing !== 'new' ? editing.name || '' : ''} />
|
||||
</div>
|
||||
<div class="form-group"><label>Type</label>
|
||||
<select name="provider">
|
||||
${providerTypes.map(t => html`<option key=${t.id} value=${t.id} selected=${editing !== 'new' && editing.provider === t.id}>${t.display_name || t.name}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:2;"><label>Endpoint</label>
|
||||
<input name="endpoint" placeholder="https://api.openai.com/v1" value=${editing !== 'new' ? editing.endpoint || '' : ''} />
|
||||
</div>
|
||||
<div class="form-group" style="flex:1;"><label>API Key</label>
|
||||
<input name="api_key" type="password" placeholder="${editing !== 'new' ? '(unchanged)' : 'sk-...'}" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="toggle-label" style="margin:8px 0;">
|
||||
<input type="checkbox" name="is_private" checked=${editing !== 'new' && editing.is_private} />
|
||||
<span class="toggle-track"></span>
|
||||
<span>Private (admin personas only)</span>
|
||||
</label>
|
||||
<div class="form-row" style="margin-top:12px;gap:8px;">
|
||||
<button type="submit" class="btn-small btn-primary">${editing === 'new' ? 'Create' : 'Save'}</button>
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`}
|
||||
|
||||
<div class="provider-cards">
|
||||
${configs.length === 0 && html`<div class="empty-hint">No provider configs</div>`}
|
||||
${configs.map(c => html`
|
||||
<div class="provider-card" key=${c.id}>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<strong>${c.name}</strong>
|
||||
<span class="badge">${c.provider}</span>
|
||||
${c.is_private && html`<span class="badge badge-inactive">private</span>`}
|
||||
${c.has_key ? html`<span class="badge badge-active">key set</span>` : html`<span class="badge badge-pending">no key</span>`}
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:12px;margin-bottom:8px;word-break:break-all;">${c.endpoint || '(default)'}</div>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="btn-small" onClick=${() => testProvider(c)}>Test</button>
|
||||
<button class="btn-small" onClick=${() => setEditing(c)}>Edit</button>
|
||||
<button class="btn-small" onClick=${() => syncModels(c.id)}>Sync Models</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteProvider(c.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Admin > AI > Roles
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function RolesSection() {
|
||||
const [roles, setRoles] = useState({});
|
||||
const [models, setModels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dirty, setDirty] = useState({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [r, m] = await Promise.all([
|
||||
sw.api.admin.roles.list(),
|
||||
sw.api.admin.models.list().catch(() => []),
|
||||
]);
|
||||
setRoles(r || {});
|
||||
setModels(m || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
function updateRole(roleName, field, value) {
|
||||
setDirty(prev => ({
|
||||
...prev,
|
||||
[roleName]: { ...(prev[roleName] || {}), [field]: value },
|
||||
}));
|
||||
}
|
||||
|
||||
async function saveRole(roleName) {
|
||||
const changes = dirty[roleName];
|
||||
if (!changes) return;
|
||||
try {
|
||||
// Get current role config and merge changes
|
||||
const current = await sw.api.admin.roles.get(roleName);
|
||||
const updated = { ...current, ...changes };
|
||||
await sw.api.admin.roles.update(roleName, updated);
|
||||
sw.toast(`Role "${roleName}" saved`, 'success');
|
||||
setDirty(prev => { const n = { ...prev }; delete n[roleName]; return n; });
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
${Object.keys(roles).length === 0 && html`<div class="empty-hint">No roles configured</div>`}
|
||||
${Object.entries(roles).map(([name, config]) => {
|
||||
const changes = dirty[name] || {};
|
||||
const primary = changes.primary_model ?? config.primary_model ?? '';
|
||||
const fallback = changes.fallback_model ?? config.fallback_model ?? '';
|
||||
|
||||
return html`
|
||||
<div class="settings-section" key=${name} style="margin-bottom:16px;padding:12px;border:1px solid var(--border);border-radius:var(--radius);">
|
||||
<h4 style="margin:0 0 8px;">${name}</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Primary Model</label>
|
||||
<select value=${primary} onChange=${e => updateRole(name, 'primary_model', e.target.value)}>
|
||||
<option value="">-- None --</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="form-group"><label>Fallback Model</label>
|
||||
<select value=${fallback} onChange=${e => updateRole(name, 'fallback_model', e.target.value)}>
|
||||
<option value="">-- None --</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>
|
||||
${dirty[name] && html`<button class="btn-small btn-primary" style="margin-top:8px;" onClick=${() => saveRole(name)}>Save</button>`}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* Admin > Routing > Routing Policies
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
const POLICY_TYPES = ['provider_prefer', 'team_route', 'cost_limit', 'model_alias', 'capability_match'];
|
||||
|
||||
export default function RoutingSection() {
|
||||
const [policies, setPolicies] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(null); // null | 'new' | policy
|
||||
const [testModel, setTestModel] = useState('');
|
||||
const [testUser, setTestUser] = useState('');
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.routing.policies();
|
||||
setPolicies(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function savePolicy(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const data = {
|
||||
name: form.pname.value.trim(),
|
||||
scope: form.scope.value,
|
||||
priority: parseInt(form.priority.value) || 0,
|
||||
policy_type: form.policy_type.value,
|
||||
config: JSON.parse(form.config.value || '{}'),
|
||||
is_active: form.is_active.checked,
|
||||
};
|
||||
try {
|
||||
if (editing === 'new') {
|
||||
await sw.api.admin.routing.createPolicy(data);
|
||||
sw.toast('Policy created', 'success');
|
||||
} else {
|
||||
await sw.api.admin.routing.updatePolicy(editing.id, data);
|
||||
sw.toast('Policy updated', 'success');
|
||||
}
|
||||
setEditing(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deletePolicy(id) {
|
||||
const ok = await sw.confirm('Delete this routing policy?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.admin.routing.deletePolicy(id);
|
||||
sw.toast('Policy deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
if (!testModel) return sw.toast('Model is required', 'error');
|
||||
try {
|
||||
const result = await sw.api.admin.routing.test({
|
||||
model: testModel,
|
||||
user_id: testUser || undefined,
|
||||
});
|
||||
setTestResult(result);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
if (editing) {
|
||||
const isNew = editing === 'new';
|
||||
const p = isNew ? {} : editing;
|
||||
return html`
|
||||
<form onSubmit=${savePolicy}>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">${isNew ? 'New Policy' : `Edit: ${p.name}`}</h4>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label><input name="pname" value=${p.name || ''} placeholder="Prefer Anthropic" /></div>
|
||||
<div class="form-group" style="width:100px;"><label>Priority</label>
|
||||
<input name="priority" type="number" value=${p.priority ?? 10} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Scope</label>
|
||||
<select name="scope">
|
||||
<option value="global" selected=${p.scope !== 'team'}>global</option>
|
||||
<option value="team" selected=${p.scope === 'team'}>team</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>Type</label>
|
||||
<select name="policy_type">
|
||||
${POLICY_TYPES.map(t => html`<option key=${t} value=${t} selected=${p.policy_type === t}>${t}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Config (JSON)</label>
|
||||
<textarea name="config" rows="4" style="font-family:monospace;font-size:12px;">${JSON.stringify(p.config || {}, null, 2)}</textarea>
|
||||
</div>
|
||||
<label class="toggle-label" style="margin:8px 0;">
|
||||
<input type="checkbox" name="is_active" checked=${p.is_active !== false} />
|
||||
<span class="toggle-track"></span><span>Active</span>
|
||||
</label>
|
||||
<div class="form-row" style="margin-top:12px;gap:8px;">
|
||||
<button type="submit" class="btn-small btn-primary">${isNew ? 'Create' : 'Save'}</button>
|
||||
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<button class="btn-md btn-primary" onClick=${() => setEditing('new')}>+ New Policy</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
${policies.length === 0 && html`<div class="empty-hint">No routing policies</div>`}
|
||||
${policies.map(p => html`
|
||||
<div class="admin-surface-row" key=${p.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${p.name}</strong>
|
||||
<span class="badge" style="margin-left:6px;">${p.policy_type}</span>
|
||||
<span class="text-muted" style="margin-left:6px;font-size:11px;">priority: ${p.priority}</span>
|
||||
</div>
|
||||
<span class="badge">${p.scope}</span>
|
||||
<span class="badge ${p.is_active ? 'badge-active' : 'badge-inactive'}">${p.is_active ? 'active' : 'off'}</span>
|
||||
<div class="admin-actions-cell">
|
||||
<button class="btn-small" onClick=${() => setEditing(p)}>Edit</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deletePolicy(p.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top:24px;">
|
||||
<h4>Test Routing</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Model</label>
|
||||
<input value=${testModel} onInput=${e => setTestModel(e.target.value)} placeholder="gpt-4o" />
|
||||
</div>
|
||||
<div class="form-group"><label>User ID</label>
|
||||
<input value=${testUser} onInput=${e => setTestUser(e.target.value)} placeholder="optional" />
|
||||
</div>
|
||||
<button class="btn-small btn-primary" style="align-self:flex-end;" onClick=${runTest}>Test</button>
|
||||
</div>
|
||||
${testResult && html`
|
||||
<pre class="code-block" style="margin-top:8px;font-size:12px;max-height:200px;overflow:auto;">${JSON.stringify(testResult, null, 2)}</pre>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -195,7 +195,7 @@ export default function SettingsSection() {
|
||||
<label class="toggle-label"><input type="checkbox" checked=${cfg.footer_enabled} onChange=${e => set('footer_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Show footer bar</span></label>
|
||||
${cfg.footer_enabled && html`
|
||||
<div style="margin-top:8px;">
|
||||
<div class="form-group"><label>Text</label><input value=${cfg.footer_text} onInput=${e => set('footer_text', e.target.value)} placeholder="Powered by Chat Switchboard" /></div>
|
||||
<div class="form-group"><label>Text</label><input value=${cfg.footer_text} onInput=${e => set('footer_text', e.target.value)} placeholder="Powered by Switchboard Core" /></div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Admin > Monitoring > Stats
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect } = hooks;
|
||||
|
||||
export default function StatsSection() {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
sw.api.admin.stats()
|
||||
.then(d => setStats(d))
|
||||
.catch(e => sw.toast(e.message, 'error'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
if (!stats) return html`<div class="empty-hint">No stats available</div>`;
|
||||
|
||||
const cards = [
|
||||
{ label: 'Users', value: stats.users },
|
||||
{ label: 'Channels', value: stats.channels },
|
||||
{ label: 'Messages', value: stats.messages },
|
||||
{ label: 'Models', value: stats.models },
|
||||
{ label: 'Providers', value: stats.providers },
|
||||
{ label: 'Personas', value: stats.personas },
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="stat-cards-grid">
|
||||
${cards.map(c => html`
|
||||
<div class="stat-card" key=${c.label}>
|
||||
<div class="stat-value">${c.value != null ? c.value.toLocaleString() : '\u2014'}</div>
|
||||
<div class="stat-label">${c.label}</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* Admin > Workflows > Tasks
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function TasksSection() {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedRuns, setExpandedRuns] = useState({}); // taskId → runs[]
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.admin.tasks.list();
|
||||
setTasks(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function runTask(id) {
|
||||
try {
|
||||
await sw.api.admin.tasks.run(id);
|
||||
sw.toast('Task triggered', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function killTask(id) {
|
||||
try {
|
||||
await sw.api.admin.tasks.kill(id);
|
||||
sw.toast('Task cancelled', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteTask(id) {
|
||||
const ok = await sw.confirm('Delete this task?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.admin.tasks.del(id);
|
||||
sw.toast('Task deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function toggleRuns(taskId) {
|
||||
if (expandedRuns[taskId]) {
|
||||
setExpandedRuns(prev => { const n = { ...prev }; delete n[taskId]; return n; });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await sw.api.tasks.runs(taskId);
|
||||
const runs = data || [];
|
||||
setExpandedRuns(prev => ({ ...prev, [taskId]: runs }));
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="admin-list">
|
||||
${tasks.length === 0 && html`<div class="empty-hint">No tasks</div>`}
|
||||
${tasks.map(t => html`
|
||||
<div key=${t.id}>
|
||||
<div class="admin-surface-row">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${t.name}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:11px;">${t.scope || 'personal'}</span>
|
||||
${t.schedule && html`<span class="badge" style="margin-left:6px;">${t.schedule}</span>`}
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">
|
||||
last: ${fmtDate(t.last_run_at)} \u2022 next: ${fmtDate(t.next_run_at)}
|
||||
</span>
|
||||
<span class="badge ${t.is_active ? 'badge-active' : 'badge-inactive'}">${t.is_active ? 'active' : 'paused'}</span>
|
||||
<div class="admin-actions-cell">
|
||||
<button class="btn-small" onClick=${() => toggleRuns(t.id)}>${expandedRuns[t.id] ? 'Hide Runs' : 'Runs'}</button>
|
||||
<button class="btn-small btn-primary" onClick=${() => runTask(t.id)}>Run</button>
|
||||
<button class="btn-small" onClick=${() => killTask(t.id)}>Kill</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteTask(t.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
${expandedRuns[t.id] && html`
|
||||
<div style="padding:4px 0 8px 24px;">
|
||||
${expandedRuns[t.id].length === 0 && html`<span class="text-muted" style="font-size:12px;">No runs</span>`}
|
||||
${expandedRuns[t.id].map(r => html`
|
||||
<div key=${r.id} style="font-size:12px;display:flex;gap:8px;padding:2px 0;">
|
||||
<span class="badge ${r.status === 'completed' ? 'badge-active' : r.status === 'failed' ? 'badge-inactive' : 'badge-pending'}">${r.status}</span>
|
||||
<span>${fmtDate(r.started_at)}</span>
|
||||
<span class="text-muted">${r.tokens_used || 0} tokens</span>
|
||||
${r.error && html`<span style="color:var(--danger);">${r.error}</span>`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Admin > Monitoring > Usage
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
export default function UsageSection() {
|
||||
const [usage, setUsage] = useState(null);
|
||||
const [pricing, setPricing] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [u, p] = await Promise.all([
|
||||
sw.api.admin.usage.get(),
|
||||
sw.api.admin.pricing.list().catch(() => []),
|
||||
]);
|
||||
setUsage(u);
|
||||
setPricing(p || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function deletePricing(provId, modelId) {
|
||||
try {
|
||||
await sw.api.admin.pricing.del(provId, modelId);
|
||||
sw.toast('Pricing entry removed', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function fmtNum(n) {
|
||||
if (n == null) return '\u2014';
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
${usage?.totals && html`
|
||||
<div class="stat-cards-grid" style="margin-bottom:16px;">
|
||||
<div class="stat-card"><div class="stat-value">${fmtNum(usage.totals.total_tokens)}</div><div class="stat-label">Total Tokens</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${fmtNum(usage.totals.total_requests)}</div><div class="stat-label">Total Requests</div></div>
|
||||
<div class="stat-card"><div class="stat-value">$${(usage.totals.total_cost || 0).toFixed(2)}</div><div class="stat-label">Total Cost</div></div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${usage?.results && usage.results.length > 0 && html`
|
||||
<h4 style="margin:16px 0 8px;">Usage by Day</h4>
|
||||
<div class="admin-list">
|
||||
${usage.results.map((r, i) => html`
|
||||
<div class="admin-surface-row" key=${i}>
|
||||
<span style="width:100px;">${r.date || r.day || '\u2014'}</span>
|
||||
<span style="flex:1;">${fmtNum(r.tokens)} tokens</span>
|
||||
<span class="text-muted" style="font-size:12px;">${r.requests || 0} requests</span>
|
||||
<span class="text-muted" style="font-size:12px;">$${(r.cost || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<h4 style="margin:24px 0 8px;">Pricing Table</h4>
|
||||
${pricing.length === 0
|
||||
? html`<div class="empty-hint">No custom pricing entries</div>`
|
||||
: html`
|
||||
<div class="admin-list">
|
||||
<div class="admin-surface-row" style="font-weight:600;font-size:12px;">
|
||||
<span style="flex:1;">Model</span>
|
||||
<span style="width:100px;">Input/M</span>
|
||||
<span style="width:100px;">Output/M</span>
|
||||
<span style="width:80px;">Currency</span>
|
||||
<span style="width:60px;"></span>
|
||||
</div>
|
||||
${pricing.map(p => html`
|
||||
<div class="admin-surface-row" key=${p.model_id + p.provider_config_id}>
|
||||
<span style="flex:1;">${p.model_id}</span>
|
||||
<span style="width:100px;">$${(p.input_per_m || 0).toFixed(2)}</span>
|
||||
<span style="width:100px;">$${(p.output_per_m || 0).toFixed(2)}</span>
|
||||
<span style="width:80px;">${p.currency || 'USD'}</span>
|
||||
<button class="btn-small btn-danger" style="width:60px;" onClick=${() => deletePricing(p.provider_config_id, p.model_id)}>Del</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function Hero({ base = '', version = '', env = null }) {
|
||||
<div class="login-hero-brand login-anim login-anim-1">
|
||||
<img class="login-hero-brand-icon"
|
||||
src="${base}/favicon.svg?v=${version}"
|
||||
alt="Chat Switchboard" />
|
||||
alt="Switchboard Core" />
|
||||
<span class="login-hero-brand-text">
|
||||
Chat <span class="hl">Switchboard</span>
|
||||
</span>
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Settings > Data & Privacy — export and account deletion
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useCallback } = hooks;
|
||||
|
||||
export default function DataSection() {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const exportData = useCallback(async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
await sw.api.dataPortability.exportMe();
|
||||
sw.toast('Export started \u2014 you will receive a download link shortly', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setExporting(false); }
|
||||
}, []);
|
||||
|
||||
const deleteAccount = useCallback(async () => {
|
||||
const ok = await sw.confirm(
|
||||
'Are you sure you want to delete your account? This action is permanent and cannot be undone. All your data, conversations, and settings will be permanently deleted.',
|
||||
true,
|
||||
);
|
||||
if (!ok) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await sw.api.dataPortability.deleteAccount({ confirm: true });
|
||||
sw.toast('Account deletion initiated', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setDeleting(false); }
|
||||
}, []);
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="settings-section">
|
||||
<h3>Export Your Data</h3>
|
||||
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">
|
||||
Download a copy of all your data including conversations, settings, and memories.
|
||||
The export will be prepared and a download link will be provided.
|
||||
</p>
|
||||
<button class="btn-md btn-primary" disabled=${exporting} onClick=${exportData}>
|
||||
${exporting ? 'Preparing\u2026' : 'Export My Data'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top:24px;border-top:1px solid var(--border);padding-top:20px;">
|
||||
<h3 style="color:var(--danger);">Danger Zone</h3>
|
||||
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">
|
||||
Permanently delete your account and all associated data. This action cannot be undone.
|
||||
We recommend exporting your data before proceeding.
|
||||
</p>
|
||||
<button class="btn-md btn-danger" disabled=${deleting} onClick=${deleteAccount}>
|
||||
${deleting ? 'Deleting\u2026' : 'Delete My Account'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Settings > Git Keys — SSH credential management
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
export default function GitKeysSection() {
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.git.credentials.list();
|
||||
setKeys(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function addKey(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const name = form.name.value.trim();
|
||||
const key = form.key.value.trim();
|
||||
if (!name) { sw.toast('Name is required', 'error'); return; }
|
||||
try {
|
||||
await sw.api.git.credentials.create({ name, public_key: key || undefined });
|
||||
sw.toast('Credential added', 'success');
|
||||
form.reset();
|
||||
setShowAdd(false);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteKey(id) {
|
||||
const ok = await sw.confirm('Delete this SSH credential?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.git.credentials.del(id);
|
||||
sw.toast('Credential deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<span class="text-muted" style="font-size:12px;">${keys.length} credential${keys.length !== 1 ? 's' : ''}</span>
|
||||
<div style="flex:1"></div>
|
||||
<button class="btn-md btn-primary" onClick=${() => setShowAdd(!showAdd)}>
|
||||
${showAdd ? 'Cancel' : '+ Add Key'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${showAdd && html`
|
||||
<form class="settings-section" style="margin-bottom:16px;" onSubmit=${addKey}>
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input name="name" placeholder="e.g. deploy-key-prod" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Public Key <span class="text-muted" style="font-weight:400;">(paste, or leave blank to generate)</span></label>
|
||||
<textarea name="key" rows="3" placeholder="ssh-ed25519 AAAA..." style="font-family:var(--mono);font-size:12px;" />
|
||||
</div>
|
||||
<button type="submit" class="btn-small btn-primary">Add Credential</button>
|
||||
</form>
|
||||
`}
|
||||
|
||||
<div class="admin-list">
|
||||
${keys.length === 0 && html`<div class="empty-hint">No SSH credentials configured.</div>`}
|
||||
${keys.map(k => html`
|
||||
<div class="admin-surface-row" key=${k.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${k.name}</strong>
|
||||
${k.fingerprint && html`
|
||||
<span class="text-muted" style="margin-left:8px;font-size:11px;font-family:var(--mono);">
|
||||
${k.fingerprint.substring(0, 24)}\u2026
|
||||
</span>
|
||||
`}
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">${fmtDate(k.created_at)}</span>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteKey(k.id)}>Delete</button>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -26,18 +26,7 @@ const sectionModules = {
|
||||
appearance: () => import('./appearance.js'),
|
||||
profile: () => import('./profile.js'),
|
||||
teams: () => import('./teams.js'),
|
||||
models: () => import('./models.js'),
|
||||
providers: () => import('./providers.js'),
|
||||
connections: () => import('./connections.js'),
|
||||
personas: () => import('./personas.js'),
|
||||
roles: () => import('./roles.js'),
|
||||
usage: () => import('./usage.js'),
|
||||
workflows: () => import('./workflows.js'),
|
||||
tasks: () => import('./tasks.js'),
|
||||
gitkeys: () => import('./gitkeys.js'),
|
||||
data: () => import('./data.js'),
|
||||
knowledge: () => import('./knowledge.js'),
|
||||
memory: () => import('./memory.js'),
|
||||
notifications: () => import('./notifications.js'),
|
||||
};
|
||||
|
||||
@@ -45,35 +34,19 @@ const sectionModules = {
|
||||
const NAV_ITEMS = [
|
||||
{ key: 'general', label: 'General' },
|
||||
{ key: 'appearance', label: 'Appearance' },
|
||||
{ key: 'models', label: 'Models' },
|
||||
{ key: 'personas', label: 'Personas', gate: 'personas' },
|
||||
{ key: 'profile', label: 'Profile' },
|
||||
{ key: 'teams', label: 'Teams' },
|
||||
{ key: 'workflows', label: 'Assignments' },
|
||||
{ key: 'tasks', label: 'Tasks' },
|
||||
{ key: 'connections', label: 'Connections' },
|
||||
{ key: 'gitkeys', label: 'Git Keys' },
|
||||
{ key: 'knowledge', label: 'Knowledge Bases' },
|
||||
{ key: 'memory', label: 'Memory' },
|
||||
{ key: 'notifications', label: 'Notifications' },
|
||||
{ key: 'data', label: 'Data & Privacy' },
|
||||
];
|
||||
|
||||
const BYOK_ITEMS = [
|
||||
{ key: 'providers', label: 'My Providers' },
|
||||
{ key: 'roles', label: 'Model Roles' },
|
||||
{ key: 'usage', label: 'My Usage' },
|
||||
];
|
||||
const BYOK_ITEMS = [];
|
||||
|
||||
// ── Section title map ───────────────────────
|
||||
const SECTION_TITLES = {
|
||||
general: 'General', appearance: 'Appearance', models: 'Models',
|
||||
personas: 'Personas', profile: 'Profile', teams: 'Teams',
|
||||
workflows: 'Assignments', tasks: 'Tasks', gitkeys: 'Git Keys',
|
||||
connections: 'Connections',
|
||||
data: 'Data & Privacy', providers: 'My Providers', roles: 'Model Roles',
|
||||
usage: 'My Usage', knowledge: 'Knowledge Bases', memory: 'Memory',
|
||||
notifications: 'Notifications',
|
||||
general: 'General', appearance: 'Appearance',
|
||||
profile: 'Profile', teams: 'Teams',
|
||||
connections: 'Connections', notifications: 'Notifications',
|
||||
};
|
||||
|
||||
// ── v0.38.3: Extension config sections ──────
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* Settings > Knowledge — personal knowledge base management
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
function formatBytes(b) {
|
||||
if (!b) return '0 B';
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
|
||||
return (b / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
export default function KnowledgeSection() {
|
||||
const [kbs, setKbs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [docs, setDocs] = useState([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.knowledge.list();
|
||||
setKbs(resp.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function openDetail(kb) {
|
||||
setDetail(kb);
|
||||
try {
|
||||
const d = await sw.api.knowledge.documents(kb.id);
|
||||
setDocs(d.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function createKb(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const name = form.name.value.trim();
|
||||
if (!name) { sw.toast('Name is required', 'error'); return; }
|
||||
try {
|
||||
await sw.api.knowledge.create({
|
||||
name,
|
||||
description: form.description.value.trim(),
|
||||
});
|
||||
sw.toast('Knowledge base created', 'success');
|
||||
form.reset();
|
||||
setShowCreate(false);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteKb(id) {
|
||||
const ok = await sw.confirm('Delete this knowledge base and all its documents?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.knowledge.del(id);
|
||||
sw.toast('Knowledge base deleted', 'success');
|
||||
setDetail(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function uploadDoc() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.txt,.md,.csv,.html,.pdf';
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
await sw.api.knowledge.upload(detail.id, file);
|
||||
sw.toast('Document uploaded', 'success');
|
||||
const d = await sw.api.knowledge.documents(detail.id);
|
||||
setDocs(d.data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
async function deleteDoc(docId) {
|
||||
const ok = await sw.confirm('Delete this document?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.knowledge.delDoc(detail.id, docId);
|
||||
sw.toast('Document deleted', 'success');
|
||||
setDocs(prev => prev.filter(d => d.id !== docId));
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const cls = status === 'ready' ? 'badge-active' : status === 'error' ? 'badge-inactive' : 'badge';
|
||||
return html`<span class="badge ${cls}">${status}</span>`;
|
||||
}
|
||||
|
||||
const canCreate = sw.can('kb.create');
|
||||
const canWrite = sw.can('kb.write');
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
// Detail view
|
||||
if (detail) return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<button class="btn-small" onClick=${() => { setDetail(null); setDocs([]); }}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">${detail.name}</h4>
|
||||
${statusBadge(detail.status || 'active')}
|
||||
<div style="flex:1"></div>
|
||||
${canWrite && html`<button class="btn-small" onClick=${uploadDoc}>Upload Document</button>`}
|
||||
${canWrite && html`<button class="btn-small btn-danger" onClick=${() => deleteKb(detail.id)}>Delete KB</button>`}
|
||||
</div>
|
||||
|
||||
${detail.description && html`
|
||||
<p class="text-muted" style="font-size:13px;margin:0 0 8px;">${detail.description}</p>
|
||||
`}
|
||||
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">
|
||||
${detail.document_count || 0} docs \u2022 Created ${fmtDate(detail.created_at)}
|
||||
</p>
|
||||
|
||||
<div class="admin-list">
|
||||
${docs.length === 0 && html`<div class="empty-hint">No documents yet. Upload one to get started.</div>`}
|
||||
${docs.map(d => html`
|
||||
<div class="admin-surface-row" key=${d.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${d.filename}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:11px;">${formatBytes(d.size_bytes)}</span>
|
||||
${' '}${statusBadge(d.status || 'pending')}
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">${fmtDate(d.created_at)}</span>
|
||||
${canWrite && html`<button class="btn-small btn-danger" onClick=${() => deleteDoc(d.id)}>Delete</button>`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// List view
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<span class="text-muted" style="font-size:12px;">${kbs.length} knowledge base${kbs.length !== 1 ? 's' : ''}</span>
|
||||
<div style="flex:1"></div>
|
||||
${canCreate && html`
|
||||
<button class="btn-md btn-primary" onClick=${() => setShowCreate(!showCreate)}>
|
||||
${showCreate ? 'Cancel' : '+ Create'}
|
||||
</button>`}
|
||||
</div>
|
||||
|
||||
${showCreate && html`
|
||||
<form class="settings-section" style="margin-bottom:16px;" onSubmit=${createKb}>
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input name="name" placeholder="e.g. Product Documentation" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<input name="description" placeholder="Optional description" />
|
||||
</div>
|
||||
<button type="submit" class="btn-small btn-primary">Create Knowledge Base</button>
|
||||
</form>
|
||||
`}
|
||||
|
||||
<div class="admin-list">
|
||||
${kbs.length === 0 && html`<div class="empty-hint">No knowledge bases. Create one to get started.</div>`}
|
||||
${kbs.map(k => html`
|
||||
<div class="admin-surface-row" key=${k.id} style="cursor:pointer;" onClick=${() => openDetail(k)}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${k.name}</strong>
|
||||
<span class="text-muted" style="margin-left:8px;font-size:12px;">${k.document_count || 0} docs</span>
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">${fmtDate(k.created_at)}</span>
|
||||
${statusBadge(k.status || 'active')}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Settings > Memory — personal memory management
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
function truncate(s, len) {
|
||||
if (!s) return '';
|
||||
return s.length > len ? s.substring(0, len) + '\u2026' : s;
|
||||
}
|
||||
|
||||
export default function MemorySection() {
|
||||
const [memories, setMemories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [count, setCount] = useState(0);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [data, c] = await Promise.all([
|
||||
sw.api.memory.list(),
|
||||
sw.api.memory.count().catch(() => null),
|
||||
]);
|
||||
setMemories(data || []);
|
||||
if (c != null) setCount(typeof c === 'number' ? c : c.count || 0);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
function startEdit(m) {
|
||||
setEditingId(m.id);
|
||||
setEditValue(m.value || m.content || '');
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingId(null);
|
||||
setEditValue('');
|
||||
}
|
||||
|
||||
async function saveEdit(id) {
|
||||
try {
|
||||
await sw.api.memory.update(id, { value: editValue, content: editValue });
|
||||
sw.toast('Memory updated', 'success');
|
||||
setEditingId(null);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteMemory(id) {
|
||||
const ok = await sw.confirm('Delete this memory?', true);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.memory.del(id);
|
||||
sw.toast('Memory deleted', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
if (!status) return null;
|
||||
const cls = status === 'approved' ? 'badge-active'
|
||||
: status === 'rejected' ? 'badge-inactive'
|
||||
: 'badge';
|
||||
return html`<span class="badge ${cls}">${status}</span>`;
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<span class="text-muted" style="font-size:12px;">${count || memories.length} memor${count === 1 ? 'y' : 'ies'}</span>
|
||||
<div style="flex:1"></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
${memories.length === 0 && html`<div class="empty-hint">No memories stored.</div>`}
|
||||
${memories.map(m => html`
|
||||
<div class="admin-surface-row" key=${m.id} style="flex-direction:column;align-items:stretch;">
|
||||
${editingId === m.id ? html`
|
||||
<div>
|
||||
<textarea rows="3" style="width:100%;font-size:13px;margin-bottom:8px;"
|
||||
value=${editValue}
|
||||
onInput=${e => setEditValue(e.target.value)} />
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="btn-small btn-primary" onClick=${() => saveEdit(m.id)}>Save</button>
|
||||
<button class="btn-small" onClick=${cancelEdit}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
` : html`
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
${m.key && html`<strong style="margin-right:6px;">${m.key}</strong>`}
|
||||
<span style="font-size:13px;color:var(--text-2);">${truncate(m.value || m.content || '', 120)}</span>
|
||||
</div>
|
||||
${statusBadge(m.status)}
|
||||
<span class="text-muted" style="font-size:11px;white-space:nowrap;">${fmtDate(m.created_at)}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-top:6px;">
|
||||
<button class="btn-small" onClick=${() => startEdit(m)}>Edit</button>
|
||||
<button class="btn-small btn-danger" onClick=${() => deleteMemory(m.id)}>Delete</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* ModelsSection — user model list with visibility toggle
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s); }
|
||||
|
||||
export function ModelsSection() {
|
||||
const [models, setModels] = useState(null);
|
||||
const [prefs, setPrefs] = useState({});
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [provFilter, setProvFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [m, p] = await Promise.all([
|
||||
sw.api.models.enabled(),
|
||||
sw.api.models.preferences(),
|
||||
]);
|
||||
setModels(m.data || []);
|
||||
// Build prefs map: model_id → { hidden }
|
||||
const map = {};
|
||||
(p || []).forEach(x => { map[x.model_id] = x; });
|
||||
setPrefs(map);
|
||||
} catch (e) {
|
||||
console.warn('[settings/models]', e.message);
|
||||
setModels([]);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const toggleHidden = useCallback(async (model) => {
|
||||
const current = prefs[model.id]?.hidden || false;
|
||||
try {
|
||||
await sw.api.models.setPref(model.id, model.provider_config_id, !current);
|
||||
setPrefs(p => ({ ...p, [model.id]: { ...p[model.id], hidden: !current } }));
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
}
|
||||
}, [prefs]);
|
||||
|
||||
if (models === null) {
|
||||
return html`<div class="settings-placeholder">Loading models\u2026</div>`;
|
||||
}
|
||||
|
||||
// Derive filter options
|
||||
const types = [...new Set(models.map(m => m.model_type).filter(Boolean))];
|
||||
const providers = [...new Set(models.map(m => m.provider_name || m.provider).filter(Boolean))];
|
||||
|
||||
const filtered = models.filter(m => {
|
||||
const name = (m.display_name || m.model_id || '').toLowerCase();
|
||||
if (search && !name.includes(search.toLowerCase())) return false;
|
||||
if (typeFilter && m.model_type !== typeFilter) return false;
|
||||
if (provFilter && (m.provider_name || m.provider) !== provFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return html`
|
||||
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;">
|
||||
<input type="text" placeholder="Search models\u2026"
|
||||
style="flex:1;min-width:140px;"
|
||||
value=${search} onInput=${e => setSearch(e.target.value)} />
|
||||
${types.length > 1 && html`
|
||||
<select value=${typeFilter} onChange=${e => setTypeFilter(e.target.value)}>
|
||||
<option value="">All types</option>
|
||||
${types.map(t => html`<option value=${t}>${t}</option>`)}
|
||||
</select>
|
||||
`}
|
||||
${providers.length > 1 && html`
|
||||
<select value=${provFilter} onChange=${e => setProvFilter(e.target.value)}>
|
||||
<option value="">All providers</option>
|
||||
${providers.map(p => html`<option value=${p}>${p}</option>`)}
|
||||
</select>
|
||||
`}
|
||||
</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr><th>Model</th><th>Type</th><th>Provider</th><th style="width:60px;">Visible</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${filtered.map(m => {
|
||||
const hidden = prefs[m.id]?.hidden || false;
|
||||
return html`
|
||||
<tr style=${hidden ? 'opacity:0.5' : ''}>
|
||||
<td style="font-weight:500;font-size:13px;">
|
||||
${esc(m.display_name || m.model_id)}
|
||||
</td>
|
||||
<td style="font-size:12px;color:var(--text-2);">
|
||||
${esc(m.model_type)}
|
||||
</td>
|
||||
<td style="font-size:12px;color:var(--text-2);">
|
||||
${esc(m.provider_name || m.provider)}
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" checked=${!hidden}
|
||||
onChange=${() => toggleHidden(m)} />
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="font-size:12px;color:var(--text-3);margin-top:8px;">
|
||||
${filtered.length} model${filtered.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* PersonasSection — user persona list + create/edit
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s); }
|
||||
|
||||
export function PersonasSection() {
|
||||
const [personas, setPersonas] = useState(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [form, setForm] = useState({ name: '', description: '', system_prompt: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.personas.list();
|
||||
setPersonas(resp || []);
|
||||
} catch (e) {
|
||||
setPersonas([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openAdd = useCallback(() => {
|
||||
setEditId(null);
|
||||
setForm({ name: '', description: '', system_prompt: '' });
|
||||
setShowForm(true);
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback((p) => {
|
||||
setEditId(p.id);
|
||||
setForm({ name: p.name || '', description: p.description || '', system_prompt: p.system_prompt || '' });
|
||||
setShowForm(true);
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setShowForm(false);
|
||||
setEditId(null);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!form.name.trim()) {
|
||||
sw.emit('toast', { message: 'Name is required', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editId) {
|
||||
await sw.api.personas.update(editId, form);
|
||||
sw.emit('toast', { message: 'Persona updated', variant: 'success' });
|
||||
} else {
|
||||
await sw.api.personas.create(form);
|
||||
sw.emit('toast', { message: 'Persona created', variant: 'success' });
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditId(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, editId, load]);
|
||||
|
||||
const del = useCallback(async (p) => {
|
||||
const ok = await sw.confirm(`Delete persona "${p.name}"?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.personas.del(p.id);
|
||||
sw.emit('toast', { message: 'Persona deleted', variant: 'success' });
|
||||
load();
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
const setField = useCallback((key, val) => {
|
||||
setForm(f => ({ ...f, [key]: val }));
|
||||
}, []);
|
||||
|
||||
if (personas === null) {
|
||||
return html`<div class="settings-placeholder">Loading personas\u2026</div>`;
|
||||
}
|
||||
|
||||
const canCreate = sw.can('persona.create');
|
||||
const canManage = sw.can('persona.manage');
|
||||
|
||||
return html`
|
||||
${canCreate && html`
|
||||
<div style="margin-bottom:12px;">
|
||||
<button class="btn-md btn-primary" onClick=${openAdd}>+ New Persona</button>
|
||||
</div>`}
|
||||
|
||||
${showForm && html`
|
||||
<div class="settings-section" style="margin-bottom:16px;">
|
||||
<h3>${editId ? 'Edit Persona' : 'New Persona'}</h3>
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" value=${form.name}
|
||||
onInput=${e => setField('name', e.target.value)} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<input type="text" value=${form.description}
|
||||
onInput=${e => setField('description', e.target.value)} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>System Prompt</label>
|
||||
<textarea rows="4" value=${form.system_prompt}
|
||||
onInput=${e => setField('system_prompt', e.target.value)}
|
||||
style="max-width:100%;" />
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||
<button class="btn-md btn-primary" disabled=${saving}
|
||||
onClick=${save}>
|
||||
${saving ? 'Saving\u2026' : editId ? 'Update' : 'Create'}
|
||||
</button>
|
||||
<button class="btn-md btn-secondary" onClick=${cancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${!personas.length && !showForm && html`
|
||||
<div class="empty-hint">No personas configured.</div>
|
||||
`}
|
||||
|
||||
${personas.length > 0 && html`
|
||||
<div>
|
||||
${personas.map(p => html`
|
||||
<div class="settings-section" style="margin-bottom:12px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<div>
|
||||
<strong style="font-size:14px;">${esc(p.name)}</strong>
|
||||
${p.description && html`
|
||||
<div style="font-size:12px;color:var(--text-2);margin-top:2px;">
|
||||
${esc(p.description)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
${canManage && html`
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="icon-btn" title="Edit" onClick=${() => openEdit(p)}>
|
||||
\u{270F}\u{FE0F}
|
||||
</button>
|
||||
<button class="icon-btn icon-btn-danger" title="Delete"
|
||||
onClick=${() => del(p)}>
|
||||
\u{1F5D1}
|
||||
</button>
|
||||
</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* ProvidersSection — BYOK provider CRUD (user-scoped)
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s); }
|
||||
|
||||
export function ProvidersSection() {
|
||||
const [providers, setProviders] = useState(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [form, setForm] = useState({ name: '', provider: 'openai', endpoint: '', api_key: '', model_default: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.providers.list();
|
||||
setProviders(resp || []);
|
||||
} catch (e) {
|
||||
setProviders([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openAdd = useCallback(() => {
|
||||
setEditId(null);
|
||||
setForm({ name: '', provider: 'openai', endpoint: '', api_key: '', model_default: '' });
|
||||
setShowForm(true);
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback(async (prov) => {
|
||||
try {
|
||||
const cfg = await sw.api.providers.get(prov.id);
|
||||
setEditId(cfg.id);
|
||||
setForm({
|
||||
name: cfg.name || '',
|
||||
provider: cfg.provider || 'openai',
|
||||
endpoint: cfg.endpoint || '',
|
||||
api_key: '',
|
||||
model_default: cfg.model_default || '',
|
||||
});
|
||||
setShowForm(true);
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setShowForm(false);
|
||||
setEditId(null);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!form.name || !form.endpoint) {
|
||||
sw.emit('toast', { message: 'Name and endpoint are required', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
if (!editId && !form.api_key) {
|
||||
sw.emit('toast', { message: 'API key is required', variant: 'error' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editId) {
|
||||
const patch = { name: form.name, provider: form.provider, endpoint: form.endpoint, model_default: form.model_default };
|
||||
if (form.api_key) patch.api_key = form.api_key;
|
||||
await sw.api.providers.update(editId, patch);
|
||||
sw.emit('toast', { message: 'Provider updated', variant: 'success' });
|
||||
} else {
|
||||
const body = { name: form.name, provider: form.provider, endpoint: form.endpoint, api_key: form.api_key, model_default: form.model_default };
|
||||
const result = await sw.api.providers.create(body);
|
||||
const count = result?.models_fetched || 0;
|
||||
sw.emit('toast', {
|
||||
message: result?.warning || `Provider added \u2014 ${count} model${count !== 1 ? 's' : ''} synced`,
|
||||
variant: result?.warning ? 'warning' : 'success',
|
||||
});
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditId(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, editId, load]);
|
||||
|
||||
const del = useCallback(async (prov) => {
|
||||
const ok = await sw.confirm(`Remove provider "${prov.name}"?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await sw.api.providers.del(prov.id);
|
||||
sw.emit('toast', { message: 'Provider removed', variant: 'success' });
|
||||
load();
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
const sync = useCallback(async (prov) => {
|
||||
sw.emit('toast', { message: `Fetching models for ${prov.name}\u2026`, variant: 'info' });
|
||||
try {
|
||||
const result = await sw.api.providers.fetchModels(prov.id);
|
||||
const total = result?.total || 0;
|
||||
sw.emit('toast', { message: `${prov.name}: ${total} model${total !== 1 ? 's' : ''} synced`, variant: 'success' });
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const testConnection = useCallback(async (prov) => {
|
||||
sw.emit('toast', { message: `Testing ${prov.name}\u2026`, variant: 'info' });
|
||||
try {
|
||||
const result = await sw.api.providers.fetchModels(prov.id);
|
||||
const total = result?.total || 0;
|
||||
sw.emit('toast', { message: `${prov.name}: connection OK (${total} model${total !== 1 ? 's' : ''})`, variant: 'success' });
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: `${prov.name}: connection failed \u2014 ${e.message}`, variant: 'error' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setField = useCallback((key, val) => {
|
||||
setForm(f => ({ ...f, [key]: val }));
|
||||
}, []);
|
||||
|
||||
if (providers === null) {
|
||||
return html`<div class="settings-placeholder">Loading providers\u2026</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div style="margin-bottom:12px;">
|
||||
<button class="btn-md btn-primary" onClick=${openAdd}>+ Add Provider</button>
|
||||
</div>
|
||||
|
||||
${showForm && html`
|
||||
<div class="settings-section" style="margin-bottom:16px;">
|
||||
<h3>${editId ? 'Edit Provider' : 'Add Provider'}</h3>
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" value=${form.name}
|
||||
onInput=${e => setField('name', e.target.value)} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Provider Type</label>
|
||||
<select value=${form.provider}
|
||||
onChange=${e => setField('provider', e.target.value)}>
|
||||
<option value="openai">OpenAI-compatible</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
<option value="venice">Venice AI</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Endpoint URL</label>
|
||||
<input type="text" placeholder="https://api.openai.com/v1"
|
||||
value=${form.endpoint}
|
||||
onInput=${e => setField('endpoint', e.target.value)} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API Key${editId ? ' (leave blank to keep current)' : ''}</label>
|
||||
<input type="password" placeholder="sk-..."
|
||||
value=${form.api_key}
|
||||
onInput=${e => setField('api_key', e.target.value)} />
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:12px;">
|
||||
<button class="btn-md btn-primary" disabled=${saving}
|
||||
onClick=${save}>
|
||||
${saving ? 'Saving\u2026' : editId ? 'Update' : 'Add'}
|
||||
</button>
|
||||
<button class="btn-md btn-secondary" onClick=${cancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${!providers.length && !showForm && html`
|
||||
<div class="empty-hint">No providers configured.</div>
|
||||
`}
|
||||
|
||||
${providers.length > 0 && html`
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Provider</th><th>Type</th><th>Endpoint</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
${providers.map(p => html`
|
||||
<tr>
|
||||
<td style="font-weight:500;font-size:13px;">${esc(p.name)}</td>
|
||||
<td style="font-size:12px;color:var(--text-2);">${esc(p.provider)}</td>
|
||||
<td style="font-size:12px;color:var(--text-3);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||
${esc(p.endpoint)}
|
||||
</td>
|
||||
<td class="admin-actions-cell" style="white-space:nowrap;">
|
||||
<button class="icon-btn" title="Test connection" onClick=${() => testConnection(p)}>
|
||||
\u{26A1}
|
||||
</button>
|
||||
<button class="icon-btn" title="Sync models" onClick=${() => sync(p)}>
|
||||
\u{1F504}
|
||||
</button>
|
||||
<button class="icon-btn" title="Edit" onClick=${() => openEdit(p)}>
|
||||
\u{270F}\u{FE0F}
|
||||
</button>
|
||||
<button class="icon-btn icon-btn-danger" title="Delete"
|
||||
onClick=${() => del(p)}>
|
||||
\u{1F5D1}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* RolesSection — user model role overrides (requires BYOK providers)
|
||||
*
|
||||
* Each "role" (primary, fallback) can be overridden to use a personal
|
||||
* provider + model instead of the org default.
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s); }
|
||||
|
||||
const ROLE_NAMES = ['primary', 'fallback'];
|
||||
|
||||
export function RolesSection() {
|
||||
const [providers, setProviders] = useState([]);
|
||||
const [models, setModels] = useState([]);
|
||||
const [roles, setRoles] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [provResp, modResp, settings] = await Promise.all([
|
||||
sw.api.providers.list(),
|
||||
sw.api.models.enabled(),
|
||||
sw.api.profile.settings(),
|
||||
]);
|
||||
const provs = (provResp || [])
|
||||
.filter(c => c.scope === 'personal');
|
||||
setProviders(provs);
|
||||
setModels(modResp.data || []);
|
||||
|
||||
// Extract role overrides from settings
|
||||
const overrides = settings?.model_roles || {};
|
||||
setRoles(overrides);
|
||||
} catch (e) {
|
||||
console.warn('[settings/roles]', e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const setRoleProvider = useCallback((role, provId) => {
|
||||
setRoles(r => ({
|
||||
...r,
|
||||
[role]: { ...(r[role] || {}), provider_config_id: provId, model_id: '' },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setRoleModel = useCallback((role, modelId) => {
|
||||
setRoles(r => ({
|
||||
...r,
|
||||
[role]: { ...(r[role] || {}), model_id: modelId },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await sw.api.profile.updateSettings({ model_roles: roles });
|
||||
sw.emit('toast', { message: 'Roles saved', variant: 'success' });
|
||||
} catch (e) {
|
||||
sw.emit('toast', { message: e.message, variant: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [roles]);
|
||||
|
||||
if (loading) {
|
||||
return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
}
|
||||
|
||||
if (!providers.length) {
|
||||
return html`
|
||||
<div class="settings-section">
|
||||
<p style="color:var(--text-2);font-size:13px;">
|
||||
Add a personal provider first to configure model role overrides.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
${ROLE_NAMES.map(role => {
|
||||
const r = roles[role] || {};
|
||||
const provId = r.provider_config_id || '';
|
||||
const modelId = r.model_id || '';
|
||||
const filteredModels = provId
|
||||
? models.filter(m => m.provider_config_id === provId)
|
||||
: [];
|
||||
return html`
|
||||
<div class="settings-section" style="margin-bottom:16px;">
|
||||
<h3 style="text-transform:capitalize;">${role}</h3>
|
||||
<div style="display:flex;gap:12px;">
|
||||
<div class="form-group" style="flex:1;">
|
||||
<label>Provider</label>
|
||||
<select value=${provId}
|
||||
onChange=${e => setRoleProvider(role, e.target.value)}>
|
||||
<option value="">\u2014 use org default \u2014</option>
|
||||
${providers.map(p => html`
|
||||
<option value=${p.id}>${esc(p.name)}</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="flex:1;">
|
||||
<label>Model</label>
|
||||
<select value=${modelId} disabled=${!provId}
|
||||
onChange=${e => setRoleModel(role, e.target.value)}>
|
||||
<option value="">\u2014 select model \u2014</option>
|
||||
${filteredModels.map(m => html`
|
||||
<option value=${m.model_id || m.id}>
|
||||
${esc(m.display_name || m.model_id || m.id)}
|
||||
</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
<button class="btn-md btn-primary" disabled=${saving} onClick=${save}>
|
||||
${saving ? 'Saving\u2026' : 'Save Roles'}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* Settings > Tasks — user's tasks with run/stop controls
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '\u2014';
|
||||
return new Date(d).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
export default function TasksSection() {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.tasks.list();
|
||||
setTasks(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function runTask(id) {
|
||||
try {
|
||||
await sw.api.tasks.start(id);
|
||||
sw.toast('Task started', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function stopTask(id) {
|
||||
try {
|
||||
await sw.api.tasks.stop(id);
|
||||
sw.toast('Task stopped', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<div class="admin-list">
|
||||
${tasks.length === 0 && html`<div class="empty-hint">No tasks.</div>`}
|
||||
${tasks.map(t => html`
|
||||
<div class="admin-surface-row" key=${t.id}>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${t.name}</strong>
|
||||
${t.schedule && html`
|
||||
<span class="badge" style="margin-left:6px;">${t.schedule}</span>
|
||||
`}
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:11px;">
|
||||
last: ${fmtDate(t.last_run_at)} \u2022 next: ${fmtDate(t.next_run_at)}
|
||||
</span>
|
||||
<span class="badge ${t.is_active ? 'badge-active' : 'badge-inactive'}">
|
||||
${t.is_active ? 'running' : 'stopped'}
|
||||
</span>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="btn-small btn-primary" onClick=${() => runTask(t.id)}>Run</button>
|
||||
<button class="btn-small btn-ghost" onClick=${() => stopTask(t.id)}>Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* UsageSection — personal usage statistics
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect } = hooks;
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s); }
|
||||
function fmt(n) { return n == null ? '0' : n.toLocaleString(); }
|
||||
|
||||
export function UsageSection() {
|
||||
const [usage, setUsage] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Use the generic API escape hatch since there's no dedicated namespace
|
||||
sw.api.get('/api/v1/profile/usage').then(data => {
|
||||
setUsage(data);
|
||||
}).catch(() => setUsage({}));
|
||||
}, []);
|
||||
|
||||
if (usage === null) {
|
||||
return html`<div class="settings-placeholder">Loading usage\u2026</div>`;
|
||||
}
|
||||
|
||||
const totals = usage.totals || usage;
|
||||
const byModel = usage.by_model || [];
|
||||
|
||||
return html`
|
||||
<div class="settings-section">
|
||||
<h3>Totals</h3>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;">
|
||||
${[
|
||||
['Requests', totals.request_count],
|
||||
['Input Tokens', totals.input_tokens],
|
||||
['Output Tokens', totals.output_tokens],
|
||||
['Total Tokens', totals.total_tokens],
|
||||
].map(([label, val]) => html`
|
||||
<div style="background:var(--bg-raised);border-radius:8px;padding:14px;">
|
||||
<div style="font-size:11px;color:var(--text-3);margin-bottom:4px;">${label}</div>
|
||||
<div style="font-size:18px;font-weight:600;">${fmt(val)}</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${byModel.length > 0 && html`
|
||||
<div class="settings-section" style="margin-top:16px;">
|
||||
<h3>By Model</h3>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr><th>Model</th><th>Requests</th><th>Tokens</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${byModel.map(m => html`
|
||||
<tr>
|
||||
<td style="font-size:13px;">${esc(m.model_id || m.model)}</td>
|
||||
<td style="font-size:12px;">${fmt(m.request_count)}</td>
|
||||
<td style="font-size:12px;">${fmt(m.total_tokens)}</td>
|
||||
</tr>
|
||||
`)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* Settings > Workflows — v0.37.15 rewrite
|
||||
*
|
||||
* Replaces read-only workflow list with MyAssignments (E4).
|
||||
* Shows the user's personal assignment queue across all teams.
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
function _timeAgo(ts) {
|
||||
if (!ts) return '';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
export default function WorkflowsSection() {
|
||||
const [assignments, setAssignments] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await sw.api.workflowAssignments.mine();
|
||||
setAssignments(data || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const userId = sw.auth?.user?.id;
|
||||
const mine = assignments.filter(a => a.status === 'claimed' && a.assigned_to === userId);
|
||||
const available = assignments.filter(a => a.status === 'unassigned');
|
||||
|
||||
async function claim(id) {
|
||||
try {
|
||||
await sw.api.workflowAssignments.claim(id);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function unclaim(id) {
|
||||
try {
|
||||
await sw.api.workflowAssignments.unclaim(id);
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function complete(id) {
|
||||
try {
|
||||
await sw.api.workflowAssignments.complete(id);
|
||||
sw.toast('Assignment completed', 'success');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
${mine.length > 0 && html`
|
||||
<h4 style="margin:0 0 8px;">Claimed (${mine.length})</h4>
|
||||
<div class="admin-list">
|
||||
${mine.map(a => html`
|
||||
<${AssignmentRow} key=${a.id} assignment=${a} onAction=${load} showTeam />
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${available.length > 0 && html`
|
||||
<h4 style="margin:${mine.length > 0 ? '16px' : '0'} 0 8px;">Available (${available.length})</h4>
|
||||
<div class="admin-list">
|
||||
${available.map(a => html`
|
||||
<${AssignmentRow} key=${a.id} assignment=${a} onAction=${load} showTeam />
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${mine.length === 0 && available.length === 0 && html`
|
||||
<div class="empty-hint">No assignments</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function AssignmentRow({ assignment: a, onAction, showTeam }) {
|
||||
const userId = sw.auth?.user?.id;
|
||||
const isMine = a.assigned_to === userId;
|
||||
|
||||
async function claim() {
|
||||
try {
|
||||
await sw.api.workflowAssignments.claim(a.id);
|
||||
onAction();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function unclaim() {
|
||||
try {
|
||||
await sw.api.workflowAssignments.unclaim(a.id);
|
||||
onAction();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function complete() {
|
||||
try {
|
||||
await sw.api.workflowAssignments.complete(a.id);
|
||||
sw.toast('Assignment completed', 'success');
|
||||
onAction();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="admin-surface-row" style="${isMine ? 'border-left:3px solid var(--accent-1);' : ''}">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<strong>${a.workflow_name || 'Workflow'}</strong>
|
||||
${showTeam && a.team_name && html`<span class="badge">${a.team_name}</span>`}
|
||||
<span class="badge">${a.stage_name || `Stage ${a.stage}`}</span>
|
||||
${a.sla_breached && html`<span class="badge badge-danger">SLA</span>`}
|
||||
<span class="text-muted" style="font-size:11px;">${_timeAgo(a.created_at)}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;">
|
||||
${a.status === 'unassigned' && html`
|
||||
<button class="btn-small btn-primary" onClick=${claim}>Claim</button>
|
||||
`}
|
||||
${isMine && html`
|
||||
<button class="btn-small" onClick=${unclaim}>Release</button>
|
||||
<button class="btn-small btn-primary"
|
||||
onClick=${() => { location.href = sw.base + '/w/' + a.channel_id; }}>Open</button>
|
||||
<button class="btn-small btn-success" onClick=${complete}>Complete</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -19,30 +19,20 @@ import { DialogStack } from '../../shell/dialog-stack.js';
|
||||
// ── Section config ──────────────────────────
|
||||
const SECTIONS = [
|
||||
{ key: 'members', label: 'Members' },
|
||||
{ key: 'providers', label: 'Providers' },
|
||||
{ key: 'connections', label: 'Connections' },
|
||||
{ key: 'personas', label: 'Personas' },
|
||||
{ key: 'knowledge', label: 'Knowledge' },
|
||||
{ key: 'groups', label: 'Groups' },
|
||||
{ key: 'connections', label: 'Connections' },
|
||||
{ key: 'workflows', label: 'Workflows' },
|
||||
{ key: 'tasks', label: 'Tasks' },
|
||||
{ key: 'settings', label: 'Settings' },
|
||||
{ key: 'usage', label: 'Usage' },
|
||||
{ key: 'activity', label: 'Activity' },
|
||||
];
|
||||
|
||||
// ── Lazy section imports ────────────────────
|
||||
const sectionModules = {
|
||||
members: () => import('./members.js'),
|
||||
providers: () => import('./providers.js'),
|
||||
connections: () => import('./connections.js'),
|
||||
personas: () => import('./personas.js'),
|
||||
knowledge: () => import('./knowledge.js'),
|
||||
groups: () => import('./groups.js'),
|
||||
connections: () => import('./connections.js'),
|
||||
workflows: () => import('./workflows.js'),
|
||||
tasks: () => import('./tasks.js'),
|
||||
settings: () => import('./settings.js'),
|
||||
usage: () => import('./usage.js'),
|
||||
activity: () => import('./activity.js'),
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user