Changeset 0.37.10 (#222)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-21 23:39:23 +00:00
committed by xcaliber
parent 37b639c9c8
commit 8d8a118232
52 changed files with 3209 additions and 13811 deletions

View File

@@ -1,8 +1,15 @@
const { describe, it } = require('node:test');
const assert = require('node:assert');
const { createBrowserContext, loadSource } = require('./helpers');
const fs = require('fs');
const path = require('path');
const { createBrowserContext, loadSource, SRC } = require('./helpers');
const vm = require('vm');
// v0.37.10: extensions.js deleted — extension system moves to Preact in v0.37.12.
// Skip all tests if the source file doesn't exist.
const EXTENSIONS_EXISTS = fs.existsSync(path.join(SRC, 'extensions.js'));
const maybeDescribe = EXTENSIONS_EXISTS ? describe : describe.skip;
/**
* Load Events + Extensions into a browser context.
*/
@@ -23,7 +30,7 @@ function loadExtensions(overrides = {}) {
// KaTeX Renderer Extension
// ═══════════════════════════════════════════
describe('KaTeX renderer extension', () => {
maybeDescribe('KaTeX renderer extension', () => {
function loadWithKaTeX() {
const ctx = loadExtensions();
ctx.Extensions.register({
@@ -133,7 +140,7 @@ describe('KaTeX renderer extension', () => {
// CSV Table Extension
// ═══════════════════════════════════════════
describe('CSV Table extension', () => {
maybeDescribe('CSV Table extension', () => {
function loadWithCSV() {
const ctx = loadExtensions();
ctx.Extensions.register({
@@ -296,7 +303,7 @@ describe('CSV Table extension', () => {
// Diff Viewer Extension
// ═══════════════════════════════════════════
describe('Diff Viewer extension', () => {
maybeDescribe('Diff Viewer extension', () => {
function loadWithDiff() {
const ctx = loadExtensions();
ctx.Extensions.register({
@@ -427,7 +434,7 @@ describe('Diff Viewer extension', () => {
// JS Sandbox Extension (Tool)
// ═══════════════════════════════════════════
describe('JS Sandbox extension', () => {
maybeDescribe('JS Sandbox extension', () => {
it('registers js_eval tool handler', async () => {
const ctx = loadExtensions();
ctx.Extensions.register({
@@ -484,7 +491,7 @@ describe('JS Sandbox extension', () => {
// Regex Tester Extension (Tool)
// ═══════════════════════════════════════════
describe('Regex Tester extension', () => {
maybeDescribe('Regex Tester extension', () => {
function loadWithRegex() {
const ctx = loadExtensions();
ctx.Extensions.register({

View File

@@ -1,8 +1,15 @@
const { describe, it } = require('node:test');
const assert = require('node:assert');
const { createBrowserContext, loadSource } = require('./helpers');
const fs = require('fs');
const path = require('path');
const { createBrowserContext, loadSource, SRC } = require('./helpers');
const vm = require('vm');
// v0.37.10: extensions.js deleted — extension system moves to Preact in v0.37.12.
// Skip all tests if the source file doesn't exist.
const EXTENSIONS_EXISTS = fs.existsSync(path.join(SRC, 'extensions.js'));
const maybeDescribe = EXTENSIONS_EXISTS ? describe : describe.skip;
/**
* Load Events + Extensions into a browser context.
* Returns an object with Events, Extensions, and the sandbox.
@@ -25,7 +32,7 @@ function loadExtensions(overrides = {}) {
// ── Registration ─────────────────────────────
describe('Extensions.register', () => {
maybeDescribe('Extensions.register', () => {
it('registers an extension by id', () => {
const ctx = loadExtensions();
ctx.Extensions.register({ id: 'test-ext', init() {} });
@@ -48,7 +55,7 @@ describe('Extensions.register', () => {
// ── Lifecycle ────────────────────────────────
describe('Extensions.initAll', () => {
maybeDescribe('Extensions.initAll', () => {
it('calls init on registered extensions', async () => {
const ctx = loadExtensions();
let initialized = false;
@@ -84,7 +91,7 @@ describe('Extensions.initAll', () => {
});
});
describe('Extensions.destroyAll', () => {
maybeDescribe('Extensions.destroyAll', () => {
it('calls destroy and deactivates extensions', async () => {
const ctx = loadExtensions();
let destroyed = false;
@@ -102,7 +109,7 @@ describe('Extensions.destroyAll', () => {
// ── Context Object ──────────────────────────
describe('Extension context', () => {
maybeDescribe('Extension context', () => {
it('provides scoped events', async () => {
const ctx = loadExtensions();
let receivedPayload = null;
@@ -159,7 +166,7 @@ describe('Extension context', () => {
// ── Tool Registration ───────────────────────
describe('ctx.tools.handle', () => {
maybeDescribe('ctx.tools.handle', () => {
it('registers a tool handler', async () => {
const ctx = loadExtensions();
ctx.Extensions.register({
@@ -177,7 +184,7 @@ describe('ctx.tools.handle', () => {
// ── Tool Bridge ─────────────────────────────
describe('Tool bridge', () => {
maybeDescribe('Tool bridge', () => {
it('routes tool.call.* to registered handler and emits result', async () => {
const ctx = loadExtensions();
let resultEmitted = null;
@@ -269,7 +276,7 @@ describe('Tool bridge', () => {
// ── Renderer Pipeline ───────────────────────
describe('Renderer pipeline', () => {
maybeDescribe('Renderer pipeline', () => {
it('registers and runs block renderers', async () => {
const ctx = loadExtensions();
let rendered = false;
@@ -356,7 +363,7 @@ describe('Renderer pipeline', () => {
// ── Debug ────────────────────────────────────
describe('Extensions.debug', () => {
maybeDescribe('Extensions.debug', () => {
it('returns extension info and renderer count', async () => {
const ctx = loadExtensions();
ctx.Extensions.register({
@@ -376,7 +383,7 @@ describe('Extensions.debug', () => {
// ── Mermaid Extension ─────────────────────────────
describe('Mermaid renderer extension', () => {
maybeDescribe('Mermaid renderer extension', () => {
function loadWithMermaid() {
const ctx = loadExtensions();
// Load the mermaid extension script into the same VM context

View File

@@ -4,6 +4,8 @@
// Loads the vanilla JS source files into a
// simulated browser environment so tests run
// against the ACTUAL frontend code.
//
// v0.37.10: Removed loadAppModules (api.js + app.js deleted).
// ==========================================
const fs = require('fs');
@@ -104,23 +106,24 @@ function createBrowserContext(overrides = {}) {
* Returns the sandbox for inspection.
*/
function loadSource(sandbox, filename) {
const code = fs.readFileSync(path.join(SRC, filename), 'utf-8');
const filepath = path.join(SRC, filename);
if (!fs.existsSync(filepath)) {
throw new Error(`Source file not found: ${filename} (deleted in v0.37.10?)`);
}
const code = fs.readFileSync(filepath, 'utf-8');
const ctx = vm.createContext(sandbox);
vm.runInContext(code, ctx, { filename });
return sandbox;
}
/**
* Loads API + App modules into a fresh context.
* Returns { API, App, sandbox }.
* Safely read a source file, returning empty string if deleted.
* For tests that audit source code content.
*/
function loadAppModules(overrides = {}) {
const sandbox = createBrowserContext(overrides);
loadSource(sandbox, 'sb.js');
loadSource(sandbox, 'app-state.js');
loadSource(sandbox, 'api.js');
loadSource(sandbox, 'app.js');
return { API: sandbox.API, App: sandbox.App, sandbox };
function readSourceSafe(filename) {
const filepath = path.join(SRC, filename);
if (!fs.existsSync(filepath)) return '';
return fs.readFileSync(filepath, 'utf-8');
}
/**
@@ -158,7 +161,7 @@ function processModelsResponse(data, hiddenModels = new Set()) {
module.exports = {
createBrowserContext,
loadSource,
loadAppModules,
readSourceSafe,
processModelsResponse,
SRC,
};

View File

@@ -7,9 +7,12 @@
// policy exists but the frontend doesn't
// check it.
//
// v0.22.5: Updated for server-rendered Go
// templates. HTML is now in server/pages/
// templates/ — not in src/index.html.
// v0.22.5: Updated for server-rendered Go templates.
// v0.37.5: Settings surface moved to Preact — legacy SPA tests
// replaced with component source audits.
// v0.37.10: Legacy SPA source audit removed (ui-core.js, app.js,
// pages.js, settings-handlers.js, ui-admin.js all deleted).
// Policy gating now verified via Preact surfaces + admin templates.
//
// Run: node --test src/js/__tests__/policy-gating.test.js
// ==========================================
@@ -36,94 +39,6 @@ function readAllTemplates() {
return files.join('\n');
}
// ── Source code audits ───────────────────────
// These tests read the actual source files and verify that required
// wiring exists. If someone removes a policy check, CI breaks.
describe('Policy wiring audit — source code', () => {
// Read all UI files (ui-core.js + ui-settings.js + ui-admin.js replace old ui.js)
const uiSrc = ['ui-core.js', 'ui-settings.js', 'ui-admin.js', 'ui-format.js']
.map(f => fs.readFileSync(path.join(SRC, f), 'utf-8')).join('\n');
// Read all app-side files (app.js + extracted handler files replace old monolith app.js)
const appSrc = ['app.js', 'settings-handlers.js', 'admin-handlers.js', 'chat.js', 'tokens.js', 'notes.js']
.map(f => fs.readFileSync(path.join(SRC, f), 'utf-8')).join('\n');
// Pages.js — server-rendered page handlers (v0.22.5+)
const pagesSrc = fs.readFileSync(path.join(SRC, 'pages.js'), 'utf-8');
const templateSrc = readAllTemplates();
// ── allow_user_byok ──
it('UI has checkUserProvidersAllowed function', () => {
assert.ok(uiSrc.includes('checkUserProvidersAllowed'),
'MISSING: checkUserProvidersAllowed — provider tab will show when policy is off');
});
it('checkUserProvidersAllowed reads App.policies.allow_user_byok', () => {
assert.ok(uiSrc.includes('allow_user_byok'),
'MISSING: allow_user_byok check in UI');
});
// ── allow_user_personas ──
it('UI has checkUserPersonasAllowed function', () => {
assert.ok(uiSrc.includes('checkUserPersonasAllowed'),
'MISSING: checkUserPersonasAllowed — persona button will show when policy is off');
});
it('checkUserPersonasAllowed reads App.policies.allow_user_personas', () => {
assert.ok(uiSrc.includes('allow_user_personas'),
'MISSING: allow_user_personas check in UI');
});
it('admin settings template has user-personas toggle', () => {
assert.ok(templateSrc.includes('settUserPersonas'),
'MISSING: settUserPersonas toggle in admin settings template');
});
it('Pages.saveSettings writes allow_user_personas', () => {
assert.ok(pagesSrc.includes('allow_user_personas'),
'MISSING: allow_user_personas in Pages.saveSettings');
});
it('admin settings save writes allow_user_personas (SPA bridge)', () => {
assert.ok(appSrc.includes('allow_user_personas'),
'MISSING: handleSaveAdminSettings must write allow_user_personas');
});
// ── Settings live-apply ──
it('admin save calls initBanners for policy refresh', () => {
assert.ok(appSrc.includes('await initBanners()'),
'MISSING: initBanners call after admin save — policies won\'t refresh');
});
it('admin save calls checkUserProvidersAllowed', () => {
// Find in handleSaveAdminSettings
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
assert.ok(saveFunc.includes('checkUserProvidersAllowed'),
'MISSING: checkUserProvidersAllowed call after admin save');
});
it('admin save calls checkUserPersonasAllowed', () => {
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
assert.ok(saveFunc.includes('checkUserPersonasAllowed'),
'MISSING: checkUserPersonasAllowed call after admin save');
});
it('admin save calls fetchModels to refresh model list', () => {
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
assert.ok(saveFunc.includes('fetchModels'),
'MISSING: fetchModels call after admin save — model list stale after settings change');
});
// ── Models tab calls preset check ──
it('models tab switch calls checkUserPersonasAllowed', () => {
assert.ok(uiSrc.includes('checkUserPersonasAllowed'),
'MISSING: checkUserPersonasAllowed call on models tab switch');
});
});
// ── Policy evaluation logic ──────────────────
describe('Policy evaluation logic', () => {
@@ -158,7 +73,6 @@ describe('Policy evaluation logic', () => {
});
// ── Team member dropdown logic ───────────────
// Bug: Empty dropdown because resp.data used instead of resp.users
describe('Team member dropdown population', () => {
function getAvailableUsers(usersResp, membersResp) {
@@ -221,72 +135,37 @@ describe('Team member dropdown population', () => {
// ── Admin settings field mapping ─────────────
// v0.22.5: Server-rendered admin settings template uses new element IDs.
// Pages.saveSettings() in pages.js is the primary handler.
// v0.37.10: pages.js deleted — admin settings save now handled by
// Preact admin surface. Template elements still required.
describe('Admin settings field mapping (server templates)', () => {
// New element IDs used by server-rendered admin/settings.html + pages.js
const settingsFieldMap = {
'settRegEnabled': 'allow_registration',
'settRegDefaultState': 'default_user_active',
'settUserBYOK': 'allow_user_byok',
'settUserPersonas': 'allow_user_personas',
'settBannerEnabled': 'banner',
};
const pagesSrc = fs.readFileSync(path.join(SRC, 'pages.js'), 'utf-8');
const templateSrc = readAllTemplates();
for (const [elementId, settingKey] of Object.entries(settingsFieldMap)) {
it(`template has element #${elementId}`, () => {
assert.ok(templateSrc.includes(`id="${elementId}"`),
`MISSING: #${elementId} in server templates — admin settings incomplete`);
});
it(`Pages.saveSettings writes setting "${settingKey}"`, () => {
assert.ok(pagesSrc.includes(settingKey),
`MISSING: "${settingKey}" in Pages.saveSettings`);
});
}
});
// ── SPA bridge field mapping (backward compat) ──
// The SPA chat surface still loads settings-handlers.js + ui-admin.js.
// These use legacy element IDs for handleSaveAdminSettings().
// Verified at the JS level (elements are SPA-modal DOM, not templates).
describe('SPA bridge — admin settings handler references policy keys', () => {
const appSrc = ['settings-handlers.js', 'admin-handlers.js']
.map(f => fs.readFileSync(path.join(SRC, f), 'utf-8')).join('\n');
const requiredPolicies = [
'allow_registration',
'default_user_active',
'allow_user_byok',
'allow_user_personas',
const requiredElements = [
'settRegEnabled',
'settRegDefaultState',
'settUserBYOK',
'settUserPersonas',
'settBannerEnabled',
];
for (const key of requiredPolicies) {
it(`SPA bridge writes policy "${key}"`, () => {
assert.ok(appSrc.includes(key),
`MISSING: "${key}" in SPA bridge handler — policy not saved`);
for (const id of requiredElements) {
it(`template has element #${id}`, () => {
assert.ok(templateSrc.includes(`id="${id}"`),
`MISSING: #${id} in server templates — admin settings incomplete`);
});
}
});
// ── HTML element existence checks ────────────
// v0.22.5: Elements now live in server templates, not index.html.
// v0.37.5: Settings surface elements moved to Preact components
// (src/js/sw/surfaces/settings/). Only admin template elements
// are checked here. Settings surface policy gating is handled by
// the Preact SettingsSurface component (nav gating + section routing).
// ── Critical HTML elements in templates ───────
describe('Critical HTML elements exist in server templates', () => {
const templateSrc = readAllTemplates();
const requiredElements = [
// Admin settings — policy toggles (still in Go templates)
'settUserBYOK', // Admin toggle for BYOK (was adminUserProvidersToggle)
'settUserPersonas', // Admin toggle for presets (was adminUserPresetsToggle)
'settUserBYOK', // Admin toggle for BYOK
'settUserPersonas', // Admin toggle for personas
];
for (const id of requiredElements) {
@@ -297,6 +176,34 @@ describe('Critical HTML elements exist in server templates', () => {
}
});
// ── Chat surface template ────────────────────
// v0.37.10: Chat surface is now Preact-rendered. Verify the mount
// point exists and the old SPA scaffold is gone.
describe('Chat surface template (v0.37.10)', () => {
const templateSrc = readAllTemplates();
it('chat-mount div exists', () => {
assert.ok(templateSrc.includes('id="chat-mount"'),
'MISSING: #chat-mount — Preact chat surface cannot render');
});
it('old appContainer div is gone', () => {
assert.ok(!templateSrc.includes('id="appContainer"'),
'STALE: #appContainer still in templates — old SPA scaffold not removed');
});
it('chat template loads Preact surface module', () => {
assert.ok(templateSrc.includes('sw/surfaces/chat/index.js'),
'MISSING: chat surface module import in template');
});
it('chat template loads SDK boot', () => {
assert.ok(templateSrc.includes('sw/sdk/index.js'),
'MISSING: SDK boot import in chat template');
});
});
// ── Preact surface policy gating ─────────────
// v0.37.5: Settings surface elements are now rendered by Preact
// components. Verify the components handle policy gating.
@@ -326,20 +233,26 @@ describe('Settings Preact surface handles policy gating', () => {
});
});
// ── SPA bridge — dynamic DOM elements ────────
// adminMemberUser is created by ui-admin.js loadMemberUserDropdown()
// which runs inside the SPA chat surface. Verify the JS function exists.
// ── Admin Preact surface ─────────────────────
// v0.37.6: Admin surface handles settings save via Preact.
// Verify the admin surface has policy key handling.
describe('SPA bridge — dynamic element creators', () => {
const uiAdminSrc = fs.readFileSync(path.join(SRC, 'ui-admin.js'), 'utf-8');
describe('Admin Preact surface handles settings', () => {
const SURFACES = path.join(SRC, 'sw', 'surfaces', 'admin');
const settingsPath = path.join(SURFACES, 'settings.js');
it('ui-admin.js has loadMemberUserDropdown', () => {
assert.ok(uiAdminSrc.includes('loadMemberUserDropdown'),
'MISSING: loadMemberUserDropdown — team member add will break');
});
// Only test if the admin settings component exists
if (fs.existsSync(settingsPath)) {
const settingsSrc = fs.readFileSync(settingsPath, 'utf-8');
it('loadMemberUserDropdown references adminMemberUser', () => {
assert.ok(uiAdminSrc.includes('adminMemberUser'),
'MISSING: adminMemberUser reference in loadMemberUserDropdown');
});
it('admin settings component references allow_user_byok', () => {
assert.ok(settingsSrc.includes('allow_user_byok'),
'MISSING: allow_user_byok in admin settings component');
});
it('admin settings component references allow_user_personas', () => {
assert.ok(settingsSrc.includes('allow_user_personas'),
'MISSING: allow_user_personas in admin settings component');
});
}
});

View File

@@ -1,996 +0,0 @@
// ==========================================
// Chat Switchboard API Client
// ==========================================
// Backend-only mode. Handles auth tokens and
// all HTTP calls. No offline fallback.
//
// BASE_PATH: injected via index.html <script>
// at container startup. All API paths are
// prefixed automatically.
//
// Exports: window.API
// ==========================================
const BASE = window.__BASE__ || '';
const _storageKey = BASE ? `sb_auth_${BASE.replace(/\//g, '')}` : 'sb_auth';
const API = {
accessToken: null,
refreshToken: null,
user: null,
_refreshing: null,
// ── Bootstrap ────────────────────────────
loadTokens() {
try {
const saved = JSON.parse(localStorage.getItem(_storageKey) || 'null');
if (saved) {
this.accessToken = saved.accessToken || null;
this.refreshToken = saved.refreshToken || null;
this.user = saved.user || null;
// Unknown age — refresh soon to get a fresh token
if (this.refreshToken) this._scheduleRefresh(60);
}
} catch (e) { /* corrupt storage */ }
},
saveTokens() {
localStorage.setItem(_storageKey, JSON.stringify({
accessToken: this.accessToken,
refreshToken: this.refreshToken,
user: this.user
}));
// Sync to cookie for Go template page auth (v0.22.5)
if (this.accessToken) {
document.cookie = `sb_token=${this.accessToken}; path=/; max-age=900; SameSite=Strict`;
} else {
document.cookie = 'sb_token=; path=/; max-age=0';
}
},
clearTokens() {
this.accessToken = null;
this.refreshToken = null;
this.user = null;
localStorage.removeItem(_storageKey);
document.cookie = 'sb_token=; path=/; max-age=0'; // clear page auth cookie
if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; }
},
get isAuthed() { return !!this.accessToken; },
get isAdmin() { return this.user?.role === 'admin'; },
// ── Auth ─────────────────────────────────
async health() {
const resp = await fetch(BASE + '/api/v1/health', { signal: AbortSignal.timeout(8000) });
if (!resp.ok) throw new Error(`Health: ${resp.status}`);
return this._parseJSON(resp, '/api/v1/health');
},
// Detect proxy interception: HTTP 200 but HTML instead of JSON
async _parseJSON(resp, context) {
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
// Proxy returned an HTML page (block page, auth wall, etc.)
let title = 'unknown';
try {
const html = await resp.clone().text();
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (m) title = m[1].trim();
} catch (e) { /* best effort */ }
const err = new Error(`Proxy interception detected on ${context}: "${title}"`);
err.proxyBlocked = true;
err.proxyTitle = title;
throw err;
}
return resp.json();
},
async login(login, password) {
const data = await this._post('/api/v1/auth/login', { login, password }, true);
this._setAuth(data);
return data;
},
async register(username, email, password) {
const data = await this._post('/api/v1/auth/register', { username, email, password }, true);
if (!data.pending) this._setAuth(data);
return data;
},
async logout() {
try { await this._post('/api/v1/auth/logout', { refresh_token: this.refreshToken }, true); }
catch (e) { /* best effort */ }
this.clearTokens();
},
async refresh() {
if (this._refreshing) return this._refreshing;
this._refreshing = (async () => {
try {
const data = await this._post('/api/v1/auth/refresh', { refresh_token: this.refreshToken }, true);
this._setAuth(data);
return true;
} catch (e) {
this.clearTokens();
return false;
} finally {
this._refreshing = null;
}
})();
return this._refreshing;
},
// Centralized 401 recovery: try refresh, redirect to login on failure.
// Returns true if refresh succeeded (caller should retry), false otherwise.
async _handle401() {
if (this.refreshToken) {
if (await this.refresh()) return true;
}
// No refresh token or refresh failed — redirect to login
this.clearTokens();
const base = window.__BASE__ || '';
window.location.href = base + '/login';
return false;
},
// Alias for legacy call sites
async _refreshAccessToken() {
if (!(await this._handle401())) {
throw new Error('Session expired');
}
},
_setAuth(data) {
this.accessToken = data.access_token;
this.refreshToken = data.refresh_token;
this.user = data.user;
this.saveTokens();
this._scheduleRefresh(data.expires_in || 900);
},
_refreshTimer: null,
_scheduleRefresh(expiresIn) {
if (this._refreshTimer) clearTimeout(this._refreshTimer);
// Refresh at 80% of expiry (e.g., 12min for 15min token)
const ms = Math.max((expiresIn * 0.8) * 1000, 30000);
this._refreshTimer = setTimeout(async () => {
if (!this.refreshToken) return;
console.debug('🔄 Proactive token refresh');
try {
const data = await this._post('/api/v1/auth/refresh', { refresh_token: this.refreshToken }, true);
this._setAuth(data);
} catch (e) {
// Proactive refresh failed — keep session alive.
// Access token is still valid for ~20% of its lifetime.
// When it naturally expires, 401-retry in _authed() handles cleanup.
console.warn('⚠ Proactive refresh failed, access token still valid');
}
}, ms);
},
// ── Channels ──────────────────────────────
listChannels(page = 1, perPage = 100, type = '') {
let url = `/api/v1/channels?page=${page}&per_page=${perPage}`;
if (type) url += `&type=${type}`;
return this._get(url);
},
createChannel(title, model, systemPrompt, type = 'direct') {
const body = { title, type };
if (model) body.model = model;
if (systemPrompt) body.system_prompt = systemPrompt;
return this._post('/api/v1/channels', body);
},
getChannel(id) { return this._get(`/api/v1/channels/${id}`); },
updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
generateTitle(id) { return this._post(`/api/v1/channels/${id}/generate-title`, {}); },
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
markRead(id) { return this._post(`/api/v1/channels/${id}/mark-read`, {}); },
// Channel models (v0.20.0 — multi-model @mention routing)
listChannelModels(channelId) { return this._get(`/api/v1/channels/${channelId}/models`); },
addChannelModel(channelId, data) { return this._post(`/api/v1/channels/${channelId}/models`, data); },
updateChannelModel(channelId, modelId, data) { return this._patch(`/api/v1/channels/${channelId}/models/${modelId}`, data); },
deleteChannelModel(channelId, modelId) { return this._del(`/api/v1/channels/${channelId}/models/${modelId}`); },
// Channel participants (v0.23.0 — ICD §3.7)
listParticipants(channelId) { return this._get(`/api/v1/channels/${channelId}/participants`); },
addParticipant(channelId, data) { return this._post(`/api/v1/channels/${channelId}/participants`, data); },
updateParticipant(channelId, participantId, data) { return this._patch(`/api/v1/channels/${channelId}/participants/${participantId}`, data); },
removeParticipant(channelId, participantId) { return this._del(`/api/v1/channels/${channelId}/participants/${participantId}`); },
// Notification preferences (v0.20.0 Phase 3)
listNotifPrefs() { return this._get('/api/v1/notifications/preferences'); },
setNotifPref(type, data) { return this._put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data); },
deleteNotifPref(type) { return this._del(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`); },
adminTestEmail() { return this._post('/api/v1/admin/notifications/test-email', {}); },
// Projects (v0.19.0)
listProjects(includeArchived) {
const q = includeArchived ? '?include_archived=true' : '';
return this._get(`/api/v1/projects${q}`);
},
createProject(data) { return this._post('/api/v1/projects', data); },
getProject(id) { return this._get(`/api/v1/projects/${id}`); },
updateProject(id, data) { return this._put(`/api/v1/projects/${id}`, data); },
deleteProject(id) { return this._del(`/api/v1/projects/${id}`); },
addChannelToProject(projectId, channelId, position) {
return this._post(`/api/v1/projects/${projectId}/channels`, { channel_id: channelId, position: position || 0 });
},
removeChannelFromProject(projectId, channelId) {
return this._del(`/api/v1/projects/${projectId}/channels/${channelId}`);
},
reorderProjectChannels(projectId, channelIds) {
return this._put(`/api/v1/projects/${projectId}/channels/reorder`, { channel_ids: channelIds });
},
listProjectChannels(projectId) { return this._get(`/api/v1/projects/${projectId}/channels`); },
addKBToProject(projectId, kbId, autoSearch) {
return this._post(`/api/v1/projects/${projectId}/knowledge-bases`, { kb_id: kbId, auto_search: autoSearch || false });
},
removeKBFromProject(projectId, kbId) {
return this._del(`/api/v1/projects/${projectId}/knowledge-bases/${kbId}`);
},
listProjectKBs(projectId) { return this._get(`/api/v1/projects/${projectId}/knowledge-bases`); },
addNoteToProject(projectId, noteId) {
return this._post(`/api/v1/projects/${projectId}/notes`, { note_id: noteId });
},
removeNoteFromProject(projectId, noteId) {
return this._del(`/api/v1/projects/${projectId}/notes/${noteId}`);
},
listProjectNotes(projectId) { return this._get(`/api/v1/projects/${projectId}/notes`); },
// ── Folders (v0.23.1) ────────────────────
listFolders() { return this._get('/api/v1/folders'); },
createFolder(name, sortOrder = 0) { return this._post('/api/v1/folders', { name, sort_order: sortOrder }); },
updateFolder(id, data) { return this._put(`/api/v1/folders/${id}`, data); },
deleteFolder(id) { return this._del(`/api/v1/folders/${id}`); },
// ── Sidebar helpers ───────────────────────
listSidebarChannels(page = 1, perPage = 500) {
return this._get(`/api/v1/channels?page=${page}&per_page=${perPage}&types=dm,channel`);
},
// ── Presence ──────────────────────────────
presenceHeartbeat() { return this._post('/api/v1/presence/heartbeat', {}); },
// ── Workspaces (v0.21.0) ────────────────
getWorkspace(id) { return this._get(`/api/v1/workspaces/${id}`); },
listWorkspaceFiles(wsId, path = '', recursive = false) {
let url = `/api/v1/workspaces/${wsId}/files?path=${encodeURIComponent(path)}`;
if (recursive) url += '&recursive=true';
return this._get(url);
},
async readWorkspaceFile(wsId, path) {
// Returns raw text, not JSON
const resp = await fetch(BASE + `/api/v1/workspaces/${wsId}/files/read?path=${encodeURIComponent(path)}`, {
headers: this._authHeaders(),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw Object.assign(new Error(data.error || `HTTP ${resp.status}`), { status: resp.status });
}
const ct = resp.headers.get('content-type') || '';
if (ct.includes('application/json')) {
const data = await resp.json();
return data.content || '';
}
return resp.text();
},
async writeWorkspaceFile(wsId, path, content) {
// Backend reads path from query string and content from raw request body
const url = BASE + `/api/v1/workspaces/${wsId}/files/write?path=${encodeURIComponent(path)}`;
const resp = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'text/plain',
'Authorization': `Bearer ${this.accessToken}`,
},
body: content,
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
return resp.json();
},
deleteWorkspaceFile(wsId, path) {
return this._del(`/api/v1/workspaces/${wsId}/files/delete?path=${encodeURIComponent(path)}`);
},
mkdirWorkspace(wsId, path) {
return this._post(`/api/v1/workspaces/${wsId}/files/mkdir`, { path });
},
// Upload a File object to a workspace (binary-safe, v0.23.0)
async uploadWorkspaceFile(wsId, file, destPath) {
const path = destPath || file.name;
const url = BASE + `/api/v1/workspaces/${wsId}/files/write?path=${encodeURIComponent(path)}`;
const doFetch = () => fetch(url, {
method: 'PUT',
headers: {
'Content-Type': file.type || 'application/octet-stream',
'Content-Length': file.size.toString(),
'Authorization': `Bearer ${this.accessToken}`,
},
body: file,
});
let resp = await doFetch();
if (resp.status === 401) {
await this._refreshAccessToken();
resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${resp.status}`);
}
return resp.json();
},
getWorkspaceGitStatus(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/status`); },
getWorkspaceGitBranches(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/branches`); },
listWorkspaces() { return this._get('/api/v1/workspaces'); },
createWorkspace(data) { return this._post('/api/v1/workspaces', data); },
updateWorkspace(id, patch) { return this._patch(`/api/v1/workspaces/${id}`, patch); },
deleteWorkspace(id) { return this._del(`/api/v1/workspaces/${id}`); },
listGitCredentials() { return this._get('/api/v1/git-credentials'); },
// ── Messages ─────────────────────────────
listMessages(channelId, page = 1, perPage = 200) {
return this._get(`/api/v1/channels/${channelId}/messages?page=${page}&per_page=${perPage}`);
},
// ── Message Tree (forking) ───────────────
getActivePath(channelId) {
return this._get(`/api/v1/channels/${channelId}/path`);
},
editMessage(channelId, messageId, content) {
return this._post(`/api/v1/channels/${channelId}/messages/${messageId}/edit`, { content });
},
async streamRegenerate(channelId, messageId, signal, model, personaId, apiConfigId, disabledTools) {
const body = {};
if (model) body.model = model;
if (personaId) body.persona_id = personaId;
if (apiConfigId) body.provider_config_id = apiConfigId;
if (disabledTools?.length) body.disabled_tools = disabledTools;
let resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
if (resp.status === 401) {
if (await this._handle401()) {
resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
}
}
if (!resp.ok) {
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy blocked the regeneration request');
err.proxyBlocked = true;
throw err;
}
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`);
}
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy intercepted the regeneration stream');
err.proxyBlocked = true;
throw err;
}
return resp;
},
updateCursor(channelId, activeLeafId) {
return this._put(`/api/v1/channels/${channelId}/cursor`, { active_leaf_id: activeLeafId });
},
listSiblings(channelId, messageId) {
return this._get(`/api/v1/channels/${channelId}/messages/${messageId}/siblings`);
},
// ── Summarize & Continue ────────────────
summarizeChannel(channelId) {
return this._post(`/api/v1/channels/${channelId}/summarize`, {});
},
// ── Completions ──────────────────────────
async streamCompletion(channelId, content, model, signal, apiConfigId, personaId, fileIds, disabledTools) {
const body = { channel_id: channelId, content, stream: true };
if (personaId) {
body.persona_id = personaId;
// Persona resolves the model on backend; still pass model for channel metadata
if (model) body.model = model;
} else {
if (model) body.model = model;
}
if (apiConfigId) body.provider_config_id = apiConfigId;
// Only send max_tokens if user explicitly set it (non-zero = override)
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
if (fileIds?.length) body.file_ids = fileIds;
if (disabledTools?.length) body.disabled_tools = disabledTools;
let resp = await fetch(BASE + '/api/v1/chat/completions', {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
if (resp.status === 401) {
if (await this._handle401()) {
resp = await fetch(BASE + '/api/v1/chat/completions', {
method: 'POST',
headers: this._authHeaders(),
body: JSON.stringify(body),
signal
});
}
}
if (!resp.ok) {
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy blocked the completion request');
err.proxyBlocked = true;
throw err;
}
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`);
}
// Also check 200 OK but HTML (proxy returning block page with 200)
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
const err = new Error('Proxy intercepted the completion stream');
err.proxyBlocked = true;
throw err;
}
return resp;
},
// ── Tools ────────────────────────────────
getTools() { return this._get('/api/v1/tools'); },
// ── Models ───────────────────────────────
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
getModelPreferences() { return this._get('/api/v1/models/preferences'); },
setModelPreference(modelId, providerConfigId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, provider_config_id: providerConfigId, hidden }); },
bulkSetModelPreferences(entries, hidden) { return this._post('/api/v1/models/preferences/bulk', { entries, hidden }); },
// User personas
listUserPersonas() { return this._get('/api/v1/personas'); },
createUserPersona(persona) { return this._post('/api/v1/personas', persona); },
getUserPersonaKBs(personaId) { return this._get(`/api/v1/personas/${personaId}/knowledge-bases`); },
setUserPersonaKBs(personaId, data) { return this._put(`/api/v1/personas/${personaId}/knowledge-bases`, data); },
updateUserPersona(id, updates) { return this._put(`/api/v1/personas/${id}`, updates); },
deleteUserPersona(id) { return this._del(`/api/v1/personas/${id}`); },
listAllModels() { return this._get('/api/v1/models/enabled'); },
// ── API Configs (user providers) ─────────
listConfigs() { return this._get('/api/v1/api-configs'); },
getConfig(id) { return this._get(`/api/v1/api-configs/${id}`); },
createConfig(name, provider, endpoint, apiKey, modelDefault) {
return this._post('/api/v1/api-configs', {
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
});
},
deleteConfig(id) { return this._del(`/api/v1/api-configs/${id}`); },
updateConfig(id, patch) { return this._put(`/api/v1/api-configs/${id}`, patch); },
listProviderModels(id) { return this._get(`/api/v1/api-configs/${id}/models`); },
fetchProviderModels(id) { return this._post(`/api/v1/api-configs/${id}/models/fetch`); },
// ── Profile & Settings ───────────────────
async getProfile() {
const data = await this._get('/api/v1/profile');
// Sync user object with profile data (including avatar)
if (data && this.user) {
this.user.avatar = data.avatar || null;
this.user.display_name = data.display_name;
this.saveTokens();
}
return data;
},
updateProfile(updates) { return this._put('/api/v1/profile', updates); },
uploadAvatar(base64Image) { return this._post('/api/v1/profile/avatar', { image: base64Image }); },
deleteAvatar() { return this._del('/api/v1/profile/avatar'); },
changePassword(current, newPw) {
return this._post('/api/v1/profile/password', { current_password: current, new_password: newPw });
},
getSettings() { return this._get('/api/v1/settings'); },
updateSettings(settings) { return this._put('/api/v1/settings', settings); },
// ── Admin ────────────────────────────────
adminListUsers(p, pp) { return this._get(`/api/v1/admin/users?page=${p||1}&per_page=${pp||50}`); },
adminCreateUser(username, email, password, role) {
return this._post('/api/v1/admin/users', { username, email, password, role });
},
adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }); },
adminUpdateRole(id, role) { return this._put(`/api/v1/admin/users/${id}/role`, { role }); },
adminToggleActive(id, active, teamIds, teamRole) {
const body = { is_active: active };
if (teamIds?.length) body.team_ids = teamIds;
if (teamRole) body.team_role = teamRole;
return this._put(`/api/v1/admin/users/${id}/active`, body);
},
adminDeleteUser(id) { return this._del(`/api/v1/admin/users/${id}`); },
adminGetSettings() { return this._get('/api/v1/admin/settings'); },
getPublicSettings() { return this._get('/api/v1/settings/public'); },
adminUpdateSetting(key, value) { return this._put(`/api/v1/admin/settings/${key}`, value); },
adminGetStats() { return this._get('/api/v1/admin/stats'); },
adminGetDashboard() { return this._get('/api/v1/admin/dashboard'); },
adminListGlobalConfigs() { return this._get('/api/v1/admin/configs'); },
adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault, isPrivate) {
return this._post('/api/v1/admin/configs', {
name, provider, endpoint, api_key: apiKey, model_default: modelDefault, is_private: !!isPrivate
});
},
adminDeleteGlobalConfig(id) { return this._del(`/api/v1/admin/configs/${id}`); },
adminUpdateGlobalConfig(id, updates) { return this._put(`/api/v1/admin/configs/${id}`, updates); },
adminListModels() { return this._get('/api/v1/admin/models'); },
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
adminUpdateModel(id, updates) { return this._put(`/api/v1/admin/models/${id}`, updates); },
adminBulkUpdateModels(visibility) {
// Send both for backward compat: old BE reads is_enabled, new BE reads visibility
return this._put('/api/v1/admin/models/bulk', {
visibility,
is_enabled: visibility === 'enabled'
});
},
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
// ── Admin Personas ────────────────────────
adminListPersonas() { return this._get('/api/v1/admin/personas'); },
adminCreatePersona(persona) { return this._post('/api/v1/admin/personas', persona); },
adminUpdatePersona(id, updates) { return this._put(`/api/v1/admin/personas/${id}`, updates); },
adminDeletePersona(id) { return this._del(`/api/v1/admin/personas/${id}`); },
adminUploadPersonaAvatar(id, base64Image) { return this._post(`/api/v1/admin/personas/${id}/avatar`, { image: base64Image }); },
adminDeletePersonaAvatar(id) { return this._del(`/api/v1/admin/personas/${id}/avatar`); },
adminPersonaKBs(personaId) { return this._get(`/api/v1/admin/personas/${personaId}/knowledge-bases`); },
adminSetPersonaKBs(personaId, data) { return this._put(`/api/v1/admin/personas/${personaId}/knowledge-bases`, data); },
// ── Admin Teams ─────────────────────────
adminListTeams() { return this._get('/api/v1/admin/teams'); },
// ── Admin Audit ─────────────────────────
adminListAudit(params) {
const q = new URLSearchParams();
if (params?.page) q.set('page', params.page);
if (params?.per_page) q.set('per_page', params.per_page);
if (params?.action) q.set('action', params.action);
if (params?.actor_id) q.set('actor_id', params.actor_id);
if (params?.resource_type) q.set('resource_type', params.resource_type);
return this._get(`/api/v1/admin/audit?${q}`);
},
adminListAuditActions() { return this._get('/api/v1/admin/audit/actions'); },
adminCreateTeam(name, description) { return this._post('/api/v1/admin/teams', { name, description }); },
adminGetTeam(id) { return this._get(`/api/v1/admin/teams/${id}`); },
adminUpdateTeam(id, updates) { return this._put(`/api/v1/admin/teams/${id}`, updates); },
adminDeleteTeam(id) { return this._del(`/api/v1/admin/teams/${id}`); },
adminListMembers(teamId) { return this._get(`/api/v1/admin/teams/${teamId}/members`); },
adminAddMember(teamId, userId, role) { return this._post(`/api/v1/admin/teams/${teamId}/members`, { user_id: userId, role }); },
adminUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/admin/teams/${teamId}/members/${memberId}`, { role }); },
adminRemoveMember(teamId, memberId) { return this._del(`/api/v1/admin/teams/${teamId}/members/${memberId}`); },
// ── Admin Groups ────────────────────────
adminListGroups() { return this._get('/api/v1/admin/groups'); },
adminCreateGroup(name, description, scope, teamId) {
const body = { name, description, scope };
if (teamId) body.team_id = teamId;
return this._post('/api/v1/admin/groups', body);
},
adminGetGroup(id) { return this._get(`/api/v1/admin/groups/${id}`); },
adminUpdateGroup(id, updates) { return this._put(`/api/v1/admin/groups/${id}`, updates); },
adminDeleteGroup(id) { return this._del(`/api/v1/admin/groups/${id}`); },
adminListGroupMembers(groupId) { return this._get(`/api/v1/admin/groups/${groupId}/members`); },
// Admin — Projects (v0.19.0)
adminListProjects(includeArchived) {
const q = includeArchived ? '?include_archived=true' : '';
return this._get(`/api/v1/admin/projects${q}`);
},
adminDeleteProject(id) { return this._del(`/api/v1/admin/projects/${id}`); },
adminAddGroupMember(groupId, userId) { return this._post(`/api/v1/admin/groups/${groupId}/members`, { user_id: userId }); },
adminRemoveGroupMember(groupId, userId) { return this._del(`/api/v1/admin/groups/${groupId}/members/${userId}`); },
// ── Admin Resource Grants ───────────────
adminGetGrant(type, id) { return this._get(`/api/v1/admin/grants/${type}/${id}`); },
adminSetGrant(type, id, grantScope, grantedGroups) {
return this._put(`/api/v1/admin/grants/${type}/${id}`, { grant_scope: grantScope, granted_groups: grantedGroups || [] });
},
adminDeleteGrant(type, id) { return this._del(`/api/v1/admin/grants/${type}/${id}`); },
// ── Permissions (v0.24.2) ───────────────
adminListPermissions() { return this._get('/api/v1/admin/permissions'); },
adminGetUserPermissions(userId) { return this._get(`/api/v1/admin/users/${userId}/permissions`); },
// ── User Groups ─────────────────────────
listMyGroups() { return this._get('/api/v1/groups/mine'); },
// ── User Teams ──────────────────────────
listMyTeams() { return this._get('/api/v1/teams/mine'); },
// ── Team Admin Self-Service ─────────────
teamListMembers(teamId) { return this._get(`/api/v1/teams/${teamId}/members`); },
teamAddMember(teamId, userId, role) { return this._post(`/api/v1/teams/${teamId}/members`, { user_id: userId, role }); },
teamUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/teams/${teamId}/members/${memberId}`, { role }); },
teamRemoveMember(teamId, memberId) { return this._del(`/api/v1/teams/${teamId}/members/${memberId}`); },
teamListPersonas(teamId) { return this._get(`/api/v1/teams/${teamId}/personas`); },
teamCreatePersona(teamId, persona) { return this._post(`/api/v1/teams/${teamId}/personas`, persona); },
teamGetPersonaKBs(teamId, personaId) { return this._get(`/api/v1/teams/${teamId}/personas/${personaId}/knowledge-bases`); },
teamSetPersonaKBs(teamId, personaId, data) { return this._put(`/api/v1/teams/${teamId}/personas/${personaId}/knowledge-bases`, data); },
teamDeletePersona(teamId, personaId) { return this._del(`/api/v1/teams/${teamId}/personas/${personaId}`); },
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
teamListGroups(teamId) { return this._get(`/api/v1/teams/${teamId}/groups`); },
// ── Team Providers ──────────────────────
teamListProviders(teamId) { return this._get(`/api/v1/teams/${teamId}/providers`); },
teamCreateProvider(teamId, provider) { return this._post(`/api/v1/teams/${teamId}/providers`, provider); },
teamUpdateProvider(teamId, providerId, updates) { return this._put(`/api/v1/teams/${teamId}/providers/${providerId}`, updates); },
teamDeleteProvider(teamId, providerId) { return this._del(`/api/v1/teams/${teamId}/providers/${providerId}`); },
teamListProviderModels(teamId, providerId) { return this._get(`/api/v1/teams/${teamId}/providers/${providerId}/models`); },
teamListAudit(teamId, params) {
const q = new URLSearchParams();
if (params?.page) q.set('page', params.page);
if (params?.per_page) q.set('per_page', params.per_page);
if (params?.action) q.set('action', params.action);
if (params?.actor_id) q.set('actor_id', params.actor_id);
if (params?.resource_type) q.set('resource_type', params.resource_type);
return this._get(`/api/v1/teams/${teamId}/audit?${q}`);
},
teamListAuditActions(teamId) { return this._get(`/api/v1/teams/${teamId}/audit/actions`); },
// ── User Personas ─────────────────────────
listPersonas() { return this._get('/api/v1/personas'); },
createPersona(persona) { return this._post('/api/v1/personas', persona); },
updatePersona(id, updates) { return this._put(`/api/v1/personas/${id}`, updates); },
deletePersona(id) { return this._del(`/api/v1/personas/${id}`); },
// ── Notes ────────────────────────────────
listNotes(limit = 50, offset = 0, folder, tag, sort) {
let url = `/api/v1/notes?limit=${limit}&offset=${offset}`;
if (folder) url += `&folder=${encodeURIComponent(folder)}`;
if (tag) url += `&tag=${encodeURIComponent(tag)}`;
if (sort) url += `&sort=${encodeURIComponent(sort)}`;
return this._get(url);
},
createNote(title, content, folderPath, tags, sourceChannelId, sourceMessageId) {
const body = { title, content };
if (folderPath) body.folder_path = folderPath;
if (tags?.length) body.tags = tags;
if (sourceChannelId) body.source_channel_id = sourceChannelId;
if (sourceMessageId) body.source_message_id = sourceMessageId;
return this._post('/api/v1/notes', body);
},
getNote(id) { return this._get(`/api/v1/notes/${id}`); },
updateNote(id, updates) { return this._put(`/api/v1/notes/${id}`, updates); },
deleteNote(id) { return this._del(`/api/v1/notes/${id}`); },
bulkDeleteNotes(ids) { return this._post('/api/v1/notes/bulk-delete', { ids }); },
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
searchNoteTitles(query, limit = 10) { return this._get(`/api/v1/notes/search-titles?q=${encodeURIComponent(query)}&limit=${limit}`); },
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
getNoteBacklinks(id) { return this._get(`/api/v1/notes/${id}/backlinks`); },
getNoteGraph() { return this._get('/api/v1/notes/graph'); },
// ── Files ────────────────────────────────
/**
* Upload a file to a channel. Uses multipart/form-data (not JSON).
* Returns the created file object with id, metadata, etc.
*/
async uploadFile(channelId, file) {
const form = new FormData();
form.append('file', file, file.name);
const doFetch = () => fetch(BASE + `/api/v1/channels/${channelId}/files`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.accessToken}` },
// No Content-Type — browser sets multipart boundary automatically
body: form,
});
let resp = await doFetch();
if (resp.status === 401) {
if (await this._handle401()) resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `Upload failed: HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
return this._parseJSON(resp, `/api/v1/channels/${channelId}/files`);
},
getFile(id) { return this._get(`/api/v1/files/${id}`); },
async downloadFileBlob(id) {
const doFetch = () => fetch(BASE + `/api/v1/files/${id}/download`, {
headers: { 'Authorization': `Bearer ${this.accessToken}` },
});
let resp = await doFetch();
if (resp.status === 401) {
if (await this._handle401()) resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `Download failed: HTTP ${resp.status}`);
}
return resp.blob();
},
deleteFile(id) { return this._del(`/api/v1/files/${id}`); },
listChannelFiles(channelId) { return this._get(`/api/v1/channels/${channelId}/files`); },
// ── Admin Storage ───────────────────────
adminGetStorageStatus() { return this._get('/api/v1/admin/storage/status'); },
adminGetOrphanCount() { return this._get('/api/v1/admin/storage/orphans'); },
adminRunStorageCleanup() { return this._post('/api/v1/admin/storage/cleanup', {}); },
adminGetExtractionStatus() { return this._get('/api/v1/admin/storage/extraction'); },
// Vault
adminGetVaultStatus() { return this._get('/api/v1/admin/vault/status'); },
// ── Admin Roles ─────────────────────────
adminListRoles() { return this._get('/api/v1/admin/roles'); },
adminGetRole(role) { return this._get(`/api/v1/admin/roles/${role}`); },
adminUpdateRole(role, config) { return this._put(`/api/v1/admin/roles/${role}`, config); },
adminTestRole(role) { return this._post(`/api/v1/admin/roles/${role}/test`, {}); },
// ── Admin Usage & Pricing ───────────────
adminGetUsage(params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.since) q.set('since', params.since);
if (params?.until) q.set('until', params.until);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage?${q}`);
},
adminGetUserUsage(userId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage/users/${userId}?${q}`);
},
adminGetTeamUsage(teamId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/admin/usage/teams/${teamId}?${q}`);
},
adminListPricing() { return this._get('/api/v1/admin/pricing'); },
adminUpsertPricing(entry) { return this._put('/api/v1/admin/pricing', entry); },
adminDeletePricing(providerConfigId, modelId) {
return this._del(`/api/v1/admin/pricing/${providerConfigId}/${encodeURIComponent(modelId)}`);
},
// ── Admin Provider Health (v0.22.3) ─────
adminGetAllProviderHealth() { return this._get('/api/v1/admin/providers/health'); },
adminGetProviderHealth(id) { return this._get(`/api/v1/admin/providers/${id}/health`); },
// ── Admin Routing Policies (v0.22.3) ────
adminListRoutingPolicies() { return this._get('/api/v1/admin/routing/policies'); },
adminGetRoutingPolicy(id) { return this._get(`/api/v1/admin/routing/policies/${id}`); },
adminCreateRoutingPolicy(policy) { return this._post('/api/v1/admin/routing/policies', policy); },
adminUpdateRoutingPolicy(id, policy) { return this._put(`/api/v1/admin/routing/policies/${id}`, policy); },
adminDeleteRoutingPolicy(id) { return this._del(`/api/v1/admin/routing/policies/${id}`); },
adminTestRouting(model, userId, teamIds) {
return this._post('/api/v1/admin/routing/test', { model, user_id: userId, team_ids: teamIds || [] });
},
// ── Admin Capabilities & Provider Types (v0.22.3) ──
adminGetModelCapabilities(modelId) { return this._get(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities`); },
adminSetModelCapability(modelId, overrides) { return this._put(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities`, overrides); },
adminDeleteModelCapability(modelId, overrideId) { return this._del(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities/${overrideId}`); },
adminListCapabilityOverrides() { return this._get('/api/v1/admin/capability-overrides'); },
adminGetProviderTypes() { return this._get('/api/v1/admin/provider-types'); },
// ── Project Files (v0.22.4) ─────────────
async projectUploadFile(projectId, file) {
const form = new FormData();
form.append('file', file, file.name);
const doFetch = () => fetch(BASE + `/api/v1/projects/${projectId}/files`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.accessToken}` },
body: form,
});
let resp = await doFetch();
if (resp.status === 401) {
await this._refreshAccessToken();
resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${resp.status}`);
}
return resp.json();
},
projectListFiles(projectId) { return this._get(`/api/v1/projects/${projectId}/files`); },
// ── Export (v0.22.4) ────────────────────
exportDocument(content, format, filename) {
return this._post('/api/v1/export', { content, format, filename });
},
// ── User Usage ──────────────────────────
getMyUsage(params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/usage?${q}`);
},
// ── Team Roles ──────────────────────────
teamListRoles(teamId) { return this._get(`/api/v1/teams/${teamId}/roles`); },
teamUpdateRole(teamId, role, config) { return this._put(`/api/v1/teams/${teamId}/roles/${role}`, config); },
teamDeleteRole(teamId, role) { return this._del(`/api/v1/teams/${teamId}/roles/${role}`); },
// ── Team Usage ──────────────────────────
teamGetUsage(teamId, params) {
const q = new URLSearchParams();
if (params?.period) q.set('period', params.period);
if (params?.group_by) q.set('group_by', params.group_by);
return this._get(`/api/v1/teams/${teamId}/usage?${q}`);
},
// ── Knowledge Bases ──────────────────────
listKnowledgeBases() { return this._get('/api/v1/knowledge-bases'); },
listDiscoverableKBs() { return this._get('/api/v1/knowledge-bases-discoverable'); },
createKnowledgeBase(name, description, scope, teamId) {
const body = { name, description: description || '' };
if (scope) body.scope = scope;
if (teamId) body.team_id = teamId;
return this._post('/api/v1/knowledge-bases', body);
},
getKnowledgeBase(id) { return this._get(`/api/v1/knowledge-bases/${id}`); },
updateKnowledgeBase(id, updates) { return this._put(`/api/v1/knowledge-bases/${id}`, updates); },
deleteKnowledgeBase(id) { return this._del(`/api/v1/knowledge-bases/${id}`); },
searchKnowledgeBase(id, query, limit) {
const body = { query };
if (limit) body.limit = limit;
return this._post(`/api/v1/knowledge-bases/${id}/search`, body);
},
// KB Documents
listKBDocuments(kbId) { return this._get(`/api/v1/knowledge-bases/${kbId}/documents`); },
getKBDocumentStatus(kbId, docId) { return this._get(`/api/v1/knowledge-bases/${kbId}/documents/${docId}/status`); },
deleteKBDocument(kbId, docId) { return this._del(`/api/v1/knowledge-bases/${kbId}/documents/${docId}`); },
async uploadKBDocument(kbId, file) {
const form = new FormData();
form.append('file', file, file.name);
const doFetch = () => fetch(BASE + `/api/v1/knowledge-bases/${kbId}/documents`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.accessToken}` },
body: form,
});
let resp = await doFetch();
if (resp.status === 401) {
if (await this._handle401()) resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `Upload failed: HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
return this._parseJSON(resp, `/api/v1/knowledge-bases/${kbId}/documents`);
},
// Channel ↔ KB linking
getChannelKBs(channelId) { return this._get(`/api/v1/channels/${channelId}/knowledge-bases`); },
setChannelKBs(channelId, kbIds) { return this._put(`/api/v1/channels/${channelId}/knowledge-bases`, { kb_ids: kbIds }); },
// ── Memories (v0.18.0) ───────────────────
listMemories(status, query) {
const params = new URLSearchParams();
if (status) params.set('status', status);
if (query) params.set('query', query);
const qs = params.toString();
return this._get('/api/v1/memories' + (qs ? '?' + qs : ''));
},
getMemoryCount() { return this._get('/api/v1/memories/count'); },
updateMemory(id, data) { return this._put(`/api/v1/memories/${id}`, data); },
deleteMemory(id) { return this._del(`/api/v1/memories/${id}`); },
approveMemory(id) { return this._post(`/api/v1/memories/${id}/approve`, {}); },
rejectMemory(id) { return this._post(`/api/v1/memories/${id}/reject`, {}); },
bulkApproveMemories(ids) { return this._post('/api/v1/admin/memories/bulk-approve', { ids }); },
adminListPendingMemories() { return this._get('/api/v1/admin/memories/pending'); },
// ── HTTP Internals ───────────────────────
_esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
},
_authHeaders() {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.accessToken}`
};
},
async _get(path) { return this._authed(path); },
async _post(path, body, skipAuth) { return skipAuth ? this._raw(path, 'POST', body) : this._authed(path, 'POST', body); },
async _put(path, body) { return this._authed(path, 'PUT', body); },
async _del(path) { return this._authed(path, 'DELETE'); },
async _delete(path) { return this._del(path); }, // alias — `delete` is a JS reserved word
async _patch(path, body) { return this._authed(path, 'PATCH', body); },
async _authed(path, method = 'GET', body) {
try {
return await this._raw(path, method, body, true);
} catch (e) {
if (e.status === 401) {
if (await this._handle401()) {
return this._raw(path, method, body, true);
}
}
throw e;
}
},
async _raw(path, method = 'GET', body, auth = false) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (auth) opts.headers['Authorization'] = `Bearer ${this.accessToken}`;
if (body) opts.body = JSON.stringify(body);
const resp = await fetch(BASE + path, opts);
if (!resp.ok) {
// Check if the error response is also a proxy page
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (ct.includes('text/html')) {
let title = 'unknown';
try {
const html = await resp.text();
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (m) title = m[1].trim();
} catch (e) { /* */ }
const err = new Error(`Proxy blocked ${method} ${path}: "${title}"`);
err.status = resp.status;
err.proxyBlocked = true;
err.proxyTitle = title;
throw err;
}
const data = await resp.json().catch(() => ({}));
const err = new Error(data.error || `HTTP ${resp.status}`);
err.status = resp.status;
throw err;
}
return this._parseJSON(resp, path);
}
};
sb.ns('API', API);

View File

@@ -1,589 +0,0 @@
// ==========================================
// Chat Switchboard Application (Chat Surface)
// ==========================================
// Boot, auth, init, listener dispatch.
// Shared state (App) and model loading (fetchModels) live in app-state.js
// (loaded by base.html for all surfaces).
// Domain logic lives in chat.js, notes.js, settings-handlers.js,
// admin-handlers.js. UI rendering in ui-*.js files.
//
// Exports: handleLogin, handleRegister, handleLogout, switchAuthTab,
// startApp, initBanners
async function init() {
console.log('🔀 Chat Switchboard initializing...');
initBranding(); // Apply branding before splash is visible
API.loadTokens();
let health = null;
try {
health = await API.health();
console.log('✅ Backend reachable:', health.version);
} catch (e) {
console.error('❌ Backend unreachable:', e.message);
const splashErr = document.getElementById('splashError');
if (e.proxyBlocked) {
splashErr.innerHTML =
`<strong>Network proxy blocked this request</strong><br>` +
`Proxy response: "${API._esc(e.proxyTitle)}"<br>` +
`<span class="splash-error-hint">Ask your network admin to whitelist this domain. ` +
`<a href="#" data-action="openDebugModal" ">Run diagnostics</a></span>`;
} else if (e.name === 'TimeoutError' || e.name === 'AbortError') {
splashErr.innerHTML =
`<strong>Connection timed out</strong><br>` +
`<span class="splash-error-hint">Server may be starting up, or a proxy is blocking the connection. ` +
`<a href="#" data-action="openDebugModal" ">Run diagnostics</a></span>`;
} else {
splashErr.innerHTML =
`<strong>Cannot reach server</strong><br>` +
`<span class="splash-error-hint">${API._esc(e.message)}. ` +
`<a href="#" data-action="openDebugModal" ">Run diagnostics</a></span>`;
}
showSplash(null);
return;
}
if (API.isAuthed) {
try {
await API.getProfile();
console.log('✅ Session valid for', API.user?.username);
} catch (e) {
console.warn('⚠️ Session expired, clearing');
API.clearTokens();
}
}
if (API.isAuthed) {
try {
await startApp();
} catch (e) {
console.error('💥 startApp crashed:', e);
// Surface the error visibly (crash banner from chat.html)
const b = document.getElementById('crashBanner');
const d = document.getElementById('crashDetail');
if (b && d) { b.style.display = ''; d.textContent += 'startApp: ' + e.message + '\n' + (e.stack || '') + '\n'; }
}
} else {
showSplash(health);
}
}
async function startApp() {
hideSplash();
UI.restoreSidebar();
// v0.25.0: Surface-aware initialization.
// Common init runs on all surfaces. Chat-specific init only on chat.
const surface = window.__SURFACE__ || 'chat';
// ── Common init (all surfaces) ──────────
// v0.28.5: Ensure SDK is initialized (idempotent — base.html also calls this).
if (typeof Switchboard !== 'undefined') Switchboard.init();
if (typeof loadSettings === 'function') await loadSettings();
// Guard: if token was invalidated during startup
if (!API.isAuthed) {
console.warn('⚠️ Auth lost during startup, returning to login');
showSplash(null);
return;
}
UI.updateUser();
UI.showAdminButton(sw.isAdmin);
UI.restoreAppearance();
// Load models for all surfaces (editor needs them for model selector)
await fetchModels();
// Connect EventBus WebSocket (non-blocking)
try {
Events.connect((window.__BASE__ || '') + '/ws');
sw.on('ws.connected', () => {
if (typeof ToolsToggle !== 'undefined') ToolsToggle.refresh();
});
} catch (e) {
console.warn('EventBus connect error:', e.message);
}
// Signal that common init is complete — surface-specific JS can react
document.dispatchEvent(new CustomEvent('sb:ready', { detail: { surface } }));
// ── Chat surface init ───────────────────
if (surface === 'chat') {
await _startChatSurface();
}
// Editor, notes, admin, settings — their surface-specific JS handles
// the rest via DOMContentLoaded listeners in their own boot scripts.
}
// Chat-surface-specific initialization (extracted from monolithic startApp)
async function _startChatSurface() {
// v0.23.1: Load folders and channels before rendering sidebar
await loadFolders();
await loadChannels();
// Load extensions BEFORE chats so block renderers (mermaid, katex, csv, diff)
// are registered when messages are first rendered.
try {
await Extensions.loadAll(); // fetch manifests + inject <script> tags
await Extensions.initAll(); // build ctx, call init(), set up tool bridge
} catch (e) {
console.warn('Extension init error:', e.message);
}
await loadChats();
await loadProjects();
await loadProjectChannelPositions(); // v0.19.2: load channel order per project
// Models already loaded in common init; update chat-surface-specific UI
if (typeof UI !== 'undefined') {
UI.updateModelSelector();
UI.updateCapabilityBadges();
}
// v0.23.2: Load user list for @mention autocomplete
await loadUsers();
await initBanners();
initFiles();
// v0.23.2: Create primary ChatPane instance from server-rendered mount points
if (typeof ChatPane !== 'undefined') {
ChatPane.primary = ChatPane.create({
id: 'main',
messagesEl: document.getElementById('chatMessages'),
inputEl: document.getElementById('chatInputBar'),
standalone: false,
});
}
if (typeof ToolsToggle !== 'undefined') ToolsToggle.init();
if (typeof REPL !== 'undefined') REPL.init(); // v0.21.3: admin-gated REPL tab
if (typeof KnowledgeUI !== 'undefined') {
KnowledgeUI.init();
} else {
console.error('[App] KnowledgeUI module not defined — knowledge-ui.js failed to load or parse');
}
UI.renderChatList();
UI.renderChannelsSection();
UI.restoreSidebarSections();
UI.updateModelSelector();
// Restore last-active conversation from sessionStorage
try {
const saved = sessionStorage.getItem('cs-active-conversation');
if (saved) {
const ac = JSON.parse(saved);
if (ac?.id) {
const inChats = App.chats.some(c => c.id === ac.id);
const inChannels = (App.channels || []).some(c => c.id === ac.id);
if (inChats) {
selectChat(ac.id);
} else if (inChannels) {
selectChannel(ac.id);
}
}
}
} catch (_) {}
// If no conversation was restored, show the welcome/empty state
if (!App.activeId) {
UI.showEmptyState();
}
initListeners();
// Admin-only: persistent fallback alert banner (v0.17.0)
sw.on('role.fallback', (payload) => {
if (!sw.isAdmin) return;
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
showFallbackBanner(data);
});
console.log('✅ Chat Switchboard ready');
}
// ── Role Fallback Banner (v0.17.0) ──────────
function showFallbackBanner(data) {
let banner = document.getElementById('roleFallbackBanner');
if (!banner) {
// Create persistent banner above messages
banner = document.createElement('div');
banner.id = 'roleFallbackBanner';
banner.className = 'role-fallback-banner';
const msgs = document.getElementById('chatMessages');
if (msgs) msgs.parentNode.insertBefore(banner, msgs);
else return;
}
const msg = data.message || `Role "${data.role}" primary failed — using fallback`;
banner.innerHTML = `
<span class="fallback-icon">⚠️</span>
<span class="fallback-msg">${esc(msg)}</span>
<button class="btn-small" onclick="this.parentElement.remove()">Dismiss</button>`;
banner.style.display = '';
}
// ── Modal Helpers ───────────────────────────
// ── Auth Flow ────────────────────────────────
function showSplash(health) {
// v0.22.6: Server-rendered architecture uses /login page.
// Clear stale cookie so the redirect sticks (no loop).
document.cookie = 'sb_token=; path=/; max-age=0';
const base = window.__BASE__ || '';
window.location.href = base + '/login';
}
function hideSplash() {
const splash = document.getElementById('splashGate');
const app = document.getElementById('appContainer');
if (splash) splash.style.display = 'none';
if (app) app.style.display = '';
}
async function handleLogin() {
const login = document.getElementById('authLogin').value.trim();
const password = document.getElementById('authPassword').value;
if (!login || !password) return setAuthError('Fill in all fields');
setAuthLoading(true);
try { await API.login(login, password); await startApp(); }
catch (e) { setAuthError(e.message); }
finally { setAuthLoading(false); }
}
async function handleRegister() {
const username = document.getElementById('authUsername').value.trim();
const email = document.getElementById('authEmail').value.trim();
const password = document.getElementById('authRegPassword').value;
if (!username || !email || !password) return setAuthError('Fill in all fields');
if (password.length < 8) return setAuthError('Password must be at least 8 characters');
setAuthLoading(true);
try {
const resp = await API.register(username, email, password);
if (resp.pending) {
setAuthError('');
const errEl = document.getElementById('authError');
errEl.textContent = 'Account created — pending admin approval. You will be able to sign in once approved.';
errEl.style.color = 'var(--accent)';
setTimeout(() => { errEl.style.color = ''; switchAuthTab('login'); }, 5000);
} else {
await startApp();
}
}
catch (e) { setAuthError(e.message); }
finally { setAuthLoading(false); }
}
async function handleLogout() {
if (!await showConfirm('Sign out?')) return;
Events.disconnect();
Events.clear();
await API.logout();
App.chats = [];
App.setActive(null);
location.reload();
}
function switchAuthTab(tab) {
document.getElementById('authTabLogin').classList.toggle('active', tab === 'login');
document.getElementById('authTabRegister').classList.toggle('active', tab === 'register');
document.getElementById('authLoginForm').style.display = tab === 'login' ? '' : 'none';
document.getElementById('authRegisterForm').style.display = tab === 'register' ? '' : 'none';
document.getElementById('authLoginBtn').style.display = tab === 'login' ? '' : 'none';
document.getElementById('authRegisterBtn').style.display = tab === 'register' ? '' : 'none';
const hdr = document.querySelector('.auth-card-header');
if (hdr) {
hdr.querySelector('h2').textContent = tab === 'login' ? 'Welcome back' : 'Create account';
hdr.querySelector('p').textContent = tab === 'login'
? 'Sign in to continue to your workspace'
: 'Get started with Chat Switchboard';
}
setAuthError('');
}
function setAuthError(msg) { document.getElementById('authError').textContent = msg; }
function setAuthLoading(on) {
document.querySelectorAll('#authLoginBtn, #authRegisterBtn').forEach(btn => {
btn.disabled = on;
btn.textContent = on ? 'Please wait...' : btn.dataset.label;
});
}
// ── Event Listeners ─────────────────────────
// Thin dispatcher — domain listeners extracted to their own files.
let _listenersInit = false;
function initListeners() {
if (_listenersInit) return;
_listenersInit = true;
_initChatListeners(); // from chat.js
// _initSettingsListeners and _initAdminListeners removed in v0.37.7
// _initNotesListeners + _registerNotesPanel removed in v0.37.9 — see sw/components/notes-pane/
_initFileListeners(); // from files.js
_registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry
_registerProjectPanel(); // from projects-ui.js — register project detail panel
if (typeof Notifications !== 'undefined') Notifications.init(); // v0.20.0
_initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize
_initWorkspaceResize(); // from panels.js — workspace handle drag
_initPanelSwipe(); // from panels.js — mobile swipe navigation
_initPanelResponsive(); // from panels.js — responsive overlay
_initPanelOverlay(); // from panels.js — mobile tap-to-close overlay
_initSidebarTabs(); // v0.21.6: Chats/Files tab switching
}
function _initSidebarTabs() {
const tabBar = document.getElementById('sidebarTabs');
if (!tabBar) return;
tabBar.addEventListener('click', (e) => {
const btn = e.target.closest('.sidebar-tab');
if (!btn) return;
const tabName = btn.dataset.tab;
// Update active tab
tabBar.querySelectorAll('.sidebar-tab').forEach(t => t.classList.toggle('active', t === btn));
// Show/hide panels
document.querySelectorAll('.sidebar-tab-panel').forEach(p => {
p.style.display = p.dataset.tabPanel === tabName ? '' : 'none';
});
});
}
/** Show or hide the Files tab. Called by EditorMode on register/unregister. */
function showSidebarFilesTab(show) {
const tab = document.getElementById('sidebarFilesTab');
if (tab) tab.style.display = show ? '' : 'none';
// If hiding and Files was active, switch back to Chats
if (!show && tab?.classList.contains('active')) {
const chatsTab = document.querySelector('.sidebar-tab[data-tab="chats"]');
if (chatsTab) chatsTab.click();
}
}
function _initGlobalKeyboard() {
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Escape: stop generation → close command palette → close side panel → close topmost modal
if (e.key === 'Escape') {
if (App.isGenerating) { stopGeneration(); return; }
if (document.getElementById('lightbox')?.classList.contains('active')) {
closeLightbox(); return;
}
if (document.getElementById('cmdPalette')?.classList.contains('active')) {
closeCmdPalette(); return;
}
if (PanelRegistry.isContainerOpen()) {
PanelRegistry.closeAll(); return;
}
const open = [...document.querySelectorAll('.modal-overlay.active')];
if (open.length) closeModal(open[open.length - 1].id);
return;
}
// Ctrl/Cmd+K: command palette
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
toggleCmdPalette();
return;
}
// Ctrl/Cmd+\: cycle side panels
if ((e.ctrlKey || e.metaKey) && e.key === '\\') {
e.preventDefault();
PanelRegistry.cycle();
return;
}
// Ctrl/Cmd+Shift+S: focus sidebar search
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') {
e.preventDefault();
const sb = document.getElementById('sidebar');
if (sb.classList.contains('collapsed')) UI.toggleSidebar();
document.getElementById('chatSearchInput')?.focus();
return;
}
});
// Recheck tab overflow on resize / zoom changes
window.addEventListener('resize', () => {
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow);
});
}
// ── Branding ────────────────────────────────
function initBranding() {
const b = window.__BRANDING__;
if (!b || typeof b !== 'object') return;
const brandBase = (window.__BASE__ || '') + '/branding/';
// Accent color → CSS custom property
if (b.accent_color) {
document.documentElement.style.setProperty('--accent-color', b.accent_color);
}
// Page title
if (b.org_name) {
document.title = b.org_name;
}
// Sidebar brand text
const sidebarText = document.getElementById('brandSidebarText');
if (sidebarText && b.org_name) sidebarText.textContent = b.org_name;
// Splash hero wordmark
const wordmark = document.getElementById('brandWordmark');
if (wordmark && b.org_name) wordmark.textContent = b.org_name;
// Splash headline — replace if branding provides it
if (b.headline) {
const headline = document.getElementById('brandHeadline');
if (headline) headline.textContent = b.headline;
}
// Splash tagline
const tagline = document.getElementById('brandTagline');
if (tagline && b.tagline) tagline.textContent = b.tagline;
// Auth card text
if (b.org_name) {
const authHeader = document.querySelector('.auth-card-header h2');
if (authHeader) authHeader.textContent = 'Welcome to ' + b.org_name;
const authSub = document.querySelector('.auth-card-header p');
if (authSub) authSub.textContent = 'Sign in to continue';
}
const authFooter = document.getElementById('brandAuthFooter');
if (authFooter && b.tagline) authFooter.textContent = b.tagline;
// Logo — replace SVG with <img> in splash hero
if (b.logo) {
const logoUrl = brandBase + b.logo;
const logoEl = document.getElementById('brandLogo');
if (logoEl) {
const img = document.createElement('img');
img.src = logoUrl;
img.alt = b.org_name || 'Logo';
img.className = 'hero-logo-img';
img.onerror = function() {
this.style.display = 'none';
const svg = logoEl.querySelector('svg');
if (svg) svg.style.display = '';
};
const svg = logoEl.querySelector('svg');
if (svg) svg.style.display = 'none';
logoEl.appendChild(img);
}
// Sidebar logo — replace emoji with small img
const sidebarLogo = document.getElementById('brandSidebarLogo');
if (sidebarLogo) {
const originalEmoji = sidebarLogo.textContent;
const img = document.createElement('img');
img.src = logoUrl;
img.alt = '';
img.className = 'brand-logo-img';
img.onerror = function() {
this.remove();
sidebarLogo.textContent = originalEmoji;
};
sidebarLogo.textContent = '';
sidebarLogo.appendChild(img);
}
}
// Favicon
if (b.favicon) {
const favUrl = brandBase + b.favicon;
document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach(link => {
link.href = favUrl;
});
}
// Feature pills — custom array replaces defaults, empty array hides them
if (b.pills !== undefined) {
const pillsEl = document.getElementById('brandPills');
if (pillsEl) {
if (!Array.isArray(b.pills) || b.pills.length === 0) {
pillsEl.style.display = 'none';
} else {
pillsEl.innerHTML = b.pills.map(p => {
const style = p.style === 'accent' ? ' accent' : p.style === 'purple' ? ' purple' : '';
return `<div class="hero-pill${style}"><span class="pill-icon">${p.icon || ''}</span> ${p.text}</div>`;
}).join('');
}
}
}
console.log('🎨 Branding applied:', b.org_name || '(defaults)');
}
// ── Banners ──────────────────────────────────
async function initBanners() {
try {
const data = await API.getPublicSettings?.() || {};
// Store policies for user-facing checks (allow_user_byok, etc.)
App.policies = data.policies || {};
// Storage capabilities (file upload, vision)
App.storageConfigured = !!data.storage_configured;
// Paste-to-file threshold (synced from backend, default 2000)
App.pasteToFileChars = data.paste_to_file_chars || 2000;
// Also flatten into serverSettings for backward compat
App.serverSettings = {};
if (data.banner) App.serverSettings.banner = data.banner;
if (data.branding) App.serverSettings.branding = data.branding;
const root = document.documentElement;
const banner = data.banner;
// Clear previous banner state
['bannerTop', 'bannerBottom'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.classList.remove('active'); el.textContent = ''; }
});
root.style.setProperty('--banner-top-height', '0px');
root.style.setProperty('--banner-bottom-height', '0px');
if (!banner || !banner.enabled) return;
root.style.setProperty('--banner-bg', banner.bg || '#007a33');
root.style.setProperty('--banner-fg', banner.fg || '#ffffff');
const text = banner.text || '';
const top = document.getElementById('bannerTop');
top.textContent = text;
top.classList.add('active');
root.style.setProperty('--banner-top-height', '22px');
const bot = document.getElementById('bannerBottom');
bot.textContent = text;
bot.classList.add('active');
root.style.setProperty('--banner-bottom-height', '22px');
} catch (e) {
// Banners are optional — non-admin users may not have access to settings
console.debug('Banner init skipped:', e.message);
}
}
// ── Boot ─────────────────────────────────────
document.addEventListener('DOMContentLoaded', init);
// ── Exports ─────────────────────────────────
sb.register('handleLogin', handleLogin);
sb.register('handleRegister', handleRegister);
sb.register('handleLogout', handleLogout);
sb.register('switchAuthTab', switchAuthTab);
sb.register('startApp', startApp);
sb.register('initBanners', initBanners);

View File

@@ -1,437 +0,0 @@
// ==========================================
// Chat Switchboard Channel Models (v0.20.0)
// ==========================================
// Multi-model management per channel.
// - Model pills in chat header (add/remove/set default)
// - @mention autocomplete in chat input
// - Model attribution on assistant messages
// ==========================================
//
// Exports: window.ChannelModels
const ChannelModels = {
_roster: [], // current channel's model roster
_channelId: null, // current channel ID
_acVisible: false, // autocomplete dropdown visible
// ── Init / Lifecycle ────────────────────
init() {
// Close autocomplete on click-outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.mention-ac')) {
this.hideAutocomplete();
}
});
},
// ── Roster Management ───────────────────
async load(channelId) {
this._channelId = channelId;
if (!channelId) {
this._roster = [];
this.renderPills();
return;
}
try {
const roster = await API.listChannelModels(channelId);
const models = roster.models || roster;
this._roster = Array.isArray(models) ? models : [];
} catch (e) {
console.debug('Channel models not loaded:', e.message);
this._roster = [];
}
this.renderPills();
},
getRoster() { return this._roster; },
getDefault() {
return this._roster.find(m => m.is_default) || this._roster[0] || null;
},
// ── Model Pills UI ──────────────────────
renderPills() {
const container = document.getElementById('channelModelPills');
if (!container) return;
if (this._roster.length <= 1) {
container.style.display = 'none';
container.innerHTML = '';
return;
}
container.style.display = '';
const html = this._roster.map(m => `
<span class="ch-model-pill${m.is_default ? ' ch-model-default' : ''}"
data-model-id="${esc(m.id)}" title="${esc(m.model_id)}">
<span class="ch-model-pill-name" data-action="ChannelModels.setDefault" data-args='${JSON.stringify([esc(m.id)])}'">${esc(m.display_name)}</span>
<button class="ch-model-pill-remove" data-action="ChannelModels.remove" data-args='${JSON.stringify([esc(m.id)])}'" title="Remove model">✕</button>
</span>
`).join('') + `
<button class="ch-model-add-btn" data-action="ChannelModels.showAddDialog" " title="Add model to channel">+ Add</button>
`;
container.innerHTML = html;
},
async remove(recordId) {
if (!this._channelId) return;
try {
const resp = await API.deleteChannelModel(this._channelId, recordId);
this._roster = resp.models || [];
this.renderPills();
} catch (e) {
UI.toast('Failed to remove model: ' + e.message, 'error');
}
},
async setDefault(recordId) {
if (!this._channelId) return;
try {
const resp = await API.updateChannelModel(this._channelId, recordId, { is_default: true });
this._roster = resp.models || [];
this.renderPills();
} catch (e) {
UI.toast('Failed to set default: ' + e.message, 'error');
}
},
// ── Add Model Dialog ────────────────────
showAddDialog() {
if (!App.models || App.models.length === 0) {
UI.toast('No models available — configure a provider first', 'warning');
return;
}
// Filter out models already in the roster
const rosterModelIds = new Set(this._roster.map(m => m.model_id));
const available = App.models.filter(m => !m.isPersona && !m.hidden && !rosterModelIds.has(m.baseModelId || m.id));
if (available.length === 0) {
UI.toast('All available models are already added to this channel', 'info');
return;
}
const html = `
<div class="ch-model-add-dialog" id="chModelAddDialog">
<div class="ch-model-add-inner">
<h3>Add Model to Channel</h3>
<label>Model
<select id="chModelAddSelect">
${available.map(m => `<option value="${esc(m.baseModelId || m.id)}" data-config="${esc(m.configId || '')}" data-name="${esc(m.name)}">${esc(m.name)}${m.provider ? ` (${esc(m.provider)})` : ''}</option>`).join('')}
</select>
</label>
<label>Display Name (used for @mentions)
<input type="text" id="chModelAddName" placeholder="e.g. Claude-3-Opus" maxlength="50">
</label>
<div class="ch-model-add-actions">
<button class="btn-secondary" data-action="ChannelModels.closeAddDialog" ">Cancel</button>
<button class="btn-primary" data-action="ChannelModels.submitAdd" ">Add</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', html);
// Auto-fill display name from selected model
const select = document.getElementById('chModelAddSelect');
const nameInput = document.getElementById('chModelAddName');
nameInput.value = _shortName(select.options[0]?.dataset.name || '');
select.addEventListener('change', () => {
nameInput.value = _shortName(select.options[select.selectedIndex]?.dataset.name || '');
});
// Focus and select
nameInput.focus();
nameInput.select();
},
closeAddDialog() {
document.getElementById('chModelAddDialog')?.remove();
},
async submitAdd() {
const select = document.getElementById('chModelAddSelect');
const nameInput = document.getElementById('chModelAddName');
if (!select || !nameInput) return;
const modelId = select.value;
const configId = select.options[select.selectedIndex]?.dataset.config || '';
const displayName = nameInput.value.trim();
if (!displayName) {
UI.toast('Display name is required', 'warning');
return;
}
try {
const resp = await API.addChannelModel(this._channelId, {
model_id: modelId,
provider_config_id: configId,
display_name: displayName,
is_default: this._roster.length === 0
});
this._roster = resp.models || [];
this.renderPills();
this.closeAddDialog();
} catch (e) {
UI.toast('Failed to add model: ' + e.message, 'error');
}
},
// ── @mention Autocomplete ───────────────
/**
* Called from the chat input's keyup/input handler.
* Shows a dropdown of channel models when user types @.
*/
onInput(inputEl) {
const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value;
const cursorPos = typeof inputEl.getCursorPos === 'function' ? inputEl.getCursorPos() : inputEl.selectionStart;
const before = text.slice(0, cursorPos);
const atIdx = before.lastIndexOf('@');
if (atIdx < 0) { this.hideAutocomplete(); return; }
if (atIdx > 0 && before[atIdx - 1] !== ' ' && before[atIdx - 1] !== '\n') {
this.hideAutocomplete();
return;
}
const partial = before.slice(atIdx + 1).toLowerCase().replace(/-/g, ' ');
if (partial.length === 0) {
// Just typed @ — show everything
}
// Build match list from ALL enabled models + personas (not just roster)
const allModels = (App.models || []).filter(m => !m.hidden);
const modelMatches = allModels.filter(m => {
const handle = (m.personaHandle || '').toLowerCase().replace(/-/g, ' ');
const modelId = (m.baseModelId || m.id || '').toLowerCase().replace(/-/g, ' ');
const name = (m.name || '').toLowerCase().replace(/-/g, ' ');
const firstName = name.split(' ')[0] || '';
if (partial.length === 0) return true;
return handle.startsWith(partial)
|| modelId.startsWith(partial)
|| name.startsWith(partial)
|| firstName.startsWith(partial);
});
// v0.24.0: Context-aware user autocomplete.
// direct / dm : no users (1:1 AI or already-defined human pair)
// group : participants only (defined roster)
// channel : full user list (open, ad-hoc)
const activeType = App.activeType || 'direct';
let userPool = [];
if (activeType === 'channel') {
userPool = App.users || [];
} else if (activeType === 'group') {
const pids = App.activeParticipants || [];
userPool = (App.users || []).filter(u => pids.includes(u.id));
}
// direct and dm: userPool stays empty
const userMatches = userPool.filter(u => {
const uhandle = (u.handle || '').toLowerCase();
const uname = u.username.toLowerCase();
const dname = (u.displayName || '').toLowerCase();
if (partial.length === 0) return true;
return uhandle.startsWith(partial) || uname.startsWith(partial) || dname.startsWith(partial);
}).map(u => ({
id: u.handle || u.username,
name: u.displayName || u.username,
baseModelId: u.handle || u.username,
isPersona: false,
isUser: true,
personaHandle: null,
personaAvatar: null,
provider: '',
}));
// Resolution order: personas first, then users, then models
const personas = modelMatches.filter(m => m.isPersona);
const models = modelMatches.filter(m => !m.isPersona);
const matches = [...personas, ...userMatches, ...models];
if (matches.length === 0) { this.hideAutocomplete(); return; }
// Cap at 10 to keep dropdown manageable
this.showAutocomplete(inputEl, atIdx, cursorPos, matches.slice(0, 10));
},
showAutocomplete(inputEl, atIdx, cursorPos, matches) {
this.hideAutocomplete();
const wrap = document.createElement('div');
wrap.className = 'mention-ac';
wrap.id = 'mentionAc';
const inputRect = (inputEl.el || inputEl).getBoundingClientRect();
wrap.style.bottom = (window.innerHeight - inputRect.top + 4) + 'px';
wrap.style.left = inputRect.left + 'px';
wrap.style.minWidth = '280px';
matches.forEach((m, i) => {
const item = document.createElement('div');
item.className = 'mention-ac-item' + (i === 0 ? ' mention-ac-active' : '');
// Determine the @handle to insert
const mentionToken = m.isPersona
? (m.personaHandle || m.name?.replace(/\s+/g, '-').toLowerCase() || m.id)
: (m.baseModelId || m.id);
item.dataset.handle = mentionToken;
const avatar = m.personaAvatar
? `<img src="${esc(m.personaAvatar)}" class="mention-ac-avatar" alt="">`
: m.isPersona
? `<span class="mention-ac-avatar mention-ac-avatar-fallback">⚡</span>`
: m.isUser
? `<span class="mention-ac-avatar mention-ac-avatar-fallback mention-ac-user">👤</span>`
: `<span class="mention-ac-avatar mention-ac-avatar-fallback">🤖</span>`;
const handleText = m.isPersona && m.personaHandle
? `@${esc(m.personaHandle)}`
: m.isUser
? `@${esc(m.baseModelId)}`
: `@${esc(m.baseModelId || m.id)}`;
const providerHint = m.isUser ? 'user' : (m.provider ? esc(m.provider) : '');
item.innerHTML = `${avatar}<div class="mention-ac-info"><span class="mention-ac-name">${esc(m.name || m.id)}</span><span class="mention-ac-handle">${handleText}</span></div><span class="mention-ac-model">${providerHint}</span>`;
item.addEventListener('click', () => {
this._insertMention(inputEl, atIdx, cursorPos, mentionToken);
});
wrap.appendChild(item);
});
document.body.appendChild(wrap);
this._acVisible = true;
},
hideAutocomplete() {
document.getElementById('mentionAc')?.remove();
this._acVisible = false;
},
isAutocompleteVisible() { return this._acVisible; },
/**
* Handle arrow keys and Enter when autocomplete is visible.
* Returns true if the event was consumed.
*/
handleKey(e) {
if (!this._acVisible) return false;
const ac = document.getElementById('mentionAc');
if (!ac) return false;
const items = ac.querySelectorAll('.mention-ac-item');
let activeIdx = Array.from(items).findIndex(el => el.classList.contains('mention-ac-active'));
if (e.key === 'ArrowDown') {
e.preventDefault();
items[activeIdx]?.classList.remove('mention-ac-active');
activeIdx = (activeIdx + 1) % items.length;
items[activeIdx]?.classList.add('mention-ac-active');
return true;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
items[activeIdx]?.classList.remove('mention-ac-active');
activeIdx = (activeIdx - 1 + items.length) % items.length;
items[activeIdx]?.classList.add('mention-ac-active');
return true;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const active = items[activeIdx];
if (active) active.click();
return true;
}
if (e.key === 'Escape') {
this.hideAutocomplete();
return true;
}
return false;
},
_insertMention(inputEl, atIdx, cursorPos, displayName) {
const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value;
const before = text.slice(0, atIdx);
const after = text.slice(cursorPos);
const newText = before + '@' + displayName + ' ' + after;
if (typeof inputEl.setValue === 'function') {
inputEl.setValue(newText);
} else {
inputEl.value = newText;
}
// Position cursor after the inserted mention
const newPos = atIdx + 1 + displayName.length + 1;
if (typeof inputEl.setCursorPos === 'function') {
inputEl.setCursorPos(newPos);
} else {
inputEl.selectionStart = inputEl.selectionEnd = newPos;
}
this.hideAutocomplete();
inputEl.focus?.();
},
// ── Model Display Name Resolution ───────
/**
* Resolve a model_id to a display name using the current roster.
* Falls back to App.models lookup, then the raw model_id.
*/
resolveDisplayName(modelId) {
if (!modelId) return 'Assistant';
const cm = this._roster.find(m => m.model_id === modelId);
if (cm?.display_name) return cm.display_name;
const m = App.models?.find(x => x.id === modelId || x.baseModelId === modelId);
return m?.name || modelId;
},
/**
* Resolve persona avatar for a channel model roster entry.
* Looks up via persona_id in App.models.
*/
_resolveAvatar(rosterEntry) {
if (!rosterEntry.persona_id) return null;
const persona = App.models?.find(m => m.personaId === rosterEntry.persona_id);
return persona?.personaAvatar || null;
},
/**
* Get persona info for a given model_id from the roster + App.models.
* Returns { displayName, avatar, personaId } or null.
*/
resolvePersonaInfo(modelId) {
const cm = this._roster.find(m => m.model_id === modelId);
if (!cm) return null;
const avatar = this._resolveAvatar(cm);
return {
displayName: cm.display_name || modelId,
avatar: avatar,
personaId: cm.persona_id || null,
};
}
};
// ── Helpers ──────────────────────────────────
function _shortName(fullName) {
// "Claude 3 Opus (Anthropic)" → "Claude-3-Opus"
return fullName
.replace(/\s*\([^)]*\)\s*$/, '') // strip "(provider)"
.trim()
.replace(/\s+/g, '-'); // spaces → hyphens
}
// ── Exports ─────────────────────────────────
sb.ns('ChannelModels', ChannelModels);

View File

@@ -1,160 +0,0 @@
// ==========================================
// Chat Switchboard - ChatPane Component (DOM-binding mode)
// ==========================================
// v0.37.8: DOM-binding mode only. Standalone mode (mount + streaming)
// moved to Preact ChatPane kit (src/js/sw/components/chat-pane/).
// This file is retained for app.js ChatPane.primary on the legacy
// chat surface. Will be deleted in v0.37.10.
//
// Exports: window.ChatPane
const ChatPane = {
...createComponentRegistry('ChatPane'),
create(opts) {
const id = opts.id || 'pane-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
const instance = componentMixin({
id,
messagesEl: opts.messagesEl,
inputEl: opts.inputEl,
sendBtnEl: opts.sendBtnEl || null,
modelSelEl: opts.modelSelEl || null,
toolbarEl: opts.toolbarEl || null,
channelId: opts.channelId || null,
standalone: opts.standalone || false,
_cmView: null,
_typing: null,
renderMessages(messages) {
if (!this.messagesEl) return;
if (typeof UI !== 'undefined' && UI._renderMessages) {
UI._renderMessages(messages, this);
} else {
this.messagesEl.innerHTML = '';
(messages || []).forEach(m => {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--' + m.role;
div.innerHTML = typeof formatMessage === 'function'
? formatMessage(m) : esc(m.content || '');
this.messagesEl.appendChild(div);
});
}
this.scrollToBottom();
},
appendChunk(html) {
if (!this.messagesEl) return;
const last = this.messagesEl.querySelector('.chat-msg--streaming');
if (last) {
const content = last.querySelector('.chat-msg__content');
if (content) content.innerHTML = html;
} else {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--assistant chat-msg--streaming';
div.innerHTML = '<div class="chat-msg__content">' + html + '</div>';
this.messagesEl.appendChild(div);
}
this.scrollToBottom();
},
finalizeStream() {
if (!this.messagesEl) return;
const el = this.messagesEl.querySelector('.chat-msg--streaming');
if (el) el.classList.remove('chat-msg--streaming');
},
appendTyping() {
if (!this.messagesEl || this._typing) return;
this._typing = document.createElement('div');
this._typing.className = 'chat-msg chat-msg--typing';
this._typing.innerHTML = '<div class="typing-dots"><span></span><span></span><span></span></div>';
this.messagesEl.appendChild(this._typing);
this.scrollToBottom();
},
removeTyping() {
if (this._typing) { this._typing.remove(); this._typing = null; }
},
scrollToBottom(smooth) {
if (!this.messagesEl) return;
const el = this.messagesEl;
if (smooth) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
else el.scrollTop = el.scrollHeight;
},
isScrolledToBottom() {
if (!this.messagesEl) return true;
const el = this.messagesEl;
return (el.scrollHeight - el.scrollTop - el.clientHeight) < 60;
},
setChannel(channelId) { this.channelId = channelId; },
getChannel() { return this.channelId; },
showWelcome(html) {
if (!this.messagesEl) return;
this.messagesEl.innerHTML = html || '<div class="chat-welcome">' +
'<p class="chat-welcome__title">Start a conversation</p>' +
'<p class="chat-welcome__hint">Type a message below to get started.</p></div>';
},
clear() {
if (this.messagesEl) this.messagesEl.innerHTML = '';
this.removeTyping();
},
getInputValue() {
if (this._cmView) return this._cmView.state.doc.toString();
const ta = this.inputEl && this.inputEl.querySelector('textarea');
return ta ? ta.value : '';
},
setInputValue(val) {
if (this._cmView) {
this._cmView.dispatch({
changes: { from: 0, to: this._cmView.state.doc.length, insert: val }
});
return;
}
const ta = this.inputEl && this.inputEl.querySelector('textarea');
if (ta) ta.value = val;
},
clearInput() { this.setInputValue(''); },
focusInput() {
if (this._cmView) this._cmView.focus();
else { const ta = this.inputEl && this.inputEl.querySelector('textarea'); if (ta) ta.focus(); }
},
_cleanup() {
if (this._cmView) { this._cmView.destroy(); this._cmView = null; }
this.removeTyping();
},
}, ChatPane);
ChatPane._register(id, instance);
return instance;
},
forChannel(channelId) {
for (const [, inst] of this._instances) {
if (inst.channelId === channelId) return inst;
}
return null;
},
active() { return this.primary; },
};
// v0.37.8: Standalone mode (mount + streaming + model selector + history)
// moved to Preact ChatPane kit at src/js/sw/components/chat-pane/.
// sw.chat() now prefers the new Preact path; this file retains only
// DOM-binding mode for the legacy chat surface (app.js ChatPane.primary).
// ── Exports ─────────────────────────────────
sb.ns('ChatPane', ChatPane);

File diff suppressed because it is too large Load Diff

View File

@@ -1,506 +0,0 @@
// ==========================================
// Chat Switchboard Extension System
// ==========================================
// Loader, registry, scoped context, and renderer pipeline.
// Tier 0 (browser) extensions register here. The ctx object
// enforces permissions declared in the manifest.
//
// Usage:
// Extensions.register({
// id: 'my-ext',
// init(ctx) { ctx.renderers.register(...); },
// destroy() { /* cleanup */ }
// });
//
// Load order: events.js → extensions.js → [ext scripts] → api.js → app.js
// ==========================================
//
// Exports: window.Extensions
const Extensions = {
// ── State ────────────────────────────────
_registry: new Map(), // extId → { def, ctx, instance }
_renderers: [], // sorted by priority
_toolHandlers: new Map(), // toolName → { extId, handler }
_loaded: false,
_manifests: [], // loaded from server
// ── Script Loading ────────────────────────
/**
* Fetch enabled browser extensions from the server and inject their scripts.
* Called before app init so extensions can register before initAll().
*/
async loadAll() {
try {
const resp = await API._get('/api/v1/extensions?tier=browser');
const exts = resp.data || [];
this._manifests = exts;
for (const ext of exts) {
// Parse manifest to check for _script (inline) or entry (file)
const manifest = ext.manifest || {};
const extId = ext.id;
if (manifest._script) {
// Inject script tag pointing at the asset endpoint
await this._injectScript(extId, `${window.__BASE__ || ''}/api/v1/extensions/${extId}/assets/main.js`);
} else if (manifest.entry) {
await this._injectScript(extId, `${window.__BASE__ || ''}/api/v1/extensions/${extId}/assets/${manifest.entry}`);
}
}
console.log(`[Extensions] Loaded ${exts.length} browser extension(s)`);
} catch (e) {
console.warn('[Extensions] Failed to load extensions:', e.message || e);
}
},
/**
* Inject a script tag and wait for it to load.
*/
_injectScript(extId, src) {
return new Promise((resolve) => {
const script = document.createElement('script');
script.src = src;
script.dataset.extension = extId;
script.onload = () => { resolve(); };
script.onerror = () => {
console.error(`[Extensions] Failed to load script for ${extId}: ${src}`);
resolve(); // Don't block other extensions
};
document.head.appendChild(script);
});
},
// ── Registration ─────────────────────────
/**
* Register a browser extension. Called by extension scripts.
* @param {object} def — { id, init(ctx), destroy() }
*/
register(def) {
if (!def || !def.id) {
console.error('[Extensions] register() requires an id');
return;
}
if (this._registry.has(def.id)) {
console.warn(`[Extensions] ${def.id} already registered, skipping`);
return;
}
const entry = { def, ctx: null, instance: null, active: false };
this._registry.set(def.id, entry);
console.log(`[Extensions] Registered: ${def.id}`);
},
// ── Lifecycle ────────────────────────────
/**
* Initialize all registered extensions. Called once after app startup.
* Builds scoped ctx for each and calls init().
*/
async initAll() {
// Set up the tool bridge: listen for tool.call.* events from server
this._setupToolBridge();
for (const [id, entry] of this._registry) {
if (entry.active) continue;
try {
entry.ctx = this._buildContext(id, entry.def);
if (typeof entry.def.init === 'function') {
await entry.def.init.call(entry.def, entry.ctx);
}
entry.active = true;
console.log(`[Extensions] Initialized: ${id}`);
} catch (e) {
console.error(`[Extensions] Failed to init ${id}:`, e);
}
}
this._loaded = true;
Events.emit('extension.loaded', { count: this._registry.size }, { localOnly: true });
},
/**
* Destroy all extensions (logout/cleanup).
*/
destroyAll() {
for (const [id, entry] of this._registry) {
if (!entry.active) continue;
try {
if (typeof entry.def.destroy === 'function') {
entry.def.destroy.call(entry.def);
}
} catch (e) {
console.error(`[Extensions] Failed to destroy ${id}:`, e);
}
entry.active = false;
entry.ctx = null;
}
this._renderers = [];
this._loaded = false;
},
// ── Context Builder ──────────────────────
/**
* Build a scoped context object for an extension.
* Each extension gets its own ctx with permission-aware proxies.
*/
_buildContext(extId, def) {
const manifest = def.manifest || {};
const permissions = new Set(manifest.permissions || []);
return {
// Extension identity
id: extId,
// Event bus (scoped — could filter by permissions later)
events: {
on: (label, fn) => Events.on(label, fn),
once: (label, fn) => Events.once(label, fn),
off: (label, fn) => Events.off(label, fn),
emit: (label, payload) => Events.emit(label, payload, { localOnly: true }),
},
// Scoped localStorage namespace
storage: {
get: (key) => {
try {
return JSON.parse(localStorage.getItem(`ext::${extId}::${key}`));
} catch { return null; }
},
set: (key, value) => {
localStorage.setItem(`ext::${extId}::${key}`, JSON.stringify(value));
},
remove: (key) => {
localStorage.removeItem(`ext::${extId}::${key}`);
},
},
// Extension settings (read-only, from manifest defaults + user overrides)
settings: Object.freeze(Object.assign(
{},
Extensions._extractDefaults(manifest.settings || {}),
Extensions._getUserSettings(extId)
)),
// Renderer registration
renderers: {
register: (name, opts) => Extensions._registerRenderer(extId, name, opts),
},
// Tool registration (browser tool bridge)
tools: {
handle: (name, fn) => {
Extensions._toolHandlers.set(name, { extId, handler: fn });
console.log(`[Extensions] Tool registered: ${extId}:${name}`);
},
},
// UI primitives — safe wrappers around primary UI components.
// Extensions use these instead of reaching into globals directly.
ui: {
/** Show a toast notification. type: 'success' | 'error' | 'warning' | 'info' */
toast(msg, type = 'success') {
if (typeof UI !== 'undefined' && UI.toast) UI.toast(msg, type);
},
/** Open the side-panel preview with arbitrary HTML content. */
openPreview(html) {
const frame = document.getElementById('previewFrame');
const empty = document.getElementById('previewEmpty');
if (frame) {
frame.srcdoc = html;
frame.style.display = '';
}
if (empty) empty.style.display = 'none';
if (typeof PanelRegistry !== 'undefined') {
PanelRegistry.open('preview');
PanelRegistry.refreshActions();
}
},
/** Is the current theme dark? */
isDark() {
return document.body.classList.contains('dark-theme') ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
},
/** Is the viewport at mobile width (≤ 768px)? */
isMobile() {
return window.innerWidth <= 768;
},
/** Is the side panel container currently open? */
isPanelOpen() {
if (typeof PanelRegistry !== 'undefined') return PanelRegistry.isContainerOpen();
return false;
},
/** Show a confirm dialog. Returns Promise<boolean>. */
confirm(msg, opts) {
if (typeof showConfirm === 'function') return showConfirm(msg, opts);
return Promise.resolve(window.confirm(msg));
},
/**
* Replace a surface region's content with a new element.
* Current children are preserved in memory (not destroyed)
* and can be restored later. Critical for CM6 state preservation.
*/
replace(regionId, element) {
console.warn(`[Extensions] ui.replace() requires surface system (removed in v0.22.6)`);
},
/**
* Restore a surface region's previously saved content.
*/
restore(regionId) {
console.warn(`[Extensions] ui.restore() requires surface system (removed in v0.22.6)`);
},
/** Inject an element into a named UI region (stub for future use). */
inject(region, el) {
console.warn(`[Extensions] ui.inject() not yet implemented (${extId}:${region})`);
},
/** Create a popup menu anchored to an element. */
createMenu(anchor, opts) {
if (typeof createPopupMenu === 'function') return createPopupMenu(anchor, opts);
return null;
},
},
// Surface registration (removed v0.22.6 — server-rendered surfaces)
surfaces: {
register: (id, opts) => {
console.warn(`[Extensions] surfaces.register() removed in v0.22.6`);
},
unregister: (id) => {
console.warn(`[Extensions] surfaces.unregister() removed in v0.22.6`);
},
activate: (id) => {
console.warn(`[Extensions] surfaces.activate() removed in v0.22.6`);
},
getCurrent: () => {
return window.__SURFACE__ || 'chat';
},
},
// Model info (resolved at call time)
get model() {
return {
id: typeof currentModelId !== 'undefined' ? currentModelId : null,
};
},
// User info
get user() {
return {
id: typeof API !== 'undefined' ? API.user?.id : null,
username: typeof API !== 'undefined' ? API.user?.username : null,
role: typeof API !== 'undefined' ? API.user?.role : null,
};
},
// Proxied API fetch
api: {
fetch: (path, opts) => {
if (typeof API !== 'undefined' && typeof API._fetch === 'function') {
return API._fetch(path, opts);
}
return fetch(path, opts);
},
},
};
},
// ── Renderer Pipeline ────────────────────
/**
* Register a custom renderer.
* @param {string} extId — owning extension
* @param {string} name — renderer name (unique within extension)
* @param {object} opts — { pattern, render, priority, type }
*
* Types:
* 'block' — operates on code blocks (receives lang, code, container)
* 'inline' — operates on the full message HTML (receives html string, returns html string)
* 'post' — operates on the rendered DOM (receives container element)
*/
_registerRenderer(extId, name, opts) {
if (!opts || !opts.render) {
console.error(`[Extensions] Renderer ${extId}:${name} requires a render function`);
return;
}
const renderer = {
extId,
name,
type: opts.type || 'block',
priority: opts.priority || 50,
pattern: opts.pattern || null,
render: opts.render,
match: opts.match || null, // function(lang, code) → bool
};
this._renderers.push(renderer);
this._renderers.sort((a, b) => a.priority - b.priority);
// v0.28.5: Shim 'post' renderers into the SDK render pipe.
// This makes old ctx.renderers.register() calls visible in
// sw.pipe.list() and ordered alongside new sw.pipe.render() filters.
// Block renderers stay in the old path (they run during formatMessage,
// not after DOM insertion).
if (renderer.type === 'post' && typeof sw !== 'undefined' && sw.pipe) {
sw.pipe.render(renderer.priority, (renderCtx) => {
try {
renderer.render(renderCtx.element);
} catch (e) {
console.error(`[Extensions] Shimmed post-renderer ${extId}:${name} error:`, e);
}
return renderCtx;
}, { source: `${extId}:${name}` });
}
console.log(`[Extensions] Renderer registered: ${extId}:${name} (type=${renderer.type}, priority=${renderer.priority})`);
},
/**
* Run block renderers on a code block element.
* Called by ui-format.js after creating the code block DOM.
* Returns true if a renderer handled the block (caller should skip default).
*
* @param {string} lang — language tag
* @param {string} code — raw code content
* @param {HTMLElement} container — the code block wrapper element
*/
runBlockRenderers(lang, code, container) {
for (const r of this._renderers) {
if (r.type !== 'block') continue;
let matched = false;
if (r.match && typeof r.match === 'function') {
matched = r.match(lang, code);
} else if (r.pattern instanceof RegExp) {
matched = r.pattern.test(lang);
} else if (typeof r.pattern === 'string') {
matched = lang === r.pattern;
}
if (matched) {
try {
r.render(lang, code, container);
return true;
} catch (e) {
console.error(`[Extensions] Renderer ${r.extId}:${r.name} error:`, e);
}
}
}
return false;
},
/**
* Run post-render DOM processors on a message container.
* Called after the full message HTML is inserted into the DOM.
*
* @param {HTMLElement} container — the message content element
*/
runPostRenderers(container) {
for (const r of this._renderers) {
if (r.type !== 'post') continue;
try {
r.render(container);
} catch (e) {
console.error(`[Extensions] Post-renderer ${r.extId}:${r.name} error:`, e);
}
}
},
// ── Helpers ──────────────────────────────
_extractDefaults(settingsSchema) {
const defaults = {};
for (const [key, def] of Object.entries(settingsSchema)) {
if (def && 'default' in def) defaults[key] = def.default;
}
return defaults;
},
_getUserSettings(extId) {
// Find user settings from the manifest data loaded from API
const manifest = this._manifests.find(m => m.id === extId);
if (manifest?.user_settings) {
try {
return typeof manifest.user_settings === 'string'
? JSON.parse(manifest.user_settings)
: manifest.user_settings;
} catch { return {}; }
}
return {};
},
/**
* Set up the WebSocket bridge for browser tool execution.
* Listens for tool.call.* events from the server, routes to
* the registered handler, and sends tool.result.* back.
*/
_setupToolBridge() {
Events.on('tool.call.*', async (payload, meta) => {
const { call_id, tool, arguments: args } = payload || {};
if (!call_id || !tool) return;
const registration = this._toolHandlers.get(tool);
if (!registration) {
// No handler registered — send error back
Events.emit(`tool.result.${call_id}`, {
call_id,
error: JSON.stringify({ error: `no handler for tool: ${tool}` }),
});
return;
}
try {
const parsedArgs = typeof args === 'string' ? JSON.parse(args) : args;
const result = await registration.handler(parsedArgs);
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
Events.emit(`tool.result.${call_id}`, {
call_id,
result: resultStr,
});
} catch (e) {
console.error(`[Extensions] Tool ${tool} error:`, e);
Events.emit(`tool.result.${call_id}`, {
call_id,
error: JSON.stringify({ error: e.message || 'tool execution failed' }),
});
}
});
},
/**
* Check if any renderers are registered for a given type.
*/
hasRenderers(type) {
return this._renderers.some(r => r.type === type);
},
/**
* Get info about all registered extensions (for debug/admin).
*/
debug() {
const exts = {};
for (const [id, entry] of this._registry) {
exts[id] = {
active: entry.active,
renderers: this._renderers.filter(r => r.extId === id).map(r => r.name),
};
}
return { extensions: exts, rendererCount: this._renderers.length };
},
};
// ── Exports ─────────────────────────────────
sb.ns('Extensions', Extensions);

View File

@@ -1,857 +0,0 @@
// ==========================================
// Chat Switchboard Files
// ==========================================
// File upload, smart paste, drag-and-drop, extraction
// polling, staged lifecycle, message rendering, lightbox,
// and admin storage panel.
//
// Depends on: API, App, UI (toast, getModelValue, renderChatList)
// Consumed by: chat.js (send flow), ui-core.js (message rendering),
// ui-admin.js (storage tab)
// ── Constants ───────────────────────────────
const FILE_POLL_INTERVAL = 2000; // ms between extraction status checks
const FILE_MAX_PER_MSG = 5; // mirrors backend defaultMaxFilesPerMsg
// MIME categories for capability-aware filtering
const IMAGE_TYPES = new Set([
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
]);
// ── State ───────────────────────────────────
// Staged files waiting to be sent with the next message.
// Each entry:
// {
// localId: string — crypto.randomUUID(), stable key for DOM
// serverId: string? — file UUID from backend (null while uploading)
// filename: string
// contentType: string
// sizeBytes: number
// previewUrl: string? — object URL for image preview (revoked on remove)
// status: 'uploading' | 'queued' | 'processing' | 'complete' | 'failed' | 'error'
// uploadProgress: number — 0100 (only meaningful during 'uploading')
// extractionStatus: string? — mirrors backend metadata.extraction_status
// queuePosition: number? — extraction queue position (0 = processing)
// error: string? — error message if status is 'error' or 'failed'
// }
// App.stagedFiles is initialized in app.js.
// Polling timers are tracked here for cleanup.
const _pollTimers = new Map(); // localId → intervalId
// ── Upload ──────────────────────────────────
/**
* Stage a file for upload. Creates the staged entry, uploads to backend,
* and starts extraction polling.
*
* If no current chat exists, creates one first (same pattern as sendMessage).
*
* @param {File} file — File object from input, paste, or drop
* @returns {Promise<void>}
*/
async function stageFile(file) {
if (!App.storageConfigured) return;
// Enforce per-message limit
if (App.stagedFiles.length >= FILE_MAX_PER_MSG) {
UI.toast(`Maximum ${FILE_MAX_PER_MSG} files per message`, 'warning');
return;
}
// Build staged entry
const staged = {
localId: crypto.randomUUID(),
serverId: null,
filename: file.name,
contentType: file.type || 'application/octet-stream',
sizeBytes: file.size,
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
status: 'uploading',
uploadProgress: 0,
extractionStatus: null,
queuePosition: null,
error: null,
};
App.stagedFiles.push(staged);
_renderStrip();
_updateSendBlock();
// Ensure a channel exists (files are channel-scoped)
if (!App.activeId) {
try {
const title = file.name.slice(0, 50);
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
const chat = {
id: resp.id, title: resp.title, type: 'direct',
model, messages: [], messageCount: 0, updatedAt: resp.updated_at,
};
App.chats.unshift(chat);
App.setActive(chat.id, 'direct');
UI.renderChatList();
} catch (e) {
staged.status = 'error';
staged.error = 'Failed to create chat: ' + e.message;
_renderStrip();
_updateSendBlock();
return;
}
}
// Upload
try {
const result = await API.uploadFile(App.activeId, file);
staged.serverId = result.id;
staged.status = _mapExtractionStatus(result.metadata?.extraction_status);
staged.extractionStatus = result.metadata?.extraction_status || null;
staged.queuePosition = result.metadata?.queue_position ?? null;
// Start polling if extraction is pending/processing
if (staged.status === 'queued' || staged.status === 'processing') {
_startPolling(staged);
}
} catch (e) {
staged.status = 'error';
staged.error = e.message;
console.error('File upload failed:', e);
}
_renderStrip();
_updateSendBlock();
}
/**
* Remove a staged file by localId.
* Revokes object URL, stops polling, and optionally deletes from backend.
*/
async function unstageFile(localId) {
const idx = App.stagedFiles.findIndex(a => a.localId === localId);
if (idx === -1) return;
const staged = App.stagedFiles[idx];
// Clean up preview blob
if (staged.previewUrl) {
URL.revokeObjectURL(staged.previewUrl);
}
// Stop polling
_stopPolling(localId);
// Delete from backend if it was uploaded
if (staged.serverId) {
try {
await API.deleteFile(staged.serverId);
} catch (e) {
console.warn('Failed to delete staged file:', e.message);
}
}
App.stagedFiles.splice(idx, 1);
_renderStrip();
_updateSendBlock();
}
/**
* Clear all staged files (chat switch, cancel, etc.)
* Does NOT delete from backend — orphan cleanup handles abandoned uploads.
*/
function clearStaged() {
for (const staged of App.stagedFiles) {
if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl);
_stopPolling(staged.localId);
}
App.stagedFiles = [];
_renderStrip();
_updateSendBlock();
}
// ── Send Integration ────────────────────────
/**
* Returns file IDs (server-side UUIDs) for the current staged set.
* Only includes successfully uploaded files.
*/
function getStagedFileIds() {
return App.stagedFiles
.filter(a => a.serverId)
.map(a => a.serverId);
}
/**
* Whether the send button should be blocked due to in-flight uploads.
* Per design: blocked ONLY while any file is actively uploading (bytes in flight).
* Queued/processing/failed extraction does NOT block send.
*/
function isSendBlocked() {
return App.stagedFiles.some(a => a.status === 'uploading');
}
/**
* Whether there are any staged files (for UI visibility).
*/
function hasStagedFiles() {
return App.stagedFiles.length > 0;
}
/**
* Post-send cleanup: detach staged list without deleting from backend
* (files are now linked to the sent message).
*/
function consumeStaged() {
for (const staged of App.stagedFiles) {
if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl);
_stopPolling(staged.localId);
}
App.stagedFiles = [];
_renderStrip();
_updateSendBlock();
}
// ── Extraction Polling ──────────────────────
function _startPolling(staged) {
if (_pollTimers.has(staged.localId)) return;
const timer = setInterval(async () => {
if (!staged.serverId) return;
try {
const data = await API.getFile(staged.serverId);
const meta = data.metadata || {};
staged.extractionStatus = meta.extraction_status || null;
staged.queuePosition = meta.queue_position ?? null;
staged.status = _mapExtractionStatus(meta.extraction_status);
if (meta.extraction_error) {
staged.error = meta.extraction_error;
}
_renderStrip();
// Stop polling on terminal state
if (staged.status === 'complete' || staged.status === 'failed') {
_stopPolling(staged.localId);
}
} catch (e) {
console.warn('Extraction poll failed for', staged.localId, e.message);
// Don't stop polling on transient errors — backend might be restarting
}
}, FILE_POLL_INTERVAL);
_pollTimers.set(staged.localId, timer);
}
function _stopPolling(localId) {
const timer = _pollTimers.get(localId);
if (timer) {
clearInterval(timer);
_pollTimers.delete(localId);
}
}
function _stopAllPolling() {
for (const [id, timer] of _pollTimers) {
clearInterval(timer);
}
_pollTimers.clear();
}
// ── Status Helpers ──────────────────────────
/**
* Map backend extraction_status to frontend staged status.
* Images have no extraction — they go straight to 'complete'.
*/
function _mapExtractionStatus(backendStatus) {
switch (backendStatus) {
case 'pending': return 'queued';
case 'processing': return 'processing';
case 'complete': return 'complete';
case 'failed': return 'failed';
case 'unavailable': return 'complete'; // no extraction needed (e.g., images)
default: return 'queued';
}
}
/**
* Human-readable status label for a staged file.
*/
function fileStatusLabel(staged) {
switch (staged.status) {
case 'uploading':
return staged.uploadProgress > 0 ? `Uploading ${staged.uploadProgress}%` : 'Uploading…';
case 'queued':
if (staged.queuePosition != null && staged.queuePosition > 0) {
return `Queued (${staged.queuePosition} ahead)`;
}
return 'Queued';
case 'processing': return 'Extracting…';
case 'complete': return '✓ Ready';
case 'failed': return '⚠ Extraction failed';
case 'error': return '✕ ' + (staged.error || 'Upload failed');
default: return '';
}
}
/**
* Whether the given content type is an image (for vision gating).
*/
function isImageFile(contentType) {
return IMAGE_TYPES.has(contentType);
}
/**
* Format file size for display.
*/
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// ── Strip Rendering ─────────────────────────
function _renderStrip() {
const strip = document.getElementById('fileStrip');
if (!strip) return;
if (App.stagedFiles.length === 0) {
strip.style.display = 'none';
strip.innerHTML = '';
return;
}
strip.style.display = 'flex';
strip.innerHTML = App.stagedFiles.map(a => {
const icon = isImageFile(a.contentType) ? '🖼' : '📄';
const name = _truncFilename(a.filename, 20);
const size = formatFileSize(a.sizeBytes);
const label = fileStatusLabel(a);
const statusClass = a.status === 'error' || a.status === 'failed' ? 'att-error' :
a.status === 'complete' ? 'att-ready' : 'att-pending';
return `<div class="att-chip ${statusClass}" data-local-id="${a.localId}">
${a.previewUrl ? `<img class="att-thumb" src="${a.previewUrl}" alt="">` : `<span class="att-icon">${icon}</span>`}
<span class="att-info">
<span class="att-name" title="${esc(a.filename)}">${esc(name)}</span>
<span class="att-meta">${esc(size)}${label ? ' · ' + label : ''}</span>
</span>
<button class="att-remove" data-action="unstageFile" data-args='${JSON.stringify([a.localId])}'" title="Remove">✕</button>
</div>`;
}).join('');
}
function _truncFilename(name, max) {
if (name.length <= max) return name;
const ext = name.lastIndexOf('.');
if (ext > 0 && name.length - ext <= 6) {
// Preserve extension: "very-long-name...pdf"
const suffix = name.slice(ext);
return name.slice(0, max - suffix.length - 1) + '…' + suffix;
}
return name.slice(0, max - 1) + '…';
}
// ── Channel File Loading ──────────────
/**
* Fetch all files for a channel, build messageId → [att] map.
* Called after loading messages in selectChat / reloadActivePath.
* Non-blocking: failures just leave the map empty (files are optional UX).
*/
async function loadChannelFiles(channelId) {
App.channelFiles = {};
if (!channelId || !App.storageConfigured) return;
try {
const data = await API.listChannelFiles(channelId);
const list = data.files || data || [];
for (const att of list) {
if (!att.message_id) continue;
if (!App.channelFiles[att.message_id]) {
App.channelFiles[att.message_id] = [];
}
App.channelFiles[att.message_id].push(att);
}
} catch (e) {
console.debug('File load skipped:', e.message);
}
}
// ── Message File Rendering ────────────
/**
* Returns HTML string for files associated with a message.
* Called from ui-core.js _messageHTML(). Returns '' if none.
*
* Images use data-att-id; actual src is loaded post-render with auth
* via loadAuthImages(). Doc downloads use onclick handlers.
*/
function renderMessageFiles(msgId) {
if (!msgId) return '';
const atts = App.channelFiles[msgId];
if (!atts || atts.length === 0) return '';
// Check current model vision capability for hint
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const hasVision = !!modelInfo?.capabilities?.vision;
const items = atts.map(att => {
const isImage = isImageFile(att.content_type);
if (isImage) {
const dims = att.metadata?.thumb_dimensions || att.metadata?.dimensions;
const w = Math.min(dims?.width || 280, 320);
const visionHint = !hasVision
? '<span class="att-vision-hint" title="Current model does not support images">👁️‍🗨️</span>'
: '';
return `<div class="msg-att msg-att-image" data-action="openLightbox" data-args='${JSON.stringify([esc(att.id)])}'">
<img class="msg-att-img" data-att-id="${esc(att.id)}" alt="${esc(att.filename)}"
style="max-width:${w}px" loading="lazy">
${visionHint}
</div>`;
}
// Document chip
const icon = _docIcon(att.content_type);
const size = formatFileSize(att.size_bytes);
return `<a class="msg-att msg-att-doc" href="#" data-action="downloadFile" data-args='${JSON.stringify([esc(att.id), esc(att.filename)])}' data-prevent-default">
<span class="att-doc-icon">${icon}</span>
<span class="att-doc-info">
<span class="att-doc-name">${esc(att.filename)}</span>
<span class="att-doc-size">${size}</span>
</span>
<span class="att-doc-dl">⬇</span>
</a>`;
});
return `<div class="msg-files">${items.join('')}</div>`;
}
function _docIcon(contentType) {
if (contentType === 'application/pdf') return '📕';
if (contentType?.includes('spreadsheet') || contentType?.includes('excel')) return '📊';
if (contentType?.includes('presentation') || contentType?.includes('powerpoint')) return '📙';
if (contentType?.includes('word') || contentType?.includes('document')) return '📄';
if (contentType?.startsWith('text/')) return '📝';
return '📎';
}
// ── Image Lightbox ──────────────────────────
function openLightbox(fileId) {
const overlay = document.getElementById('lightbox');
const img = document.getElementById('lightboxImg');
if (!overlay || !img) return;
img.src = '';
img.alt = 'Loading…';
overlay.classList.add('active');
document.body.style.overflow = 'hidden';
// Fetch full-size image with auth
API.downloadFileBlob(fileId)
.then(blob => { img.src = URL.createObjectURL(blob); })
.catch(() => { img.alt = 'Failed to load image'; });
}
function closeLightbox() {
const overlay = document.getElementById('lightbox');
const img = document.getElementById('lightboxImg');
if (!overlay) return;
overlay.classList.remove('active');
document.body.style.overflow = '';
// Revoke previous blob URL to free memory
if (img?.src?.startsWith('blob:')) URL.revokeObjectURL(img.src);
if (img) { img.src = ''; img.alt = ''; }
}
// ── Auth Image Loading ──────────────────────
/**
* Post-render pass: finds all <img data-att-id="..."> in the given
* container and fetches them with auth headers, setting blob URLs.
* Called after renderMessages and reloadActivePath.
*/
function loadAuthImages(container) {
if (!container) container = document.getElementById('chatMessages');
if (!container) return;
const imgs = container.querySelectorAll('img[data-att-id]:not([data-loaded])');
for (const img of imgs) {
const attId = img.dataset.attId;
if (!attId) continue;
img.dataset.loaded = '1';
API.downloadFileBlob(attId)
.then(blob => { img.src = URL.createObjectURL(blob); })
.catch(() => {
img.alt = '⚠ Load failed';
img.classList.add('att-load-error');
});
}
}
// ── Document Download ───────────────────────
/**
* Download a file via auth-aware fetch.
* Creates a temporary <a> element to trigger the browser download dialog.
*/
async function downloadFile(fileId, filename) {
try {
const blob = await API.downloadFileBlob(fileId);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'download';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
UI.toast('Download failed: ' + e.message, 'error');
}
}
// ── Admin Storage Panel ─────────────────────
async function loadAdminStorage() {
const el = document.getElementById('adminStorageContent');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const [status, orphans] = await Promise.all([
API.adminGetStorageStatus().catch(() => null),
API.adminGetOrphanCount().catch(() => null),
]);
let html = '';
// Status card
if (status) {
const healthClass = status.healthy ? 'badge-success' : 'badge-danger';
const healthLabel = status.healthy ? 'Healthy' : 'Unhealthy';
html += `<div class="admin-storage-card">
<h4>Storage Backend</h4>
<div class="admin-storage-grid">
<div class="admin-storage-item">
<span class="admin-storage-label">Backend</span>
<span class="admin-storage-value">${esc(status.backend || 'none')}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Status</span>
<span class="badge ${healthClass}">${healthLabel}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Total Files</span>
<span class="admin-storage-value">${status.total_files ?? '—'}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Total Size</span>
<span class="admin-storage-value">${status.total_bytes ? formatFileSize(status.total_bytes) : '—'}</span>
</div>
${status.path ? `<div class="admin-storage-item">
<span class="admin-storage-label">Path</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.path)}</span>
</div>` : ''}
${status.endpoint ? `<div class="admin-storage-item">
<span class="admin-storage-label">Endpoint</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.endpoint)}</span>
</div>` : ''}
${status.bucket ? `<div class="admin-storage-item">
<span class="admin-storage-label">Bucket</span>
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${esc(status.bucket)}</span>
</div>` : ''}
</div>
</div>`;
} else {
html += `<div class="admin-storage-card">
<h4>Storage Backend</h4>
<p class="empty-hint">Storage not configured. Set STORAGE_BACKEND=pvc or STORAGE_BACKEND=s3 to enable file uploads.</p>
</div>`;
}
// Orphan cleanup card
if (orphans) {
const count = orphans.count ?? 0;
const bytes = orphans.total_bytes ?? 0;
html += `<div class="admin-storage-card">
<h4>Orphan Files</h4>
<p class="admin-storage-desc">Files uploaded but never linked to a message for &gt;24 hours.</p>
<div class="admin-storage-grid">
<div class="admin-storage-item">
<span class="admin-storage-label">Orphaned</span>
<span class="admin-storage-value">${count} file${count !== 1 ? 's' : ''}</span>
</div>
<div class="admin-storage-item">
<span class="admin-storage-label">Reclaimable</span>
<span class="admin-storage-value">${formatFileSize(bytes)}</span>
</div>
</div>
${count > 0 ? `<button class="btn-small" id="adminRunCleanup" style="margin-top:8px">Run Cleanup Now</button>` : ''}
</div>`;
}
el.innerHTML = html;
// Wire cleanup button
document.getElementById('adminRunCleanup')?.addEventListener('click', async (e) => {
const btn = e.target;
btn.disabled = true;
btn.textContent = 'Cleaning…';
try {
const result = await API.adminRunStorageCleanup();
UI.toast(`Cleaned ${result.deleted || 0} orphan${result.deleted !== 1 ? 's' : ''}, freed ${formatFileSize(result.freed_bytes || 0)}`, 'success');
await loadAdminStorage(); // refresh counts
} catch (err) {
UI.toast('Cleanup failed: ' + err.message, 'error');
btn.disabled = false;
btn.textContent = 'Run Cleanup Now';
}
});
} catch (e) {
el.innerHTML = `<div class="empty-hint">Failed to load storage status: ${esc(e.message)}</div>`;
}
}
// ── Constants (Paste) ───────────────────────
// Threshold for auto-attaching pasted text as a file.
// Synced from backend via PublicSettings (storage.paste_to_file_chars).
// Falls back to 2000 if not yet loaded.
function _pasteToFileChars() {
return App.pasteToFileChars || 2000;
}
// Accept string for file picker — mirrors backend allowedMIMETypes.
// Backend is authoritative (rejects disallowed types server-side),
// this just gives the OS file dialog a better filter.
const FILE_ACCEPT = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
'application/pdf',
'text/plain', 'text/markdown', 'text/csv',
'.docx', '.xlsx', '.pptx', '.doc', '.xls', '.odt', '.ods', '.odp', '.rtf',
].join(',');
// ── MIME → Extension ────────────────────────
const _mimeToExt = {
'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
'image/webp': 'webp', 'image/svg+xml': 'svg',
'application/pdf': 'pdf', 'text/plain': 'txt', 'text/markdown': 'md',
'text/csv': 'csv', 'text/html': 'html',
};
function _extFromMime(mime) {
return _mimeToExt[mime] || mime.split('/').pop() || 'bin';
}
// ── Send Button Blocking ────────────────────
function _updateSendBlock() {
const sendBtn = document.getElementById('sendBtn');
if (!sendBtn) return;
// Merge with existing generation check — don't override if generating
if (App.isGenerating) return;
if (isSendBlocked()) {
sendBtn.disabled = true;
sendBtn.title = 'Upload in progress…';
} else {
sendBtn.disabled = false;
sendBtn.title = 'Send';
}
}
// ── Init + Listeners ────────────────────────
/**
* Called from app.js startApp(). Reads storage_configured from boot
* payload and sets up file UI visibility.
*/
function initFiles() {
const attachBtn = document.getElementById('attachBtn');
const fileInput = document.getElementById('fileInput');
if (attachBtn) {
attachBtn.style.display = App.storageConfigured ? '' : 'none';
}
if (fileInput) {
fileInput.accept = FILE_ACCEPT;
}
console.log(`📎 Files: storage ${App.storageConfigured ? 'configured' : 'not configured'}`);
}
/**
* Called from app.js initListeners(). Wires up all file input
* handlers: button, file picker, paste, and drag-and-drop.
*/
function _initFileListeners() {
const attachBtn = document.getElementById('attachBtn');
const fileInput = document.getElementById('fileInput');
const chatArea = document.getElementById('chatMessages');
const lightbox = document.getElementById('lightbox');
// ── 📎 Button → File Picker ─────────────
if (attachBtn && fileInput) {
attachBtn.addEventListener('click', () => {
if (!App.storageConfigured) return;
fileInput.click();
});
fileInput.addEventListener('change', () => {
const files = fileInput.files;
if (!files?.length) return;
for (const file of files) stageFile(file);
fileInput.value = ''; // reset so same file can be re-selected
});
}
// ── Smart Paste ─────────────────────────
// Attach to the active input element (CM6 contentDOM or textarea)
const pasteTarget = (typeof ChatInput !== 'undefined' && ChatInput.getDom())
? ChatInput.getDom()
: document.getElementById('messageInput');
if (pasteTarget) {
pasteTarget.addEventListener('paste', (e) => {
if (!App.storageConfigured) return; // fall through to normal paste
const items = [...(e.clipboardData?.items || [])];
// Binary detection: images/files from clipboard
const binaryItems = items.filter(i => i.kind === 'file');
if (binaryItems.length > 0) {
e.preventDefault();
for (const item of binaryItems) {
const file = item.getAsFile();
if (!file) continue;
// Generate UUID filename — original clipboard items have no useful name
const ext = _extFromMime(file.type);
const renamed = new File([file], `${crypto.randomUUID()}.${ext}`, { type: file.type });
stageFile(renamed);
}
return;
}
// Large text detection — auto-attach as .txt file
const _ptfChars = _pasteToFileChars();
if (_ptfChars > 0) {
const text = e.clipboardData?.getData('text/plain');
if (text && text.length > _ptfChars) {
e.preventDefault();
const blob = new Blob([text], { type: 'text/plain' });
const file = new File([blob], `${crypto.randomUUID()}.txt`, { type: 'text/plain' });
stageFile(file);
return;
}
}
// Below threshold or no text: normal paste (no preventDefault)
});
}
// ── Drag-and-Drop ───────────────────────
if (chatArea) {
// Track drag enter/leave depth to avoid flicker from child elements
let dragDepth = 0;
chatArea.addEventListener('dragenter', (e) => {
if (!App.storageConfigured) return;
if (!_hasDragFiles(e)) return;
e.preventDefault();
dragDepth++;
if (dragDepth === 1) chatArea.classList.add('drag-over');
});
chatArea.addEventListener('dragover', (e) => {
if (!App.storageConfigured) return;
if (!_hasDragFiles(e)) return;
e.preventDefault(); // required to allow drop
e.dataTransfer.dropEffect = 'copy';
});
chatArea.addEventListener('dragleave', (e) => {
if (!App.storageConfigured) return;
dragDepth--;
if (dragDepth <= 0) {
dragDepth = 0;
chatArea.classList.remove('drag-over');
}
});
chatArea.addEventListener('drop', (e) => {
if (!App.storageConfigured) return;
e.preventDefault();
dragDepth = 0;
chatArea.classList.remove('drag-over');
const files = e.dataTransfer?.files;
if (!files?.length) return;
for (const file of files) stageFile(file);
});
}
// ── Lightbox ────────────────────────────
if (lightbox) {
// Click overlay (not the image) to close
lightbox.addEventListener('click', (e) => {
if (e.target === lightbox || e.target.classList.contains('lightbox-close')) {
closeLightbox();
}
});
}
}
/**
* Check if a drag event contains files (not just text selection drag).
*/
function _hasDragFiles(e) {
if (!e.dataTransfer?.types) return false;
return e.dataTransfer.types.includes('Files');
}
// ── Exports ─────────────────────────────────
sb.register('_initFileListeners', _initFileListeners);
sb.register('_pollTimers', _pollTimers);
sb.register('_startPolling', _startPolling);
sb.register('_stopPolling', _stopPolling);
sb.register('clearStaged', clearStaged);
sb.register('closeLightbox', closeLightbox);
sb.register('consumeStaged', consumeStaged);
sb.register('getStagedFileIds', getStagedFileIds);
sb.register('hasStagedFiles', hasStagedFiles);
sb.register('initFiles', initFiles);
sb.register('isSendBlocked', isSendBlocked);
sb.register('loadAdminStorage', loadAdminStorage);
sb.register('loadAuthImages', loadAuthImages);
sb.register('loadChannelFiles', loadChannelFiles);
sb.register('renderMessageFiles', renderMessageFiles);
sb.register('unstageFile', unstageFile);
sb.register('openLightbox', openLightbox);
sb.register('downloadFile', downloadFile);

View File

@@ -1,556 +0,0 @@
// ── knowledge-ui.js ─────────────────────────
// Knowledge base management UI.
//
// Three contexts:
// 1. User Settings → personal KBs (scope: personal)
// 2. Admin Panel → global KBs (scope: global)
// 3. Team Admin → team-scoped KBs (scope: team)
//
// Plus the channel KB toggle popup in the chat input bar.
//
// Depends on: API (api.js), App (app.js)
const KnowledgeUI = (() => {
console.debug('[KnowledgeUI] module loaded');
let _allKBs = []; // [{id, name, scope, document_count, ...}]
let _channelKBs = []; // [{kb_id, kb_name, enabled, document_count}]
let _pollTimers = {}; // docId → timer
let _currentKBId = null; // for document panel
let _panelCtx = null; // { container, scope, teamId } — current panel context
// ═════════════════════════════════════════
// Channel KB Toggle Popup (chat input bar)
// ═════════════════════════════════════════
async function openChannelPopup() {
const popup = document.getElementById('kbPopup');
if (!popup) return;
if (!App.activeId) {
popup.innerHTML = '<div class="empty-hint">Start a conversation first</div>';
popup.classList.add('open');
return;
}
popup.innerHTML = '<div class="loading">Loading…</div>';
popup.classList.add('open');
try {
const [allResp, chResp] = await Promise.all([
API.listKnowledgeBases(),
API.getChannelKBs(App.activeId),
]);
_allKBs = allResp.data || [];
_channelKBs = chResp.data || [];
_renderChannelPopup(popup);
} catch (e) {
popup.innerHTML = '<div class="empty-hint">Failed to load knowledge bases</div>';
console.warn('KB popup load failed:', e);
}
}
function _renderChannelPopup(popup) {
if (_allKBs.length === 0) {
popup.innerHTML = `
<div class="empty-hint">
No knowledge bases yet.
<br><small>Create one in Settings → Knowledge Bases</small>
</div>`;
return;
}
const linkedSet = new Set(_channelKBs.map(kb => kb.kb_id));
let html = '<div class="kb-popup-header">Knowledge Bases</div>';
for (const kb of _allKBs) {
const linked = linkedSet.has(kb.id);
const scope = kb.scope === 'personal' ? '' : ` · ${kb.scope}`;
const docs = kb.document_count === 1 ? '1 doc' : `${kb.document_count} docs`;
const status = kb.chunk_count === 0 ? ' · no content' : '';
html += `
<div class="kb-popup-item ${linked ? 'enabled' : ''}" data-kb-id="${kb.id}">
<span class="kb-popup-check"></span>
<span class="kb-popup-label">
<span class="kb-popup-name">${esc(kb.name)}</span>
<span class="kb-popup-meta">${docs}${scope}${status}</span>
</span>
</div>`;
}
popup.innerHTML = html;
}
async function _toggleChannelKB(kbId) {
if (!App.activeId) return;
const linkedSet = new Set(_channelKBs.map(kb => kb.kb_id));
if (linkedSet.has(kbId)) {
linkedSet.delete(kbId);
} else {
linkedSet.add(kbId);
}
try {
const resp = await API.setChannelKBs(App.activeId, [...linkedSet]);
_channelKBs = resp.data || [];
const popup = document.getElementById('kbPopup');
if (popup) _renderChannelPopup(popup);
_updateKBBtnState();
} catch (e) {
console.warn('Failed to update channel KBs:', e);
}
}
function _updateKBBtnState() {
const btn = document.getElementById('kbToggleBtn');
if (!btn) return;
const count = _channelKBs.filter(kb => kb.enabled !== false).length;
btn.classList.toggle('has-active', count > 0);
btn.title = count > 0 ? `Knowledge Bases (${count} active)` : 'Knowledge Bases';
}
// ═════════════════════════════════════════
// Shared KB Management Panel
// ═════════════════════════════════════════
// Rendered into any container with { scope, teamId } context.
/**
* Open KB management panel.
* @param {string} containerId — DOM id of the container to render into
* @param {string} scope — 'personal', 'global', or 'team'
* @param {string} [teamId] — required when scope='team'
*/
async function openPanel(containerId, scope, teamId) {
console.debug(`[KB] openPanel: container=${containerId}, scope=${scope}, teamId=${teamId}`);
const panel = document.getElementById(containerId);
if (!panel) {
console.warn(`[KB] openPanel: container '${containerId}' not found`);
return;
}
_panelCtx = { container: containerId, scope, teamId };
panel.innerHTML = '<div class="loading">Loading…</div>';
panel.style.display = '';
try {
const resp = await API.listKnowledgeBases();
// Filter by scope
const all = resp.data || [];
_allKBs = all.filter(kb => {
if (scope === 'personal') return kb.scope === 'personal';
if (scope === 'global') return kb.scope === 'global';
if (scope === 'team') return kb.scope === 'team' && kb.team_id === teamId;
return true;
});
_renderManagePanel(panel);
} catch (e) {
console.warn('[KB] openPanel error:', e);
panel.innerHTML = '<div class="empty-hint">Failed to load knowledge bases</div>';
}
}
// Convenience wrappers
function openManagePanel() { openPanel('kbManagePanel', 'personal'); }
function openAdminPanel() { openPanel('adminKBContent', 'global'); }
function openTeamPanel(teamId) { openPanel('teamKBContent', 'team', teamId); }
function _renderManagePanel(panel) {
const ctx = _panelCtx || { scope: 'personal' };
const scopeLabel = ctx.scope === 'global' ? 'Global' : ctx.scope === 'team' ? 'Team' : 'Personal';
let html = `
<div class="kb-manage-header">
<h3>${scopeLabel} Knowledge Bases</h3>
<button class="btn-small btn-primary" id="kbCreateBtn">+ New</button>
</div>`;
if (_allKBs.length === 0) {
html += '<div class="empty-hint">No knowledge bases yet. Create one to get started.</div>';
} else {
// Summary stats
const totalDocs = _allKBs.reduce((s, kb) => s + (kb.document_count || 0), 0);
const totalChunks = _allKBs.reduce((s, kb) => s + (kb.chunk_count || 0), 0);
const totalBytes = _allKBs.reduce((s, kb) => s + (kb.total_bytes || 0), 0);
html += `<div class="kb-manage-summary">${_allKBs.length} KB${_allKBs.length !== 1 ? 's' : ''} · ${totalDocs} documents · ${totalChunks} chunks · ${_formatSize(totalBytes)}</div>`;
html += '<div class="kb-manage-list">';
for (const kb of _allKBs) {
const scope = kb.scope === 'personal' ? '🔒' : kb.scope === 'team' ? '👥' : '🌐';
const status = kb.status === 'processing' ? ' · ⏳ processing' : '';
const docs = kb.document_count === 1 ? '1 document' : `${kb.document_count} documents`;
const chunks = kb.chunk_count === 1 ? '1 chunk' : `${kb.chunk_count} chunks`;
const size = kb.total_bytes > 0 ? ` · ${_formatSize(kb.total_bytes)}` : '';
html += `
<div class="kb-manage-item" data-kb-id="${kb.id}" data-grant-kb="${kb.id}">
<div class="kb-manage-info">
<span class="kb-manage-name">${scope} ${esc(kb.name)}</span>
<span class="kb-manage-desc">${esc(kb.description || '')}</span>
<span class="kb-manage-stats">${docs} · ${chunks}${size}${status}</span>
</div>
<div class="kb-manage-actions">
${kb.scope !== 'personal' ? `<button class="btn-small" data-kb-grant="${kb.id}" data-kb-name="${esc(kb.name)}" title="Access">🔒</button>` : ''}
<button class="btn-small" data-kb-docs="${kb.id}" title="Documents">📄</button>
<button class="btn-small btn-danger" data-kb-del="${kb.id}" title="Delete">✕</button>
</div>
</div>`;
}
html += '</div>';
}
panel.innerHTML = html;
// Wire create button
document.getElementById('kbCreateBtn')?.addEventListener('click', () => {
_showCreateDialog(ctx.scope, ctx.teamId);
});
// Wire item actions
panel.querySelectorAll('[data-kb-docs]').forEach(btn => {
btn.addEventListener('click', () => _openDocumentsPanel(btn.dataset.kbDocs));
});
panel.querySelectorAll('[data-kb-del]').forEach(btn => {
btn.addEventListener('click', () => _deleteKB(btn.dataset.kbDel));
});
// Wire grant access buttons (admin only, non-personal KBs)
panel.querySelectorAll('[data-kb-grant]').forEach(btn => {
btn.addEventListener('click', () => {
if (typeof UI !== 'undefined' && UI.openGrantPicker) {
UI.openGrantPicker('knowledge_base', btn.dataset.kbGrant, btn.dataset.kbName, `[data-grant-kb="${btn.dataset.kbGrant}"]`);
}
});
});
}
// ── Create KB Dialog ─────────────────────
function _showCreateDialog(scope, teamId) {
const existing = document.getElementById('kbCreateDialog');
if (existing) existing.remove();
const scopeLabel = scope === 'global' ? 'Global' : scope === 'team' ? 'Team' : 'Personal';
const dialog = document.createElement('div');
dialog.id = 'kbCreateDialog';
dialog.className = 'modal-overlay active';
dialog.innerHTML = `
<div class="modal" style="max-width: 420px">
<div class="modal-header">
<h2>New ${scopeLabel} Knowledge Base</h2>
<button class="modal-close" id="kbCreateClose">✕</button>
</div>
<div class="modal-body">
<div style="margin-bottom:12px">
<label style="font-size:12px;font-weight:600;color:var(--text-2);display:block;margin-bottom:4px">Name</label>
<input type="text" id="kbCreateName" placeholder="e.g. Project Docs" maxlength="200"
style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:13px">
</div>
<div>
<label style="font-size:12px;font-weight:600;color:var(--text-2);display:block;margin-bottom:4px">Description (optional)</label>
<textarea id="kbCreateDesc" rows="2" maxlength="2000" placeholder="What documents will this contain?"
style="width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:13px;resize:vertical"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn-small" id="kbCreateCancel">Cancel</button>
<button class="btn-small btn-primary" id="kbCreateSave">Create</button>
</div>
</div>`;
document.body.appendChild(dialog);
const close = () => dialog.remove();
document.getElementById('kbCreateClose').addEventListener('click', close);
document.getElementById('kbCreateCancel').addEventListener('click', close);
document.getElementById('kbCreateSave').addEventListener('click', async () => {
const name = document.getElementById('kbCreateName').value.trim();
if (!name) {
document.getElementById('kbCreateName').focus();
return;
}
const desc = document.getElementById('kbCreateDesc').value.trim();
try {
await API.createKnowledgeBase(name, desc, scope, teamId);
close();
_refreshCurrentPanel();
} catch (e) {
UI.toast('Failed to create: ' + (e.message || e), 'error');
}
});
// Enter key submits
document.getElementById('kbCreateName').addEventListener('keydown', (e) => {
if (e.key === 'Enter') document.getElementById('kbCreateSave').click();
});
dialog.addEventListener('click', (e) => {
if (e.target === dialog) close();
});
// Focus name field after render
requestAnimationFrame(() => document.getElementById('kbCreateName')?.focus());
}
async function _deleteKB(kbId) {
const kb = _allKBs.find(k => k.id === kbId);
if (!await showConfirm(`Delete "${kb?.name || kbId}" and all its documents?`)) return;
try {
await API.deleteKnowledgeBase(kbId);
_refreshCurrentPanel();
} catch (e) {
UI.toast('Delete failed: ' + (e.message || e), 'error');
}
}
/** Refresh whatever panel is currently active. */
function _refreshCurrentPanel() {
if (!_panelCtx) return;
openPanel(_panelCtx.container, _panelCtx.scope, _panelCtx.teamId);
}
// ═════════════════════════════════════════
// Documents Panel
// ═════════════════════════════════════════
async function _openDocumentsPanel(kbId) {
// Use the same alias for backward compat
return openDocumentsPanel(kbId);
}
async function openDocumentsPanel(kbId) {
_currentKBId = kbId;
const containerId = _panelCtx?.container || 'kbManagePanel';
const panel = document.getElementById(containerId);
if (!panel) return;
panel.innerHTML = '<div class="loading">Loading documents…</div>';
try {
const [kbResp, docsResp] = await Promise.all([
API.getKnowledgeBase(kbId),
API.listKBDocuments(kbId),
]);
const kb = kbResp;
const docs = docsResp.data || docsResp || [];
_renderDocumentsPanel(panel, kb, Array.isArray(docs) ? docs : []);
} catch (e) {
panel.innerHTML = `<div class="empty-hint">Failed to load documents: ${esc(e.message || '')}</div>`;
}
}
function _renderDocumentsPanel(panel, kb, docs) {
let html = `
<div class="kb-manage-header">
<button class="btn-small" id="kbDocsBack">← Back</button>
<h3>${esc(kb.name)}</h3>
<label class="btn-small btn-primary" id="kbUploadLabel" title="Upload document">
+ Upload
<input type="file" id="kbFileInput" accept=".txt,.md,.csv,.html,.htm" style="display:none" multiple>
</label>
</div>`;
if (kb.description) {
html += `<div class="kb-manage-desc-block">${esc(kb.description)}</div>`;
}
// Stats line
const totalBytes = docs.reduce((s, d) => s + (d.size_bytes || 0), 0);
const totalChunks = docs.reduce((s, d) => s + (d.chunk_count || 0), 0);
if (docs.length > 0) {
html += `<div class="kb-manage-summary">${docs.length} file${docs.length !== 1 ? 's' : ''} · ${totalChunks} chunks · ${_formatSize(totalBytes)}</div>`;
}
if (docs.length === 0) {
html += '<div class="empty-hint">No documents yet. Upload text, markdown, CSV, or HTML files.</div>';
} else {
html += '<div class="kb-manage-list">';
for (const doc of docs) {
const statusIcon = _docStatusIcon(doc.status);
const size = _formatSize(doc.size_bytes);
const chunks = doc.chunk_count > 0 ? ` · ${doc.chunk_count} chunks` : '';
const errText = doc.error ? ` · ⚠ ${esc(doc.error)}` : '';
html += `
<div class="kb-manage-item kb-doc-item" data-doc-id="${doc.id}" data-doc-status="${doc.status}">
<div class="kb-manage-info">
<span class="kb-manage-name">${statusIcon} ${esc(doc.filename)}</span>
<span class="kb-manage-stats">${size}${chunks}${errText}</span>
</div>
<div class="kb-manage-actions">
<button class="btn-small btn-danger" data-doc-del="${doc.id}" title="Delete">✕</button>
</div>
</div>`;
}
html += '</div>';
}
panel.innerHTML = html;
// Wire back button
document.getElementById('kbDocsBack')?.addEventListener('click', () => _refreshCurrentPanel());
// Wire upload
document.getElementById('kbFileInput')?.addEventListener('change', async (e) => {
const files = e.target.files;
if (!files.length) return;
for (const file of files) {
await _uploadDocument(kb.id, file);
}
e.target.value = '';
openDocumentsPanel(kb.id);
});
// Wire delete buttons
panel.querySelectorAll('[data-doc-del]').forEach(btn => {
btn.addEventListener('click', () => _deleteDocument(kb.id, btn.dataset.docDel));
});
// Start polling for in-progress documents
for (const doc of docs) {
if (['pending', 'extracting', 'chunking', 'embedding'].includes(doc.status)) {
_startPolling(kb.id, doc.id);
}
}
}
async function _uploadDocument(kbId, file) {
try {
const resp = await API.uploadKBDocument(kbId, file);
console.log(`📄 Uploaded ${file.name}, status: ${resp.status}`);
if (resp.id) _startPolling(kbId, resp.id);
} catch (e) {
UI.toast(`Upload failed for ${file.name}: ${e.message || e}`, 'error');
}
}
async function _deleteDocument(kbId, docId) {
if (!await showConfirm('Delete this document and all its chunks?')) return;
try {
_stopPolling(docId);
await API.deleteKBDocument(kbId, docId);
openDocumentsPanel(kbId);
} catch (e) {
UI.toast('Delete failed: ' + (e.message || e), 'error');
}
}
// ═════════════════════════════════════════
// Status Polling
// ═════════════════════════════════════════
function _startPolling(kbId, docId) {
if (_pollTimers[docId]) return;
_pollTimers[docId] = setInterval(async () => {
try {
const status = await API.getKBDocumentStatus(kbId, docId);
_updateDocStatus(docId, status);
if (status.status === 'ready' || status.status === 'error') {
_stopPolling(docId);
if (_currentKBId === kbId) openDocumentsPanel(kbId);
}
} catch {
_stopPolling(docId);
}
}, 2000);
}
function _stopPolling(docId) {
if (_pollTimers[docId]) {
clearInterval(_pollTimers[docId]);
delete _pollTimers[docId];
}
}
function _updateDocStatus(docId, status) {
const item = document.querySelector(`[data-doc-id="${docId}"]`);
if (!item) return;
item.dataset.docStatus = status.status;
const nameEl = item.querySelector('.kb-manage-name');
if (nameEl) {
const icon = _docStatusIcon(status.status);
const filename = nameEl.textContent.replace(/^[^\s]+ /, '');
nameEl.innerHTML = `${icon} ${esc(status.filename || filename)}`;
}
const statsEl = item.querySelector('.kb-manage-stats');
if (statsEl && status.chunk_count > 0) {
statsEl.textContent = `${status.chunk_count} chunks`;
}
}
// ═════════════════════════════════════════
// Event Wiring
// ═════════════════════════════════════════
function init() {
// KB toggle button in chat input bar
const btn = document.getElementById('kbToggleBtn');
const popup = document.getElementById('kbPopup');
if (btn && popup) {
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (popup.classList.contains('open')) {
popup.classList.remove('open');
} else {
openChannelPopup();
}
});
popup.addEventListener('click', (e) => {
e.stopPropagation();
const item = e.target.closest('.kb-popup-item');
if (item) _toggleChannelKB(item.dataset.kbId);
});
document.addEventListener('click', () => popup.classList.remove('open'));
}
}
/** Refresh channel KB state when switching chats. */
async function onChatChanged() {
_channelKBs = [];
if (App.activeId) {
try {
const resp = await API.getChannelKBs(App.activeId);
_channelKBs = resp.data || [];
} catch { /* silent */ }
}
_updateKBBtnState();
}
// ═════════════════════════════════════════
// Helpers
// ═════════════════════════════════════════
function _docStatusIcon(status) {
switch (status) {
case 'ready': return '✅';
case 'error': return '❌';
case 'pending': return '⏳';
case 'extracting': return '📖';
case 'chunking': return '✂️';
case 'embedding': return '🧮';
default: return '📄';
}
}
function _formatSize(bytes) {
if (!bytes || bytes === 0) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// ═════════════════════════════════════════
// Public API
// ═════════════════════════════════════════
return {
init,
onChatChanged,
openManagePanel, // User Settings → personal
openAdminPanel, // Admin Panel → global
openTeamPanel, // Team Admin → team-scoped
openDocumentsPanel,
openChannelPopup,
};
})();
sb.ns('KnowledgeUI', KnowledgeUI);

View File

@@ -1,431 +0,0 @@
// ==========================================
// Chat Switchboard Notifications (v0.20.0)
// ==========================================
// In-app notification bell with dropdown and
// side panel. Real-time push via WebSocket.
//
// Dependencies: API, Events, PanelRegistry, UI
// ==========================================
//
// Exports: window.Notifications
const Notifications = {
_items: [], // cached notifications (latest first)
_unreadCount: 0,
_loaded: false, // full list fetched at least once
_dropdownOpen: false,
// ── Init ─────────────────────────────────
async init() {
this._bindEvents();
this._registerPanel();
await this._fetchUnreadCount();
},
// ── Data ─────────────────────────────────
async _fetchUnreadCount() {
try {
const data = await API._get('/api/v1/notifications/unread-count');
this._unreadCount = data.count || 0;
this._renderBadge();
} catch (e) {
// Silently fail — badge just stays at 0
}
},
async _fetchList(opts = {}) {
const limit = opts.limit || 20;
const offset = opts.offset || 0;
const unreadOnly = opts.unreadOnly || false;
try {
const data = await API._get(
`/api/v1/notifications?limit=${limit}&offset=${offset}&unread_only=${unreadOnly}`);
return data;
} catch (e) {
return { data: [], total: 0 };
}
},
async _loadLatest() {
const result = await this._fetchList({ limit: 10 });
this._items = result.data || [];
this._loaded = true;
return result;
},
// ── Badge ────────────────────────────────
_renderBadge() {
const badge = document.getElementById('notifBadge');
if (!badge) return;
if (this._unreadCount > 0) {
badge.textContent = this._unreadCount > 9 ? '9+' : String(this._unreadCount);
badge.style.display = '';
} else {
badge.style.display = 'none';
}
},
// ── Dropdown ─────────────────────────────
async toggleDropdown() {
if (this._dropdownOpen) {
this._closeDropdown();
return;
}
// Lazy-load on first open
if (!this._loaded) {
await this._loadLatest();
}
const dd = document.getElementById('notifDropdown');
if (!dd) return;
dd.innerHTML = this._renderDropdownContent();
dd.style.display = 'block';
this._dropdownOpen = true;
// Click-outside handler
setTimeout(() => {
document.addEventListener('click', this._outsideClickHandler);
}, 0);
},
_closeDropdown() {
const dd = document.getElementById('notifDropdown');
if (dd) dd.style.display = 'none';
this._dropdownOpen = false;
document.removeEventListener('click', this._outsideClickHandler);
},
_outsideClickHandler(e) {
const wrap = document.getElementById('notifWrap');
if (wrap && !wrap.contains(e.target)) {
Notifications._closeDropdown();
}
},
_renderDropdownContent() {
const items = this._items;
if (!items.length) {
return `<div class="notif-empty">No notifications</div>`;
}
let html = `<div class="notif-dd-header">
<span>Notifications</span>
<button class="notif-mark-all" data-action="Notifications.markAllRead">Mark all ✓</button>
</div><div class="notif-dd-list">`;
for (const n of items.slice(0, 10)) {
const unread = !n.is_read;
const dot = unread ? '●' : '○';
const cls = unread ? 'notif-item notif-unread' : 'notif-item';
const ago = _timeAgo(n.created_at);
html += `<div class="${cls}" data-id="${n.id}" data-resource-type="${n.resource_type || ''}" data-resource-id="${n.resource_id || ''}" data-action="Notifications._onItemClick" data-pass-el>
<span class="notif-dot">${dot}</span>
<div class="notif-body">
<div class="notif-title">${esc(n.title)}</div>
${n.body ? `<div class="notif-detail">${esc(n.body)}</div>` : ''}
<div class="notif-time">${ago}</div>
</div>
</div>`;
}
html += `</div>`;
html += `<div class="notif-dd-footer">
<button class="notif-view-all" data-action="Notifications.openPanel">View all →</button>
</div>`;
return html;
},
// ── Item Actions ─────────────────────────
async _onItemClick(el) {
const id = el.dataset.id;
const resType = el.dataset.resourceType;
const resId = el.dataset.resourceId;
// Mark read
if (el.classList.contains('notif-unread')) {
await this._markRead(id);
el.classList.remove('notif-unread');
el.querySelector('.notif-dot').textContent = '○';
}
// Navigate
this._navigate(resType, resId);
this._closeDropdown();
},
_navigate(resourceType, resourceId) {
if (!resourceType || !resourceId) return;
switch (resourceType) {
case 'channel':
if (typeof selectChat === 'function') selectChat(resourceId);
break;
case 'knowledge_base':
// Open admin panel → KB section (if admin)
if (API.isAdmin && typeof showAdmin === 'function') {
showAdmin('ai', 'knowledge-bases');
}
break;
case 'project':
// Open project detail panel
if (typeof openProjectPanel === 'function') openProjectPanel(resourceId);
break;
case 'group':
if (API.isAdmin && typeof showAdmin === 'function') {
showAdmin('people', 'groups');
}
break;
}
},
async _markRead(id) {
try {
await API._authed(`/api/v1/notifications/${id}/read`, 'PATCH');
this._unreadCount = Math.max(0, this._unreadCount - 1);
const item = this._items.find(n => n.id === id);
if (item) item.is_read = true;
this._renderBadge();
} catch (e) { /* best effort */ }
},
async markAllRead() {
try {
await API._post('/api/v1/notifications/mark-all-read');
this._unreadCount = 0;
this._items.forEach(n => n.is_read = true);
this._renderBadge();
// Re-render dropdown if open
if (this._dropdownOpen) {
const dd = document.getElementById('notifDropdown');
if (dd) dd.innerHTML = this._renderDropdownContent();
}
// Re-render panel if open
this._renderPanelContent();
} catch (e) { /* best effort */ }
},
// ── Side Panel ───────────────────────────
_registerPanel() {
const body = document.getElementById('sidePanelBody');
if (!body || typeof PanelRegistry === 'undefined') return;
const el = document.createElement('div');
el.className = 'side-panel-page';
el.id = 'sidePanelNotifications';
el.style.display = 'none';
el.innerHTML = `<div class="notif-panel" id="notifPanelInner">
<div class="notif-panel-toolbar">
<button class="btn-small" data-action="Notifications.markAllRead">Mark all read</button>
<button class="btn-small btn-danger" data-action="Notifications.clearAll">Clear all</button>
</div>
<div class="notif-panel-list" id="notifPanelList"></div>
<div class="notif-panel-more" id="notifPanelMore" style="display:none">
<button class="btn-small" data-action="Notifications.loadMore">Load more</button>
</div>
</div>`;
body.appendChild(el);
PanelRegistry.register('notifications', {
element: el,
label: 'Notifications',
onOpen: () => this._onPanelOpen(),
saveState: () => ({ scrollTop: el.querySelector('.notif-panel-list')?.scrollTop || 0 }),
restoreState: (s) => {
const list = el.querySelector('.notif-panel-list');
if (list && s.scrollTop) list.scrollTop = s.scrollTop;
},
});
},
_panelOffset: 0,
_panelTotal: 0,
async _onPanelOpen() {
this._panelOffset = 0;
const result = await this._fetchList({ limit: 30 });
this._panelTotal = result.total || 0;
this._panelOffset = (result.data || []).length;
this._panelItems = result.data || [];
this._renderPanelContent();
},
_panelItems: [],
_renderPanelContent() {
const list = document.getElementById('notifPanelList');
if (!list) return;
if (!this._panelItems.length) {
list.innerHTML = '<div class="notif-empty">No notifications yet</div>';
return;
}
let html = '';
for (const n of this._panelItems) {
const unread = !n.is_read;
const cls = unread ? 'notif-panel-item notif-unread' : 'notif-panel-item';
const dot = unread ? '●' : '○';
const ago = _timeAgo(n.created_at);
html += `<div class="${cls}" data-id="${n.id}" data-resource-type="${n.resource_type || ''}" data-resource-id="${n.resource_id || ''}" data-action="Notifications._onPanelItemClick" data-pass-el>
<div class="notif-panel-row">
<span class="notif-dot">${dot}</span>
<div class="notif-body">
<div class="notif-title">${esc(n.title)}</div>
${n.body ? `<div class="notif-detail">${esc(n.body)}</div>` : ''}
<div class="notif-time">${ago}</div>
</div>
<button class="notif-delete" data-action="Notifications._deleteItem" data-args='${JSON.stringify([n.id])}'" title="Delete">✕</button>
</div>
</div>`;
}
list.innerHTML = html;
// Show/hide load more
const moreBtn = document.getElementById('notifPanelMore');
if (moreBtn) {
moreBtn.style.display = this._panelOffset < this._panelTotal ? '' : 'none';
}
},
async _onPanelItemClick(el) {
const id = el.dataset.id;
const resType = el.dataset.resourceType;
const resId = el.dataset.resourceId;
if (el.classList.contains('notif-unread')) {
await this._markRead(id);
el.classList.remove('notif-unread');
el.querySelector('.notif-dot').textContent = '○';
const pItem = this._panelItems.find(n => n.id === id);
if (pItem) pItem.is_read = true;
}
this._navigate(resType, resId);
},
async _deleteItem(id) {
try {
await API._del(`/api/v1/notifications/${id}`);
// Check if it was unread before removal
const item = this._panelItems.find(n => n.id === id);
if (item && !item.is_read) {
this._unreadCount = Math.max(0, this._unreadCount - 1);
this._renderBadge();
}
this._panelItems = this._panelItems.filter(n => n.id !== id);
this._items = this._items.filter(n => n.id !== id);
this._panelTotal = Math.max(0, this._panelTotal - 1);
this._renderPanelContent();
} catch (e) { /* best effort */ }
},
async loadMore() {
const result = await this._fetchList({ limit: 30, offset: this._panelOffset });
const newItems = result.data || [];
this._panelItems = this._panelItems.concat(newItems);
this._panelOffset += newItems.length;
this._renderPanelContent();
},
async clearAll() {
if (typeof showConfirm === 'function') {
const ok = await showConfirm('Delete all notifications?', 'This cannot be undone.');
if (!ok) return;
}
// Delete visible items (API doesn't have bulk delete, so mark all read and let retention handle it)
await this.markAllRead();
},
openPanel() {
this._closeDropdown();
if (typeof PanelRegistry !== 'undefined') {
PanelRegistry.open('notifications');
}
},
// ── WebSocket Events ─────────────────────
_bindEvents() {
if (typeof Events === 'undefined') return;
// New notification from server
Events.on('notification.new', (payload) => {
// Prepend to cached list
this._items.unshift(payload);
if (this._items.length > 50) this._items.length = 50;
this._unreadCount++;
this._renderBadge();
// Update dropdown if open
if (this._dropdownOpen) {
const dd = document.getElementById('notifDropdown');
if (dd) dd.innerHTML = this._renderDropdownContent();
}
// Update panel if it has items
if (this._panelItems.length) {
this._panelItems.unshift(payload);
this._panelTotal++;
this._panelOffset++;
this._renderPanelContent();
}
// Toast for high-priority types
const highPriority = ['kb.error', 'role.fallback'];
if (highPriority.includes(payload.type)) {
if (typeof UI !== 'undefined' && UI.toast) {
UI.toast(payload.title, 'warning');
}
}
});
// Badge sync across tabs (from markRead / markAllRead in another tab)
Events.on('notification.read', (payload) => {
if (payload.action === 'mark_all_read') {
this._unreadCount = 0;
this._items.forEach(n => n.is_read = true);
this._panelItems.forEach(n => n.is_read = true);
} else if (payload.id) {
this._unreadCount = Math.max(0, this._unreadCount - 1);
const item = this._items.find(n => n.id === payload.id);
if (item) item.is_read = true;
const pItem = this._panelItems.find(n => n.id === payload.id);
if (pItem) pItem.is_read = true;
}
this._renderBadge();
if (this._dropdownOpen) {
const dd = document.getElementById('notifDropdown');
if (dd) dd.innerHTML = this._renderDropdownContent();
}
this._renderPanelContent();
});
},
};
// ── Helpers ──────────────────────────────────
function _timeAgo(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
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`;
if (sec < 604800) return `${Math.floor(sec / 86400)}d ago`;
return d.toLocaleDateString();
}
// ── Exports ─────────────────────────────────
sb.ns('Notifications', Notifications);

View File

@@ -1,349 +0,0 @@
// ==========================================
// Chat Switchboard Pages (v0.22.5)
// ==========================================
// Client-side handlers for Go template-rendered pages.
// Works with server-rendered HTML — no DOM construction,
// just event handling and API calls.
//
// The server pre-populates all dropdowns. This JS handles:
// - Cascading model select (provider change → filter models)
// - Admin save operations (roles, routing, providers, teams, users, settings)
// - Table filtering
//
// Exports: window.Pages, window._val (used by pages-splash.js)
// ==========================================
const Pages = {
// ── Model Select Cascading ───────────────
roleProviderChanged(selectEl) {
const providerID = selectEl.value;
const filterType = selectEl.dataset.filterType || '';
const row = selectEl.closest('.form-row') || selectEl.parentElement;
const modelSelect = row.querySelector('.model-model-select');
if (!modelSelect) return;
const models = _pageData('Models') || [];
const filtered = models.filter(m =>
m.provider_config_id === providerID &&
(!filterType || m.model_type === filterType)
);
modelSelect.innerHTML = '<option value="">— select model —</option>' +
filtered.map(m =>
`<option value="${m.model_id}">${esc(m.display_name || m.model_id)}</option>`
).join('');
},
// ── Role Save ────────────────────────────
async saveRole(roleName) {
const container = document.querySelector(`.role-config[data-role="${roleName}"]`);
if (!container) return;
const getValue = (slot, cls) => {
const sel = container.querySelector(`[data-slot="${slot}"].${cls}`);
return sel ? sel.value : '';
};
const body = {
role: roleName,
primary: getValue('primary','model-provider-select') && getValue('primary','model-model-select')
? { provider_config_id: getValue('primary','model-provider-select'), model_id: getValue('primary','model-model-select') }
: null,
fallback: getValue('fallback','model-provider-select') && getValue('fallback','model-model-select')
? { provider_config_id: getValue('fallback','model-provider-select'), model_id: getValue('fallback','model-model-select') }
: null,
};
const resp = await _api('PUT', '/api/v1/admin/roles/' + roleName, body);
resp ? _toast('Role saved', 'success') : null;
},
// ── Routing Policy ───────────────────────
showRoutingForm() { _show('routingPolicyForm'); },
hideRoutingForm() { _hide('routingPolicyForm'); },
routingScopeChanged(sel) {
const row = document.getElementById('routingTeamRow');
if (row) row.style.display = sel.value === 'team' ? '' : 'none';
},
async saveRoutingPolicy() {
const id = _val('routingPolicyId');
const body = {
name: _val('routingPolicyName'),
priority: parseInt(_val('routingPolicyPriority') || '100'),
type: _val('routingPolicyType'),
scope: _val('routingPolicyScope'),
team_id: _val('routingPolicyScope') === 'team' ? _val('routingPolicyTeamId') : '',
config: _parseJSON(_val('routingPolicyConfig')),
active: document.getElementById('routingPolicyActive')?.checked ?? true,
};
if (body.config === null) return; // parse error
const ok = await _api(id ? 'PUT' : 'POST',
id ? '/api/v1/admin/routing/policies/' + id : '/api/v1/admin/routing/policies', body);
if (ok) { this.hideRoutingForm(); window.location.reload(); }
},
// ── Providers ────────────────────────────
showProviderForm() { _show('providerFormWrap'); _val('providerFormId', ''); _val('providerFormTitle', 'Add Provider'); },
hideProviderForm() { _hide('providerFormWrap'); },
editProvider(id) {
const details = (_pageData('ProviderDetails') || []).find(p => p.id === id);
if (!details) return;
_val('providerFormId', id);
_val('providerFormName', details.name);
_val('providerFormEndpoint', details.endpoint);
document.getElementById('providerFormTitle').textContent = 'Edit Provider';
_show('providerFormWrap');
},
async saveProvider() {
const id = _val('providerFormId');
const body = {
name: _val('providerFormName'),
provider: _val('providerFormType'),
endpoint: _val('providerFormEndpoint'),
scope: _val('providerFormScope'),
};
const key = _val('providerFormKey');
if (key) body.api_key = key;
const teamId = _val('providerFormTeamId');
if (body.scope === 'team' && teamId) body.team_id = teamId;
const ok = await _api(id ? 'PUT' : 'POST',
id ? '/api/v1/admin/providers/' + id : '/api/v1/admin/providers', body);
if (ok) { this.hideProviderForm(); window.location.reload(); }
},
async syncProvider(id) {
_toast('Syncing…', 'info');
const ok = await _api('POST', '/api/v1/admin/providers/' + id + '/sync', {});
if (ok) { _toast('Sync complete', 'success'); window.location.reload(); }
},
// ── Model Filtering ──────────────────────
filterModels(query) {
const q = (query || _val('modelSearch') || '').toLowerCase();
const typeF = _val('modelTypeFilter') || '';
const provF = _val('modelProviderFilter') || '';
document.querySelectorAll('#modelTable tbody tr').forEach(tr => {
const name = (tr.dataset.name || '').toLowerCase();
const type = tr.dataset.type || '';
const prov = tr.dataset.provider || '';
const show = (!q || name.includes(q)) && (!typeF || type === typeF) && (!provF || prov === provF);
tr.style.display = show ? '' : 'none';
});
},
// ── Teams ────────────────────────────────
showTeamForm() { _show('teamFormWrap'); _val('teamFormId', ''); _val('teamFormName', ''); _val('teamFormDescription', ''); },
hideTeamForm() { _hide('teamFormWrap'); },
editTeam(id, name, desc) {
_val('teamFormId', id);
_val('teamFormName', name);
_val('teamFormDescription', desc);
document.getElementById('teamFormTitle').textContent = 'Edit Team';
_show('teamFormWrap');
},
async saveTeam() {
const id = _val('teamFormId');
const body = { name: _val('teamFormName'), description: _val('teamFormDescription') };
const ok = await _api(id ? 'PUT' : 'POST',
id ? '/api/v1/admin/teams/' + id : '/api/v1/admin/teams', body);
if (ok) { this.hideTeamForm(); window.location.reload(); }
},
// ── Users ────────────────────────────────
filterUsers(query) {
const q = (query || '').toLowerCase();
document.querySelectorAll('#userTable tbody tr').forEach(tr => {
tr.style.display = (!q || (tr.dataset.name || '').toLowerCase().includes(q)) ? '' : 'none';
});
},
async editUserRole(id, username, currentRole) {
const role = await showPrompt({ title: `Set role for ${username}`, value: currentRole, ok: 'Save' });
if (!role || role === currentRole) return;
const ok = await _api('PUT', '/api/v1/admin/users/' + id, { role });
if (ok) window.location.reload();
},
async toggleUser(id, active) {
const ok = await _api('PUT', '/api/v1/admin/users/' + id, { is_active: active });
if (ok) window.location.reload();
},
// ── Settings ─────────────────────────────
async saveSettings() {
// Policies
const policies = {
allow_registration: document.getElementById('settRegEnabled')?.checked ? 'true' : 'false',
default_user_active: _val('settRegDefaultState') === 'active' ? 'true' : 'false',
allow_user_byok: document.getElementById('settUserBYOK')?.checked ? 'true' : 'false',
allow_user_personas: document.getElementById('settUserPersonas')?.checked ? 'true' : 'false',
kb_direct_access: document.getElementById('settKBDirect')?.checked ? 'true' : 'false',
};
// Banner
const banner = {
enabled: document.getElementById('settBannerEnabled')?.checked || false,
text: _val('settBannerText'),
bg: _val('settBannerBg'),
fg: _val('settBannerFg'),
};
// System prompt
const system_prompt = { content: _val('settSystemPrompt') };
const ok = await _api('PUT', '/api/v1/admin/settings', { policies, settings: { banner, system_prompt } });
if (ok) _toast('Settings saved', 'success');
},
// ── Login ─────────────────────────────────
async doLogin() {
const username = _val('loginUsername');
const password = _val('loginPassword');
const errEl = document.getElementById('loginError');
const btn = document.getElementById('loginBtn');
if (!username || !password) {
if (errEl) { errEl.textContent = 'Enter username and password'; errEl.style.display = ''; }
return;
}
if (errEl) errEl.style.display = 'none';
if (btn) { btn.disabled = true; btn.textContent = 'Logging in…'; }
const base = window.__BASE__ || '';
try {
const resp = await fetch(base + '/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login: username, password }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `Login failed (${resp.status})`);
}
const data = await resp.json();
// Save tokens in same format as API._setAuth / API.saveTokens
const storageKey = base ? `sb_auth_${base.replace(/\//g, '')}` : 'sb_auth';
localStorage.setItem(storageKey, JSON.stringify({
accessToken: data.access_token,
refreshToken: data.refresh_token,
user: data.user,
}));
// Set cookie for server-rendered page auth
document.cookie = `sb_token=${data.access_token}; path=/; max-age=900; SameSite=Strict`;
// Redirect to chat surface
window.location.href = base + '/';
} catch (e) {
if (errEl) { errEl.textContent = e.message; errEl.style.display = ''; }
if (btn) { btn.disabled = false; btn.textContent = 'Log In'; }
}
},
// ── User Settings (settings surface) ─────
async saveProfile() {
const name = _val('profileDisplayName');
if (!name) { _toast('Display name is required', 'error'); return; }
const ok = await _api('PUT', '/api/v1/profile', { display_name: name });
if (ok) _toast('Profile saved', 'success');
},
async changePassword() {
const current = _val('settingsCurrentPw');
const newPw = _val('settingsNewPw');
const confirm = _val('settingsConfirmPw');
if (!current || !newPw) { _toast('All password fields are required', 'error'); return; }
if (newPw !== confirm) { _toast('Passwords do not match', 'error'); return; }
if (newPw.length < 8) { _toast('Password must be at least 8 characters', 'error'); return; }
const ok = await _api('POST', '/api/v1/profile/password', { current_password: current, new_password: newPw });
if (ok) {
_toast('Password changed', 'success');
_val('settingsCurrentPw', '');
_val('settingsNewPw', '');
_val('settingsConfirmPw', '');
}
},
};
// ── Init: banner toggle ──────────────────────
document.addEventListener('DOMContentLoaded', () => {
const cb = document.getElementById('settBannerEnabled');
const fields = document.getElementById('bannerFields');
if (cb && fields) {
cb.addEventListener('change', () => { fields.style.display = cb.checked ? '' : 'none'; });
fields.style.display = cb.checked ? '' : 'none';
}
});
// ── Helpers ──────────────────────────────────
function _pageData(key) {
const d = window.__PAGE_DATA__;
return d ? (d[key] || d[key.toLowerCase()]) : null;
}
function _val(id, setVal) {
const el = document.getElementById(id);
if (!el) return '';
if (setVal !== undefined) { el.value = setVal; return; }
return el.value;
}
function _show(id) { const el = document.getElementById(id); if (el) el.style.display = ''; }
function _hide(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; }
function _toast(msg, type) {
if (typeof UI !== 'undefined' && UI.toast) {
UI.toast(msg, type);
} else {
console[type === 'error' ? 'error' : 'log']('[Pages]', msg);
}
}
function _parseJSON(text) {
if (!text || !text.trim()) return {};
try { return JSON.parse(text); }
catch (e) { _toast('Invalid JSON', 'error'); return null; }
}
async function _api(method, path, body) {
const base = window.__BASE__ || '';
const storageKey = base ? `sb_auth_${base.replace(/\//g, '')}` : 'sb_auth';
let token = '';
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || '{}');
token = saved.accessToken || '';
} catch (e) { /* ignore */ }
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
try {
const resp = await fetch(base + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
if (resp.ok) return true;
const err = await resp.json().catch(() => ({}));
_toast(err.error || `Error ${resp.status}`, 'error');
return false;
} catch (e) {
_toast('Connection error', 'error');
return false;
}
}
// ── Exports ─────────────────────────────────
sb.ns('Pages', Pages);
sb.register('_val', _val); // used by pages-splash.js

View File

@@ -1,589 +0,0 @@
// ==========================================
// Chat Switchboard — PaneContainer
// ==========================================
// v0.25.0: Composable multi-pane layout system.
// Replaces the "one surface fills viewport" model with resizable
// side-by-side panes. Supports leaf panes (single component) and
// tabbed panes (N components, tab bar, one active at a time).
//
// Layout presets define the pane structure. The surface manifest
// declares which preset to use via __MANIFEST__.layout.
//
// Usage:
// const layout = PaneContainer.mount(
// document.getElementById('editorBody'),
// 'editor',
// { workspaceId: 'ws-123' }
// );
// const fileTree = layout.getPane('files');
// layout.resize('files', 250);
// layout.destroy();
//
// Exports: window.PaneContainer
const PaneContainer = {
_presets: {},
_active: null,
// ── Layout Preset Registration ──────────
/**
* Register a named layout preset.
* @param {string} name - Preset name (e.g. 'single', 'editor', 'split')
* @param {object} definition - Layout tree definition
*
* Definition format:
* { type: 'leaf', id: 'main', component: 'chat-pane', opts: {} }
* { type: 'tabbed', id: 'right', tabs: [{id,label,component,opts}], defaultTab: 0 }
* { type: 'split', direction: 'horizontal', children: [def, def, ...], sizes: [220, null, 380] }
*
* sizes: array matching children. Numbers are initial px widths. null = flex:1 (fills remaining).
*/
registerPreset(name, definition) {
this._presets[name] = definition;
},
// ── Mount ───────────────────────────────
/**
* Mount a layout preset into a root element.
* @param {HTMLElement} rootEl - Container element
* @param {string} presetName - Registered preset name
* @param {object} opts - Passed to component factories (e.g. { workspaceId })
* @returns {object} Layout instance
*/
mount(rootEl, presetName, opts) {
if (!rootEl) { console.error('[PaneContainer] No root element'); return null; }
const preset = this._presets[presetName];
if (!preset) { console.error('[PaneContainer] Unknown preset:', presetName); return null; }
opts = opts || {};
const surfaceId = window.__SURFACE__ || 'unknown';
// Build the DOM tree from the preset definition
const panes = new Map(); // id → { el, component, type }
const handles = [];
const buildNode = (def, parentEl) => {
if (def.type === 'leaf') {
return _buildLeafPane(def, parentEl, panes, opts);
}
if (def.type === 'tabbed') {
return _buildTabbedPane(def, parentEl, panes, opts, surfaceId);
}
if (def.type === 'split') {
return _buildSplit(def, parentEl, panes, handles, opts, surfaceId);
}
console.error('[PaneContainer] Unknown node type:', def.type);
return null;
};
rootEl.classList.add('pane-container');
const rootNode = buildNode(preset, rootEl);
// Restore persisted sizes
_restoreSizes(surfaceId, presetName, handles);
const instance = {
rootEl,
presetName,
_panes: panes,
_handles: handles,
_rootNode: rootNode,
/** Get the component instance for a pane by ID. */
getPane(id) {
const p = panes.get(id);
return p ? p.component : null;
},
/** Get the pane element by ID. */
getPaneEl(id) {
const p = panes.get(id);
return p ? p.el : null;
},
/** Programmatic resize of a leaf pane. */
resize(id, sizePx) {
const p = panes.get(id);
if (p?.el) {
p.el.style.flexBasis = sizePx + 'px';
p.el.style.flexGrow = '0';
p.el.style.flexShrink = '0';
}
},
/** Minimize a pane (collapse to 0). */
minimize(id) {
const p = panes.get(id);
if (!p?.el) return;
p._prevBasis = p.el.style.flexBasis;
p._prevGrow = p.el.style.flexGrow;
p.el.style.flexBasis = '0px';
p.el.style.flexGrow = '0';
p.el.style.overflow = 'hidden';
p.el.classList.add('pane-minimized');
},
/** Restore a minimized pane. */
restore(id) {
const p = panes.get(id);
if (!p?.el) return;
p.el.style.flexBasis = p._prevBasis || '';
p.el.style.flexGrow = p._prevGrow || '';
p.el.style.overflow = '';
p.el.classList.remove('pane-minimized');
},
/** Tear down the entire layout. */
destroy() {
// Destroy all component instances
for (const [, p] of panes) {
if (p.component?.destroy) p.component.destroy();
// Tabbed: destroy all tab components
if (p.tabs) {
for (const tab of p.tabs) {
if (tab.component?.destroy) tab.component.destroy();
}
}
}
panes.clear();
handles.length = 0;
rootEl.innerHTML = '';
rootEl.classList.remove('pane-container');
PaneContainer._active = null;
},
};
PaneContainer._active = instance;
return instance;
},
/** Get the currently active layout instance. */
active() { return this._active; },
};
// ── Leaf Pane Builder ───────────────────────
function _buildLeafPane(def, parentEl, panes, opts) {
const el = document.createElement('div');
el.className = 'pane pane-leaf';
el.dataset.paneId = def.id;
if (def.size) {
el.style.flexBasis = def.size + 'px';
el.style.flexGrow = '0';
el.style.flexShrink = '0';
} else {
el.style.flex = '1';
el.style.minWidth = '0';
el.dataset.paneFlex = '1'; // Mark as flexible — drag handles skip this pane
}
if (def.minSize) el.style.minWidth = def.minSize + 'px';
parentEl.appendChild(el);
// Component instantiation is deferred — the surface boot script
// creates components and mounts them into pane elements.
// We just register the pane slot here.
panes.set(def.id, {
el,
component: null, // set by surface boot script
type: 'leaf',
def,
});
return el;
}
// ── Tabbed Pane Builder ─────────────────────
function _buildTabbedPane(def, parentEl, panes, opts, surfaceId) {
const el = document.createElement('div');
el.className = 'pane pane-tabbed';
el.dataset.paneId = def.id;
if (def.size) {
el.style.flexBasis = def.size + 'px';
el.style.flexGrow = '0';
el.style.flexShrink = '0';
} else {
el.style.flex = '1';
el.style.minWidth = '0';
}
if (def.minSize) el.style.minWidth = def.minSize + 'px';
// Tab bar
const tabBar = document.createElement('div');
tabBar.className = 'pane-tab-bar';
el.appendChild(tabBar);
// Content area
const contentArea = document.createElement('div');
contentArea.className = 'pane-tab-content';
el.appendChild(contentArea);
// Restore persisted active tab
const persistKey = 'sb_tabs_' + surfaceId + '_' + def.id;
let savedTab = 0;
try { savedTab = parseInt(localStorage.getItem(persistKey)) || 0; } catch (_) {}
if (savedTab >= (def.tabs || []).length) savedTab = 0;
const tabs = (def.tabs || []).map((tabDef, i) => {
// Tab button
const btn = document.createElement('button');
btn.className = 'pane-tab-btn' + (i === savedTab ? ' pane-tab-btn--active' : '');
btn.textContent = tabDef.label || tabDef.id;
btn.dataset.tabIndex = i;
tabBar.appendChild(btn);
// Tab panel (mount point for component)
const panel = document.createElement('div');
panel.className = 'pane-tab-panel';
panel.dataset.tabId = tabDef.id;
panel.style.display = i === savedTab ? '' : 'none';
contentArea.appendChild(panel);
return {
id: tabDef.id,
label: tabDef.label,
component: tabDef.component,
opts: tabDef.opts || {},
btn,
panel,
instance: null, // lazy-created on first activation
_activated: i === savedTab,
};
});
// Tab click handler
const activateTab = (index) => {
tabs.forEach((tab, i) => {
const isActive = i === index;
tab.btn.classList.toggle('pane-tab-btn--active', isActive);
tab.panel.style.display = isActive ? '' : 'none';
if (isActive) tab._activated = true;
});
// Persist
try { localStorage.setItem(persistKey, String(index)); } catch (_) {}
};
tabs.forEach((tab, i) => {
tab.btn.addEventListener('click', () => activateTab(i));
});
parentEl.appendChild(el);
panes.set(def.id, {
el,
component: null, // tabbed panes don't have a single component
type: 'tabbed',
def,
tabs,
activateTab,
getActiveTab() {
return tabs.find((_, i) => tabs[i].btn.classList.contains('pane-tab-btn--active')) || tabs[0];
},
getTabPanel(tabId) {
const t = tabs.find(t => t.id === tabId);
return t ? t.panel : null;
},
});
return el;
}
// ── Split Builder ───────────────────────────
function _buildSplit(def, parentEl, panes, handles, opts, surfaceId) {
const el = document.createElement('div');
el.className = 'pane-split pane-split--' + (def.direction || 'horizontal');
parentEl.appendChild(el);
const children = def.children || [];
const sizes = def.sizes || [];
children.forEach((childDef, i) => {
// Apply initial size from preset
if (sizes[i] != null && childDef.size === undefined) {
childDef.size = sizes[i];
}
// Build child
if (childDef.type === 'leaf') {
_buildLeafPane(childDef, el, panes, opts);
} else if (childDef.type === 'tabbed') {
_buildTabbedPane(childDef, el, panes, opts, surfaceId);
} else if (childDef.type === 'split') {
_buildSplit(childDef, el, panes, handles, opts, surfaceId);
}
// Add drag handle between children (not after last)
if (i < children.length - 1) {
const handle = _createHandle(def.direction || 'horizontal', el, i, surfaceId, def.id || 'root');
handles.push(handle);
}
});
return el;
}
// ── Drag Handle ─────────────────────────────
function _createHandle(direction, splitEl, index, surfaceId, splitId) {
const handle = document.createElement('div');
handle.className = 'pane-handle pane-handle--' + direction;
handle.dataset.handleIndex = index;
// Insert after the Nth child (child, handle, child, handle, child)
// Children are at positions 0, 2, 4, ... and handles at 1, 3, ...
// But since we add sequentially, handle goes after the (index)th pane
const childEls = splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split');
const afterEl = childEls[index];
if (afterEl?.nextSibling) {
splitEl.insertBefore(handle, afterEl.nextSibling);
} else {
splitEl.appendChild(handle);
}
// Track adjacent panes for dblclick reset
let lastLeftEl = null, lastRightEl = null;
initDragResize(handle, {
direction,
onStart(_clientPos) {
// Find the two panes adjacent to this handle
const handleIdx = Array.from(splitEl.children).indexOf(handle);
const leftEl = splitEl.children[handleIdx - 1];
const rightEl = splitEl.children[handleIdx + 1];
if (!leftEl || !rightEl) return false;
lastLeftEl = leftEl;
lastRightEl = rightEl;
const isHoriz = direction === 'horizontal';
const dim = isHoriz ? 'width' : 'height';
const startLeftBasis = leftEl.getBoundingClientRect()[dim];
const startRightBasis = rightEl.getBoundingClientRect()[dim];
// Lock non-flex panes to current rendered size so the first
// move delta doesn't cause a visual snap
if (leftEl.dataset.paneFlex !== '1') {
leftEl.style.flexBasis = startLeftBasis + 'px';
leftEl.style.flexGrow = '0';
leftEl.style.flexShrink = '0';
}
if (rightEl.dataset.paneFlex !== '1') {
rightEl.style.flexBasis = startRightBasis + 'px';
rightEl.style.flexGrow = '0';
rightEl.style.flexShrink = '0';
}
handle.classList.add('pane-handle--active');
return { leftEl, rightEl, startLeftBasis, startRightBasis };
},
onMove(delta, ctx) {
const { leftEl, rightEl, startLeftBasis, startRightBasis } = ctx;
const leftIsFlex = leftEl.dataset.paneFlex === '1';
const rightIsFlex = rightEl.dataset.paneFlex === '1';
const isHoriz = direction === 'horizontal';
const dim = isHoriz ? 'width' : 'height';
const maxSize = splitEl.getBoundingClientRect()[dim] * 0.6;
if (leftIsFlex) {
const newRight = Math.min(maxSize, Math.max(50, startRightBasis - delta));
rightEl.style.flexBasis = newRight + 'px';
rightEl.style.flexGrow = '0';
rightEl.style.flexShrink = '0';
} else if (rightIsFlex) {
const newLeft = Math.min(maxSize, Math.max(50, startLeftBasis + delta));
leftEl.style.flexBasis = newLeft + 'px';
leftEl.style.flexGrow = '0';
leftEl.style.flexShrink = '0';
} else {
const newLeft = Math.max(50, startLeftBasis + delta);
const newRight = Math.max(50, startRightBasis - delta);
leftEl.style.flexBasis = newLeft + 'px';
leftEl.style.flexGrow = '0';
leftEl.style.flexShrink = '0';
rightEl.style.flexBasis = newRight + 'px';
rightEl.style.flexGrow = '0';
rightEl.style.flexShrink = '0';
}
},
onEnd(_ctx) {
handle.classList.remove('pane-handle--active');
_persistSizes(surfaceId, splitEl);
},
});
// Double-click resets to default
handle.addEventListener('dblclick', () => {
_resetPaneSize(lastLeftEl);
_resetPaneSize(lastRightEl);
_persistSizes(surfaceId, splitEl);
});
return { handle, splitEl, index, direction };
}
/** Reset a pane element to its default flex sizing. */
function _resetPaneSize(el) {
if (!el) return;
if (el.dataset.paneFlex === '1') {
el.style.flex = '1';
}
el.style.flexBasis = '';
el.style.flexGrow = '';
el.style.flexShrink = '';
}
// ── Persistence ─────────────────────────────
// v0.27.0: Debounced server sync for cross-device pane layout persistence.
var _paneSyncTimer = null;
function _syncPaneLayoutsToServer() {
if (_paneSyncTimer) clearTimeout(_paneSyncTimer);
_paneSyncTimer = setTimeout(function() {
try {
// Collect all sb_layout_* keys
var layouts = {};
for (var i = 0; i < localStorage.length; i++) {
var k = localStorage.key(i);
if (k && k.indexOf('sb_layout_') === 0) {
try { layouts[k] = JSON.parse(localStorage.getItem(k)); } catch (_) {}
}
}
if (typeof API !== 'undefined' && API._put) {
API._put('/api/v1/settings', { pane_layouts: layouts }).catch(function() {});
}
} catch (_) {}
}, 2000); // 2s debounce — avoid hammering during resize drags
}
// v0.27.0: On page load, restore pane layouts from server if available.
// Called once from PaneContainer init (or surface boot).
function _loadPaneLayoutsFromServer() {
if (typeof API === 'undefined' || !API._get) return;
API._get('/api/v1/settings').then(function(settings) {
if (!settings || !settings.pane_layouts) return;
var layouts = settings.pane_layouts;
for (var key in layouts) {
if (key.indexOf('sb_layout_') === 0 && layouts[key]) {
// Only overwrite if localStorage is empty for this key
if (!localStorage.getItem(key)) {
localStorage.setItem(key, JSON.stringify(layouts[key]));
}
}
}
}).catch(function() {});
}
// Kick off server layout load on script execution
if (typeof API !== 'undefined') _loadPaneLayoutsFromServer();
function _persistSizes(surfaceId, splitEl) {
const key = 'sb_layout_' + surfaceId;
try {
// Collect sizes for fixed panes only (skip flex panes)
const sizes = {};
splitEl.querySelectorAll(':scope > .pane, :scope > .pane-split').forEach(child => {
const id = child.dataset?.paneId;
if (id && child.dataset.paneFlex !== '1') {
sizes[id] = child.getBoundingClientRect().width;
}
});
// Merge with existing persisted data
let existing = {};
try { existing = JSON.parse(localStorage.getItem(key) || '{}'); } catch (_) {}
Object.assign(existing, sizes);
localStorage.setItem(key, JSON.stringify(existing));
// v0.27.0: Debounced sync to server for cross-device persistence
_syncPaneLayoutsToServer();
} catch (_) {}
}
function _restoreSizes(surfaceId, presetName, handles) {
const key = 'sb_layout_' + surfaceId;
try {
const saved = JSON.parse(localStorage.getItem(key) || '{}');
if (Object.keys(saved).length === 0) return;
// Find the split container to measure available width
const splitEl = document.querySelector('.pane-split--horizontal');
const containerWidth = splitEl ? splitEl.getBoundingClientRect().width : window.innerWidth;
// Validate: sum of all fixed pane sizes must leave at least 150px for flex pane
const fixedPanes = document.querySelectorAll('.pane[data-pane-id]');
let totalFixed = 0;
fixedPanes.forEach(el => {
const id = el.dataset.paneId;
if (saved[id] && el.dataset.paneFlex !== '1') {
totalFixed += saved[id];
}
});
// If saved sizes overflow, clear corrupt data and use defaults
if (totalFixed > containerWidth - 150) {
console.warn('[PaneContainer] Saved layout overflows viewport (' + totalFixed + 'px > ' + containerWidth + 'px), resetting');
localStorage.removeItem(key);
return;
}
// Apply saved sizes to pane elements (skip flex panes)
fixedPanes.forEach(el => {
const id = el.dataset.paneId;
if (saved[id] && el.dataset.paneFlex !== '1') {
el.style.flexBasis = saved[id] + 'px';
el.style.flexGrow = '0';
el.style.flexShrink = '0';
}
});
} catch (_) {}
}
// ── Built-in Presets ────────────────────────
// single: One leaf pane (default chat UX)
PaneContainer.registerPreset('single', {
type: 'leaf',
id: 'main',
component: 'chat-pane',
});
// editor: files | code-editor | <chat, notes>
PaneContainer.registerPreset('editor', {
type: 'split',
id: 'root',
direction: 'horizontal',
sizes: [220, null, 380],
children: [
{ type: 'leaf', id: 'files', component: 'file-tree', size: 220, minSize: 140 },
{ type: 'leaf', id: 'editor', component: 'code-editor' },
{
type: 'tabbed', id: 'assist', size: 380, minSize: 200,
tabs: [
{ id: 'chat', label: 'Chat', component: 'chat-pane' },
{ id: 'notes', label: 'Notes', component: 'note-editor' },
],
},
],
});
// split: generic two-pane layout
PaneContainer.registerPreset('split', {
type: 'split',
id: 'root',
direction: 'horizontal',
children: [
{ type: 'leaf', id: 'primary', component: null },
{ type: 'leaf', id: 'secondary', component: null },
],
});
// ── Exports ─────────────────────────────────
sb.ns('PaneContainer', PaneContainer);

View File

@@ -1,454 +0,0 @@
// ==========================================
// Chat Switchboard Panel Registry (v0.22.0)
// ==========================================
// Workspace-aware panel system. Panels are shown inside the
// workspace secondary pane (.workspace-secondary). The workspace
// owns the resize handle between primary and secondary.
//
// Each panel registers with name, DOM element, and lifecycle hooks.
// The registry owns all open/close/focus logic — individual panels
// never touch the secondary container directly.
//
// Layout model (v0.22.0):
// .workspace
// .workspace-primary ← main content (chat / notes / editor)
// .workspace-handle ← drag-resize between panes
// .workspace-secondary ← panel pages (preview / notes / project)
//
// Exports: window.PanelRegistry,window.toggleSidePanelFullscreen,window._initWorkspaceResize,window._initPanelSwipe,window._initPanelResponsive,window._initPanelOverlay
// ── Panel Registry ──────────────────────────
const PanelRegistry = {
_panels: {}, // { name: { element, label, onOpen, onClose, saveState, restoreState, actions, state } }
_active: null, // currently visible panel name
_order: [], // registration order (for tab rendering + cycle)
/**
* Register a panel.
* @param {string} name — unique identifier (e.g. 'preview', 'notes')
* @param {object} opts
* @param {HTMLElement} opts.element — the .side-panel-page div
* @param {string} opts.label — display name
* @param {Function} [opts.onOpen] — called when panel becomes visible
* @param {Function} [opts.onClose] — called when panel is hidden
* @param {Function} [opts.saveState] — returns state snapshot before hiding
* @param {Function} [opts.restoreState]— receives state snapshot on re-show
* @param {Array} [opts.actions] — per-panel action button configs
* Each action: { id, icon, title, onClick, visible? }
*/
register(name, opts) {
if (this._panels[name]) {
console.warn(`[panels] panel "${name}" already registered, replacing`);
}
this._panels[name] = {
element: opts.element,
label: opts.label || name,
onOpen: opts.onOpen || null,
onClose: opts.onClose || null,
saveState: opts.saveState || null,
restoreState: opts.restoreState || null,
actions: opts.actions || [],
state: {},
};
if (!this._order.includes(name)) {
this._order.push(name);
}
this._renderLabel();
},
/**
* Open a panel (and the secondary pane if collapsed).
* Hides current active panel, shows the requested one.
*/
open(name) {
const panel = this._panels[name];
if (!panel) {
console.warn(`[panels] unknown panel: "${name}"`);
return;
}
const container = this._container();
if (!container) return;
// Already active — just refresh label/actions
if (this._active === name) {
this._renderLabel();
this._renderActions();
return;
}
// Hide current active (saves state + fires onClose)
if (this._active && this._active !== name) {
this._hide(this._active);
}
// Hide ALL panel pages in the body, including unregistered ones
// (e.g. sidePanelPreview which is a static placeholder)
const body = this._body();
if (body) {
body.querySelectorAll('.side-panel-page').forEach(el => {
if (el !== panel.element) el.style.display = 'none';
});
}
// Show the secondary pane
container.classList.add('open');
this._showHandle(true);
this._show(name);
this._active = name;
this._showOverlay();
this._renderLabel();
this._renderActions();
},
/**
* Close a specific panel. If it's active, close the secondary pane.
*/
close(name) {
const panel = this._panels[name];
if (!panel) return;
if (this._active === name) {
this._hide(name);
this._active = null;
this._closeContainer();
}
},
/**
* Toggle: if this panel is visible, close it. Otherwise, open it.
*/
toggle(name) {
if (this.isOpen(name)) {
this.close(name);
} else {
this.open(name);
}
},
/**
* Close all panels and the secondary pane.
*/
closeAll() {
if (this._active) {
this._hide(this._active);
this._active = null;
}
this._closeContainer();
},
/** Is a specific panel currently visible? */
isOpen(name) {
return this._active === name;
},
/** Is the secondary pane open at all? */
isContainerOpen() {
return this._container()?.classList.contains('open') || false;
},
/** Get the active panel name (or null). */
active() {
return this._active;
},
/**
* Cycle to the next registered panel. If container is closed, open
* the first panel. Wraps around.
*/
cycle() {
if (this._order.length === 0) return;
if (!this._active) {
this.open(this._order[0]);
return;
}
const idx = this._order.indexOf(this._active);
const next = this._order[(idx + 1) % this._order.length];
this.open(next);
},
// ── Internal ────────────────────────────
/** The secondary pane element. */
_container() {
return document.getElementById('workspaceSecondary');
},
_body() {
return document.getElementById('sidePanelBody');
},
/** Show a panel: set display, restore state, fire onOpen. */
_show(name) {
const panel = this._panels[name];
if (!panel) return;
panel.element.style.display = '';
// Restore state if previously saved
if (panel.restoreState && Object.keys(panel.state).length > 0) {
panel.restoreState(panel.state);
}
if (panel.onOpen) panel.onOpen();
},
/** Hide a panel: save state, call onClose, set display:none. */
_hide(name) {
const panel = this._panels[name];
if (!panel) return;
if (panel.saveState) {
panel.state = panel.saveState() || {};
}
if (panel.onClose) panel.onClose();
panel.element.style.display = 'none';
},
/** Close the secondary pane. */
_closeContainer() {
const container = this._container();
if (!container) return;
container.classList.remove('open', 'fullscreen');
container.style.width = '';
container.style.minWidth = '';
this._showHandle(false);
this._hideOverlay();
},
/** Show/hide the workspace resize handle. */
_showHandle(show) {
const handle = document.getElementById('workspaceHandle');
if (handle) handle.classList.toggle('active', show);
},
// ── Mobile overlay ──────────────────────
_isMobile() {
return window.innerWidth <= 768;
},
/** Show overlay behind secondary on mobile (tap-to-close). */
_showOverlay() {
const ov = document.getElementById('sidePanelOverlay');
if (ov && this._isMobile()) ov.style.display = 'block';
},
/** Hide the mobile overlay. */
_hideOverlay() {
const ov = document.getElementById('sidePanelOverlay');
if (ov) ov.style.display = 'none';
},
// ── Label and action rendering ──────────
/** Update the header label to show the active panel name. */
_renderLabel() {
const el = document.getElementById('sidePanelLabel');
if (!el) return;
if (this._active) {
const p = this._panels[this._active];
el.textContent = p ? p.label : '';
} else {
el.textContent = '';
}
},
/** Render per-panel action buttons for the active panel. */
_renderActions() {
const slot = document.getElementById('sidePanelPanelActions');
if (!slot) return;
if (!this._active) {
slot.innerHTML = '';
return;
}
const panel = this._panels[this._active];
slot.innerHTML = (panel.actions || [])
.filter(a => !a.visible || a.visible())
.map(a => `<button class="side-panel-action-btn" id="${a.id || ''}" onclick="${a.onClickName || ''}" title="${esc(a.title || '')}">${a.icon || ''}</button>`)
.join('');
},
/** Refresh action button visibility (call after state changes). */
refreshActions() {
this._renderActions();
},
};
/** Minimal HTML escaping for panel labels/titles. */
// ── Secondary Pane Fullscreen ──────────────
function toggleSidePanelFullscreen() {
const panel = document.getElementById('workspaceSecondary');
if (!panel) return;
panel.classList.toggle('fullscreen');
const btn = document.getElementById('sidePanelFullscreenBtn');
if (btn) {
const isFS = panel.classList.contains('fullscreen');
btn.title = isFS ? 'Exit fullscreen' : 'Toggle fullscreen';
btn.innerHTML = isFS
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>'
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
}
}
// ── Workspace Resize Handle ────────────────
function _initWorkspaceResize() {
const secondary = document.getElementById('workspaceSecondary');
const handle = document.getElementById('workspaceHandle');
if (!handle || !secondary) return;
initDragResize(handle, {
direction: 'horizontal',
canDrag() {
return handle.classList.contains('active') &&
!secondary.classList.contains('fullscreen');
},
onStart(_clientPos) {
// Kill transition first, force reflow, then measure.
// Pin inline width immediately to prevent snap-back if
// the open-transition is still animating.
secondary.style.transition = 'none';
void secondary.offsetWidth;
// #surfaceInner has transform:scale(z) from applyAppearance.
// getBoundingClientRect on descendants returns post-transform
// visual px. Divide by scale to recover CSS px for style.width.
const inner = document.getElementById('surfaceInner');
const m = inner?.style.transform?.match(/scale\(([^)]+)\)/);
const scale = m ? parseFloat(m[1]) : 1;
const startW = secondary.getBoundingClientRect().width / scale;
secondary.style.width = startW + 'px';
secondary.style.minWidth = startW + 'px';
return { startW, scale };
},
onMove(delta, ctx) {
// Handle is left of secondary — dragging left (negative delta) grows it.
// Mouse delta is in viewport px; convert to CSS px in scaled space.
const cssDelta = delta / ctx.scale;
const newW = Math.max(280, Math.min(window.innerWidth * 0.7, ctx.startW - cssDelta));
secondary.style.width = newW + 'px';
secondary.style.minWidth = newW + 'px';
},
onEnd(_ctx) {
secondary.style.transition = '';
},
});
}
// ── Mobile Swipe Navigation ─────────────────
function _initPanelSwipe() {
const body = document.getElementById('sidePanelBody');
if (!body) return;
let startX = 0;
let startY = 0;
let tracking = false;
body.addEventListener('touchstart', (e) => {
if (!PanelRegistry.isContainerOpen()) return;
if (PanelRegistry._order.length < 2) return;
if (e.touches.length !== 1) return;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
tracking = true;
}, { passive: true });
body.addEventListener('touchend', (e) => {
if (!tracking) return;
tracking = false;
const touch = e.changedTouches[0];
if (!touch) return;
const dx = touch.clientX - startX;
const dy = touch.clientY - startY;
// Require horizontal movement > 80px and more horizontal than vertical
if (Math.abs(dx) < 80 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
if (dx > 0) {
_cyclePanelDirection(-1);
} else {
_cyclePanelDirection(1);
}
}, { passive: true });
}
/**
* Cycle panels in a specific direction.
* direction: +1 = next, -1 = previous.
*/
function _cyclePanelDirection(direction) {
const order = PanelRegistry._order;
if (order.length < 2 || !PanelRegistry._active) return;
const idx = order.indexOf(PanelRegistry._active);
const next = order[(idx + direction + order.length) % order.length];
PanelRegistry.open(next);
}
// ── Responsive Resize Handler ───────────────
function _initPanelResponsive() {
let wasWide = window.innerWidth > 768;
window.addEventListener('resize', () => {
const isWide = window.innerWidth > 768;
// Update overlay visibility: show on mobile if panel open, hide on desktop
if (PanelRegistry.isContainerOpen()) {
const ov = document.getElementById('sidePanelOverlay');
if (ov) {
ov.style.display = isWide ? 'none' : 'block';
}
}
wasWide = isWide;
});
}
// ── Panel Overlay (mobile tap-to-close) ─────
function _initPanelOverlay() {
const ov = document.getElementById('sidePanelOverlay');
if (!ov) return;
ov.addEventListener('click', () => {
PanelRegistry.closeAll();
});
}
// ── Legacy Compat (thin wrappers) ───────────
function openSidePanel(tab) {
PanelRegistry.open(tab);
}
function closeSidePanel() {
PanelRegistry.closeAll();
}
function switchSidePanelTab(tab) {
PanelRegistry.open(tab);
}
// ── Exports ─────────────────────────────────
sb.ns('PanelRegistry', PanelRegistry);
sb.register('toggleSidePanelFullscreen', toggleSidePanelFullscreen);
sb.register('_initWorkspaceResize', _initWorkspaceResize);
sb.register('_initPanelSwipe', _initPanelSwipe);
sb.register('_initPanelResponsive', _initPanelResponsive);
sb.register('_initPanelOverlay', _initPanelOverlay);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
// ==========================================
// 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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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;
}
function _unescapeHtml(text) {
return text
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}

View File

@@ -0,0 +1,205 @@
// ==========================================
// 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>`;
}

View File

@@ -5,6 +5,9 @@
// 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} />`
@@ -20,8 +23,12 @@ 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 { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
import { EmojiPicker } from './emoji-picker.js';
const { useRef, useEffect } = window.hooks;
const { useRef, useEffect, useCallback } = window.hooks;
const html = window.html;
/**
@@ -70,6 +77,8 @@ export function ChatPane(props) {
}
});
const _dismissError = useCallback(() => chat.clearError(), [chat.clearError]);
return html`
<div class=${'sw-chat-pane' + (className ? ' ' + className : '')}>
${standalone && html`
@@ -84,23 +93,41 @@ export function ChatPane(props) {
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}
streaming=${chat.streaming} />
streaming=${chat.streaming}
hasMore=${chat.hasMore}
loadingMore=${chat.loadingMore}
onLoadMore=${chat.loadMore}
onRegenerate=${chat.regenerate}
onDelete=${chat.deleteMessage} />
<${MessageInput}
handleRef=${inputHandle}
onSend=${chat.sendMessage}
disabled=${chat.sending} />
onStop=${chat.stop}
disabled=${chat.sending}
streaming=${chat.streaming} />
</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 { 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 { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
export { EmojiPicker } from './emoji-picker.js';

View File

@@ -0,0 +1,110 @@
// ==========================================
// ChatPane Kit — MarkdownToolbar Component
// ==========================================
// Formatting toolbar for the message input.
// Wraps selection with markdown syntax. Independently importable.
//
// Usage:
// html`<${MarkdownToolbar} textareaRef=${ref} onInput=${fn} />`
const html = window.html;
/**
* Insert markdown wrapper around the textarea's selection.
*
* @param {HTMLTextAreaElement} ta
* @param {string} before — prefix (e.g. '**')
* @param {string} after — suffix (e.g. '**')
* @param {string} placeholder — text if nothing selected
* @param {Function} onInput — trigger resize after edit
*/
function _wrap(ta, before, after, placeholder, onInput) {
if (!ta) return;
ta.focus();
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = ta.value.slice(start, end) || placeholder;
const replacement = before + selected + after;
// Use execCommand for undo support, fallback to direct edit
const hasExecCommand = document.queryCommandSupported?.('insertText');
if (hasExecCommand) {
document.execCommand('insertText', false, replacement);
} else {
ta.value = ta.value.slice(0, start) + replacement + ta.value.slice(end);
}
// Select the inserted text (without wrappers) for quick overtype
const selStart = start + before.length;
const selEnd = selStart + selected.length;
ta.setSelectionRange(selStart, selEnd);
if (onInput) onInput();
}
/**
* Insert text at cursor (no wrapping).
*/
function _insert(ta, text, onInput) {
if (!ta) return;
ta.focus();
const start = ta.selectionStart;
const hasExecCommand = document.queryCommandSupported?.('insertText');
if (hasExecCommand) {
document.execCommand('insertText', false, text);
} else {
ta.value = ta.value.slice(0, start) + text + ta.value.slice(ta.selectionEnd);
}
const pos = start + text.length;
ta.setSelectionRange(pos, pos);
if (onInput) onInput();
}
const ACTIONS = [
{ key: 'bold', label: 'B', title: 'Bold (Ctrl+B)', fn: (ta, cb) => _wrap(ta, '**', '**', 'bold text', cb) },
{ key: 'italic', label: 'I', title: 'Italic (Ctrl+I)', fn: (ta, cb) => _wrap(ta, '_', '_', 'italic text', cb), style: 'font-style:italic' },
{ key: 'code', label: '<>', title: 'Inline code', fn: (ta, cb) => _wrap(ta, '`', '`', 'code', cb), style: 'font-family:var(--mono, monospace);font-size:11px' },
{ key: 'codeblk', label: '```',title: 'Code block', fn: (ta, cb) => _wrap(ta, '```\n', '\n```', 'code', cb), style: 'font-family:var(--mono, monospace);font-size:10px' },
{ key: 'link', label: '🔗', title: 'Link', fn: (ta, cb) => _wrap(ta, '[', '](url)', 'link text', cb) },
{ key: 'list', label: '•', title: 'Bullet list', fn: (ta, cb) => _insert(ta, '\n- ', cb) },
{ key: 'olist', label: '1.', title: 'Numbered list', fn: (ta, cb) => _insert(ta, '\n1. ', cb) },
{ key: 'heading', label: 'H', title: 'Heading', fn: (ta, cb) => _insert(ta, '\n## ', cb), style: 'font-weight:700' },
{ key: 'quote', label: '>', title: 'Blockquote', fn: (ta, cb) => _insert(ta, '\n> ', cb) },
];
/**
* @param {{ textareaRef: { current: HTMLTextAreaElement }, onInput?: () => void }} props
*/
export function MarkdownToolbar({ textareaRef, onInput }) {
return html`
<div class="sw-md-toolbar" role="toolbar" aria-label="Formatting">
${ACTIONS.map(a => html`
<button key=${a.key}
class="sw-md-toolbar__btn"
title=${a.title}
style=${a.style || ''}
onClick=${() => a.fn(textareaRef.current, onInput)}
tabindex="-1"
type="button">
${a.label}
</button>`)}
</div>`;
}
/**
* Handle keyboard shortcuts for formatting.
* Call from textarea onKeyDown handler.
*
* @param {KeyboardEvent} e
* @param {HTMLTextAreaElement} ta
* @param {Function} onInput
* @returns {boolean} true if handled
*/
export function handleFormatShortcut(e, ta, onInput) {
if (!(e.ctrlKey || e.metaKey)) return false;
switch (e.key) {
case 'b': e.preventDefault(); _wrap(ta, '**', '**', 'bold', onInput); return true;
case 'i': e.preventDefault(); _wrap(ta, '_', '_', 'italic', onInput); return true;
case 'e': e.preventDefault(); _wrap(ta, '`', '`', 'code', onInput); return true;
default: return false;
}
}

View File

@@ -4,6 +4,8 @@
// Wraps window.marked + window.DOMPurify with fallback.
// Independently importable utility.
//
// v0.37.10: Task list rendering, code block language extraction.
//
// Usage:
// import { renderMarkdown } from './markdown.js';
// const html = renderMarkdown('**bold** text');
@@ -17,6 +19,37 @@ 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
const renderer = new marked.Renderer();
const origListItem = renderer.listitem?.bind(renderer);
renderer.listitem = function(text, task, checked) {
if (task) {
const checkbox = 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 + ' ' + text + '</li>\n';
}
if (origListItem) return origListItem(text, task, checked);
return '<li>' + text + '</li>\n';
};
marked.use({ renderer });
}
/**
* Render markdown text to sanitized HTML.
* Falls back to escaped plaintext when marked/DOMPurify unavailable.
@@ -30,6 +63,7 @@ export function renderMarkdown(text) {
let result;
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
_ensureConfigured();
result = DOMPurify.sanitize(marked.parse(text));
} else {
result = _escapeHtml(text);

View File

@@ -0,0 +1,91 @@
// ==========================================
// 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>`;
/**
* @param {{
* role: string,
* content: string,
* onRegenerate?: () => void,
* onDelete?: () => void,
* }} props
*/
export function MessageActions({ role, content, onRegenerate, onDelete }) {
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>
${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>`;
}

View File

@@ -1,35 +1,79 @@
// ==========================================
// ChatPane Kit — MessageBubble Component
// ==========================================
// Single message rendering with role styling and markdown.
// 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} />`
// html`<${MessageBubble} role="assistant" content=${text}
// onRegenerate=${fn} onDelete=${fn} />`
import { renderMarkdown } from './markdown.js';
import { CodeBlock, extractCodeBlocks } from './code-block.js';
import { MessageActions } from './message-actions.js';
const html = window.html;
const { useMemo } = window.hooks;
/**
* @param {{ role: string, content: string, streaming?: boolean }} props
* Format a relative time string from an ISO date.
*/
export function MessageBubble({ role, content, streaming }) {
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,
* id?: string,
* created_at?: string,
* streaming?: boolean,
* onRegenerate?: () => void,
* onDelete?: () => void,
* }} props
*/
export function MessageBubble({ role, content, id, created_at, streaming, onRegenerate, onDelete }) {
const cls = 'sw-chat-msg sw-chat-msg--' + role
+ (streaming ? ' sw-chat-msg--streaming' : '');
if (role === 'user') {
// User messages use escaped text (no markdown)
return html`
<div class=${cls}>
<div class="sw-chat-msg__content"
dangerouslySetInnerHTML=${{ __html: renderMarkdown(content || '') }} />
</div>`;
}
// Parse markdown and extract code blocks for enhanced rendering
const segments = useMemo(() => {
const rendered = renderMarkdown(content || '');
return extractCodeBlocks(rendered);
}, [content]);
const timeStr = _relTime(created_at);
return html`
<div class=${cls}>
<div class="sw-chat-msg__content"
dangerouslySetInnerHTML=${{ __html: renderMarkdown(content || '') }} />
<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} />
</div>`}
</div>`;
}

View File

@@ -1,15 +1,21 @@
// ==========================================
// ChatPane Kit — MessageInput Component
// ==========================================
// Auto-resize textarea with Enter-to-send.
// Auto-resize textarea with Enter-to-send, markdown toolbar,
// emoji picker, and stop button.
// Independently importable.
//
// v0.37.10: Added MarkdownToolbar, EmojiPicker, stop overlay, aria.
//
// Usage:
// const inputHandle = useRef(null);
// html`<${MessageInput} onSend=${fn} disabled=${false} handleRef=${inputHandle} />`
// inputHandle.current.focus();
// html`<${MessageInput} onSend=${fn} onStop=${fn} disabled=${false}
// streaming=${false} handleRef=${inputHandle} />`
const { useRef, useCallback, useEffect } = window.hooks;
import { MarkdownToolbar, handleFormatShortcut } from './markdown-toolbar.js';
import { EmojiPicker } from './emoji-picker.js';
const { useRef, useState, useCallback, useEffect } = window.hooks;
const html = window.html;
const SEND_SVG = html`
@@ -19,11 +25,33 @@ const SEND_SVG = html`
<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>`;
/**
* @param {{ onSend: (text: string) => void, disabled?: boolean, handleRef?: { current: any } }} props
* @param {{
* onSend: (text: string) => void,
* onStop?: () => void,
* disabled?: boolean,
* streaming?: boolean,
* handleRef?: { current: any },
* }} props
*/
export function MessageInput({ onSend, disabled, handleRef }) {
export function MessageInput({ onSend, onStop, disabled, streaming, handleRef }) {
const taRef = useRef(null);
const [emojiOpen, setEmojiOpen] = useState(false);
function _autoResize() {
const ta = taRef.current;
@@ -40,6 +68,7 @@ export function MessageInput({ onSend, disabled, handleRef }) {
setValue(val) { if (taRef.current) { taRef.current.value = val; _autoResize(); } },
focus() { taRef.current?.focus(); },
clear() { if (taRef.current) { taRef.current.value = ''; _autoResize(); } },
getTextarea() { return taRef.current; },
};
}
return () => { if (handleRef) handleRef.current = null; };
@@ -48,6 +77,9 @@ export function MessageInput({ onSend, disabled, handleRef }) {
const _onInput = useCallback(() => _autoResize(), []);
const _onKeyDown = useCallback((e) => {
// Formatting shortcuts (Ctrl+B, Ctrl+I, Ctrl+E)
if (handleFormatShortcut(e, taRef.current, _autoResize)) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const text = taRef.current?.value?.trim();
@@ -66,8 +98,28 @@ export function MessageInput({ onSend, disabled, handleRef }) {
}
}, [onSend, disabled]);
const _insertEmoji = useCallback((emoji) => {
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), []);
return html`
<div class="sw-chat-pane__input-bar">
<${MarkdownToolbar} textareaRef=${taRef} onInput=${_autoResize} />
<div class="sw-msg-input__wrap">
<textarea
ref=${taRef}
@@ -77,13 +129,38 @@ export function MessageInput({ onSend, disabled, handleRef }) {
disabled=${disabled}
onInput=${_onInput}
onKeyDown=${_onKeyDown}
aria-label="Message input"
/>
<button
class="sw-msg-input__send"
title="Send"
disabled=${disabled}
onClick=${_onClickSend}
>${SEND_SVG}</button>
<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>`;
}

View File

@@ -1,25 +1,44 @@
// ==========================================
// ChatPane Kit — MessageList Component
// ==========================================
// Scrollable message list with auto-scroll-to-bottom.
// 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} />`
// 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 } = window.hooks;
const { useRef, useEffect, useCallback } = window.hooks;
const html = window.html;
const SCROLL_THRESHOLD = 60;
/**
* @param {{ messages: Array<{role: string, content: string}>, streamContent?: string, streaming?: boolean, className?: string }} props
* @param {{
* messages: Array<{role: string, content: string, id?: string, created_at?: string}>,
* streamContent?: 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, streaming, className }) {
export function MessageList({
messages, streamContent, streaming, hasMore, loadingMore,
onLoadMore, onRegenerate, onDelete, className,
}) {
const scrollRef = useRef(null);
const wasAtBottom = useRef(true);
const prevScrollHeightRef = useRef(0);
// Track scroll position
function _onScroll() {
@@ -36,19 +55,50 @@ export function MessageList({ messages, streamContent, streaming, className }) {
}
}, [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;
return html`
<div class=${'sw-chat-pane__messages' + (className ? ' ' + className : '')}
ref=${scrollRef}
onScroll=${_onScroll}>
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) => html`
<${MessageBubble} key=${m.id || i} role=${m.role} content=${m.content} />`)}
<${MessageBubble}
key=${m.id || i}
role=${m.role}
content=${m.content}
id=${m.id}
created_at=${m.created_at}
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} streaming=${true} />`}
</div>`;

View File

@@ -4,6 +4,8 @@
// 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');
@@ -12,6 +14,8 @@ import { useStream } from './use-stream.js';
const { useState, useRef, useCallback } = window.hooks;
const PAGE_SIZE = 50;
/**
* @param {{ initialChannelId?: string, getContext?: () => {path: string, content: string}|null, onChannelChange?: (id: string) => void }} opts
*/
@@ -23,21 +27,22 @@ export function useChat(opts = {}) {
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, streaming, startStream, stopStream } = useStream();
// Keep ref in sync
channelRef.current = channelId;
// ── Init model from first enabled ──────
// Deferred — set on first render if not already set
// ── Init model from session ─────────────
if (!model && window.sw?.auth?.session?.settings?.model) {
// Will be picked up on next render cycle via setModel
setTimeout(() => setModel(window.sw.auth.session.settings.model), 0);
}
// ── Send Message ───────────────────────
// ── Send Message ───────────────────────
const sendMessage = useCallback(async (text) => {
if (!text.trim() || sending) return;
setSending(true);
@@ -78,9 +83,6 @@ export function useChat(opts = {}) {
const data = { channel_id: cid, content, model };
const resp = await window.sw.api.channels.complete(data);
await startStream(resp);
// After stream completes, append assistant message
// (streamContent will have the final text via useStream)
} catch (e) {
if (e.name !== 'AbortError') {
setError('Error: ' + e.message);
@@ -94,15 +96,54 @@ export function useChat(opts = {}) {
const prevStreamingRef = useRef(false);
if (prevStreamingRef.current && !streaming && streamContent) {
prevStreamingRef.current = false;
// Use functional update to avoid stale closure
setMessages(prev => [...prev, { role: 'assistant', content: streamContent }]);
}
prevStreamingRef.current = streaming;
// ── Switch Channel ─────────────────────
// ── 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 });
// 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, model, 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);
@@ -110,30 +151,60 @@ export function useChat(opts = {}) {
if (!id) return;
try {
const resp = await window.sw.api.channels.messages(id, { page: 1, per_page: 100 });
setMessages(resp?.data || resp || []);
const resp = await window.sw.api.channels.messages(id, { page: 1, per_page: PAGE_SIZE });
const data = resp?.data || resp || [];
setMessages(data);
setHasMore(data.length >= PAGE_SIZE);
} catch (e) {
setError('Failed to load messages: ' + e.message);
}
}, [onChannelChange]);
// ── New Chat ───────────────────────────
// ── 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 = 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 ───────────────────────────────
// ── Stop ───────────────────────────────
const stop = useCallback(() => {
stopStream();
setSending(false);
}, [stopStream]);
// ── Clear Error ────────────────────────
// ── Clear Error ────────────────────────
const clearError = useCallback(() => setError(null), []);
return {
@@ -151,5 +222,11 @@ export function useChat(opts = {}) {
// Streaming state (passthrough from useStream)
streamContent,
streaming,
// v0.37.10: new methods
regenerate,
deleteMessage,
loadMore,
hasMore,
loadingMore,
};
}

View File

@@ -4,9 +4,13 @@
* RBAC-gated items based on sw.can() / sw.isAdmin / sw.isTeamAdmin().
* Surfaces mount this wherever they want; the shell does not own placement.
*
* Surface links (Notes, extensions) are auto-included and the CURRENT
* surface is filtered out so every surface gets the same menu minus itself.
*
* Props:
* placement — Menu direction: 'down-right' (default) | 'down-left'
* onAction — Override handler; if omitted, uses default routing
* placement — Menu direction: 'down-right' (default) | 'up-right' etc.
* onAction — Override handler; if omitted, navigates via location.href
* extraItems — Additional menu items inserted after surface links
*/
const { html } = window;
const { useState, useRef, useMemo } = hooks;
@@ -14,7 +18,14 @@ const { useState, useRef, useMemo } = hooks;
import { Avatar } from '../primitives/avatar.js';
import { Menu } from '../primitives/menu.js';
export function UserMenu({ placement = 'down-right', onAction }) {
/**
* Detect current surface from body[data-surface].
*/
function _currentSurface() {
return document.body?.dataset?.surface || '';
}
export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
const [open, setOpen] = useState(false);
const btnRef = useRef(null);
@@ -23,18 +34,51 @@ export function UserMenu({ placement = 'down-right', onAction }) {
const authenticated = sw?.auth?.isAuthenticated;
const items = useMemo(() => {
const list = [
{ label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' },
const current = _currentSurface();
const list = [];
// ── Surface links (auto-filtered) ──────
// Each surface that exists gets a menu item, except the current one.
const surfaces = [
{ key: 'chat', label: 'Chat', icon: '\ud83d\udcac' },
{ key: 'notes', label: 'Notes', icon: '\ud83d\udcdd' },
];
// Add extension surfaces from page data if available
const extSurfaces = window.__EXTENSION_SURFACES__ || [];
for (const ext of extSurfaces) {
surfaces.push({ key: ext.id || ext.route, label: ext.title, icon: '\ud83e\udde9' });
}
const surfaceItems = surfaces
.filter(s => s.key !== current)
.map(s => ({ label: s.label, action: s.key, icon: s.icon }));
if (surfaceItems.length) {
list.push(...surfaceItems);
list.push({ divider: true });
}
// ── Extra items from caller ────────────
if (extraItems && extraItems.length) {
list.push(...extraItems);
list.push({ divider: true });
}
// ── Standard items ─────────────────────
// Settings — skip if current surface IS settings
if (current !== 'settings') {
list.push({ label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' });
}
// Admin — requires admin role
if (!authenticated || sw?.isAdmin) {
if ((!authenticated || sw?.isAdmin) && current !== 'admin') {
list.push({ label: 'Admin', action: 'admin', icon: '\ud83d\udee1\ufe0f' });
}
// Team Admin — requires admin role on at least one team
const hasTeamAdmin = sw?.auth?.teams?.some(t => t.my_role === 'admin');
if (!authenticated || hasTeamAdmin) {
if ((!authenticated || hasTeamAdmin) && current !== 'team-admin') {
list.push({ label: 'Team Admin', action: 'team-admin', icon: '\ud83d\udc65' });
}
@@ -47,15 +91,27 @@ export function UserMenu({ placement = 'down-right', onAction }) {
list.push({ label: 'Sign Out', action: 'sign-out' });
return list;
}, [user, authenticated]);
}, [user, authenticated, extraItems]);
function handleSelect(action) {
setOpen(false);
if (onAction) return onAction(action);
if (action === 'sign-out') {
sw?.auth?.logout();
} else {
sw?.emit('shell.navigate', { target: action });
// Default routing
const BASE = window.__BASE__ || '';
switch (action) {
case 'sign-out':
window.sw?.auth?.logout();
break;
case 'debug':
const dbg = document.getElementById('debugModal');
if (dbg) dbg.classList.toggle('active');
break;
case 'chat':
location.href = BASE + '/';
break;
default:
location.href = BASE + '/' + action;
}
}

View File

@@ -0,0 +1,66 @@
// ==========================================
// Chat Surface — ChatWorkspace Component
// ==========================================
// Header bar (model selector, tools, sidebar toggle) + ChatPane.
// ChatPane runs standalone=false — the surface manages navigation.
import { ChatPane, ModelSelector, useChat } from '../../components/chat-pane/index.js';
const html = window.html;
const { useRef, useEffect } = window.hooks;
const PANEL_SVG = html`
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="9" y1="3" x2="9" y2="21"/>
</svg>`;
/**
* @param {{
* activeId: string|null,
* activeType: 'chat'|'channel'|null,
* onChannelChange: (id: string) => void,
* onNewChat: () => void,
* sidebarOpen: boolean,
* onToggleSidebar: () => void,
* toolsButton?: any,
* }} props
*/
export function ChatWorkspace({
activeId, activeType, onChannelChange, onNewChat,
sidebarOpen, onToggleSidebar, toolsButton,
}) {
const chatRef = useRef(null);
// When activeId changes, tell ChatPane to switch channel
useEffect(() => {
if (chatRef.current) {
chatRef.current.setChannel(activeId);
}
}, [activeId]);
return html`
<div class="sw-chat-surface__workspace">
<div class="sw-chat-surface__workspace-header">
<div class="sw-chat-surface__workspace-header-left">
${!sidebarOpen && html`
<button class="sw-chat-surface__sidebar-toggle"
onClick=${onToggleSidebar}
title="Show sidebar"
aria-label="Show sidebar">
${PANEL_SVG}
</button>`}
</div>
<div class="sw-chat-surface__workspace-header-right">
${toolsButton}
</div>
</div>
<${ChatPane}
channelId=${activeId}
standalone=${false}
handleRef=${chatRef}
onChannelChange=${onChannelChange}
className="sw-chat-surface__chat-pane" />
</div>`;
}

View File

@@ -0,0 +1,115 @@
// ==========================================
// Chat Surface — Root Component
// ==========================================
// Main chat surface: sidebar + workspace.
// Follows the settings/admin mount pattern.
//
// v0.37.10: New Preact surface replacing the legacy SPA.
import { ToastContainer } from '../../primitives/toast.js';
import { DialogStack } from '../../shell/dialog-stack.js';
import { useWorkspace } from './use-workspace.js';
import { useSidebar } from './use-sidebar.js';
import { Sidebar } from './sidebar.js';
import { ChatWorkspace } from './chat-workspace.js';
import { useTools, ToolsPopup } from './tools-popup.js';
const { render } = window.preact;
const html = window.html;
const { useState, useCallback } = window.hooks;
const WRENCH_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="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0
0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1
7.94-7.94l-3.76 3.76z"/>
</svg>`;
function ChatSurface() {
const workspace = useWorkspace();
const sidebar = useSidebar({ activeId: workspace.activeId });
const tools = useTools();
const [toolsOpen, setToolsOpen] = useState(false);
const _onNewChat = useCallback(() => {
workspace.clearSelection();
}, [workspace.clearSelection]);
const _onChannelChange = useCallback((id) => {
if (id) workspace.selectChat(id);
}, [workspace.selectChat]);
const _toggleSidebar = useCallback(() => {
workspace.setSidebarOpen(!workspace.sidebarOpen);
}, [workspace.sidebarOpen, workspace.setSidebarOpen]);
const _toggleTools = useCallback(() => {
setToolsOpen(v => !v);
}, []);
const _closeTools = useCallback(() => {
setToolsOpen(false);
}, []);
// Tools button — shared between mobile bar and workspace header
const toolsButton = html`
<button class="sw-chat-surface__tools-btn"
onClick=${_toggleTools}
title="Tools"
aria-label="Toggle tools"
aria-expanded=${toolsOpen}>
${WRENCH_SVG}
${tools.disabled.size > 0 && html`
<span class="sw-chat-surface__tools-badge">${tools.disabled.size}</span>`}
</button>`;
return html`
<div class="sw-chat-surface">
${/* Mobile sidebar overlay */''}
${workspace.sidebarOpen && html`
<div class="sw-chat-surface__sidebar-overlay"
onClick=${_toggleSidebar} />`}
${/* Sidebar */''}
${workspace.sidebarOpen && html`
<${Sidebar}
sidebar=${sidebar}
activeId=${workspace.activeId}
onSelectChat=${workspace.selectChat}
onSelectChannel=${workspace.selectChannel}
onNewChat=${_onNewChat}
onCollapse=${_toggleSidebar} />`}
${/* Main workspace */''}
<div class="sw-chat-surface__main">
<${ChatWorkspace}
activeId=${workspace.activeId}
activeType=${workspace.activeType}
onChannelChange=${_onChannelChange}
onNewChat=${_onNewChat}
sidebarOpen=${workspace.sidebarOpen}
onToggleSidebar=${_toggleSidebar}
toolsButton=${toolsButton} />
<${ToolsPopup}
open=${toolsOpen}
onClose=${_closeTools}
categories=${tools.categories}
disabled=${tools.disabled}
onToggle=${tools.toggle}
onToggleCategory=${tools.toggleCategory} />
</div>
</div>
<${ToastContainer} />
<${DialogStack} />
`;
}
// ── Mount ────────────────────────────────────
const mount = document.getElementById('chat-mount');
if (mount) {
render(html`<${ChatSurface} />`, mount);
console.log('[chat] Surface mounted (v0.37.10)');
}
export { ChatSurface };

View File

@@ -0,0 +1,65 @@
// ==========================================
// Chat Surface — SidebarChannels Component
// ==========================================
// Collapsible channels section: DMs and named channels.
const html = window.html;
const CHEVRON_SVG = html`
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>`;
const HASH_SVG = html`
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="9" x2="20" y2="9"/>
<line x1="4" y1="15" x2="20" y2="15"/>
<line x1="10" y1="3" x2="8" y2="21"/>
<line x1="16" y1="3" x2="14" y2="21"/>
</svg>`;
/**
* @param {{
* channels: Array<{id: string, title: string, unread_count?: number}>,
* activeId: string|null,
* collapsed: boolean,
* onToggle: () => void,
* onSelect: (id: string) => void,
* }} props
*/
export function SidebarChannels({ channels, activeId, collapsed, onToggle, onSelect }) {
if (!channels.length && collapsed) return null;
return html`
<div class="sw-chat-surface__section">
<button class="sw-chat-surface__section-header"
onClick=${onToggle}
aria-expanded=${!collapsed}>
<span class=${'sw-chat-surface__chevron' + (collapsed ? '' : ' sw-chat-surface__chevron--open')}>
${CHEVRON_SVG}
</span>
<span class="sw-chat-surface__section-label">Channels</span>
${channels.length > 0 && html`
<span class="sw-chat-surface__section-count">${channels.length}</span>`}
</button>
${!collapsed && html`
<div class="sw-chat-surface__section-body" role="listbox">
${channels.length === 0 && html`
<div class="sw-chat-surface__empty">No channels</div>`}
${channels.map(ch => html`
<button key=${ch.id}
class=${'sw-chat-surface__item' + (ch.id === activeId ? ' sw-chat-surface__item--active' : '')}
onClick=${() => onSelect(ch.id)}
role="option"
aria-selected=${ch.id === activeId}
title=${ch.title || 'Untitled'}>
<span class="sw-chat-surface__item-icon">${HASH_SVG}</span>
<span class="sw-chat-surface__item-title">${ch.title || 'Untitled'}</span>
${(ch.unread_count || 0) > 0 && html`
<span class="sw-chat-surface__unread">${ch.unread_count}</span>`}
</button>`)}
</div>`}
</div>`;
}

View File

@@ -0,0 +1,199 @@
// ==========================================
// Chat Surface — SidebarChats Component
// ==========================================
// Personal AI chats with folder grouping and context menus.
const html = window.html;
const { useState, useCallback, useRef, useEffect } = window.hooks;
const CHEVRON_SVG = html`
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>`;
const CHAT_SVG = html`
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>`;
const FOLDER_SVG = html`
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>`;
const DOTS_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">
<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>
</svg>`;
/**
* @param {{
* chatsByFolder: Record<string, Array>,
* folders: Array<{id: string, name: string}>,
* activeId: string|null,
* collapsed: boolean,
* onToggle: () => void,
* onSelect: (id: string) => void,
* onRename: (id: string, title: string) => void,
* onDelete: (id: string) => void,
* onMoveToFolder: (chatId: string, folderId: string|null) => void,
* onCreateFolder: (name: string) => void,
* onRenameFolder: (id: string, name: string) => void,
* onDeleteFolder: (id: string) => void,
* }} props
*/
export function SidebarChats({
chatsByFolder, folders, activeId, collapsed, onToggle, onSelect,
onRename, onDelete, onMoveToFolder, onCreateFolder,
onRenameFolder, onDeleteFolder,
}) {
const [contextMenu, setContextMenu] = useState(null);
const menuRef = useRef(null);
// Close context menu on outside click or Escape
useEffect(() => {
if (!contextMenu) return;
function _close(e) {
if (e.type === 'keydown' && e.key !== 'Escape') return;
if (e.type === 'click' && menuRef.current?.contains(e.target)) return;
setContextMenu(null);
}
document.addEventListener('click', _close);
document.addEventListener('keydown', _close);
return () => {
document.removeEventListener('click', _close);
document.removeEventListener('keydown', _close);
};
}, [contextMenu]);
const _openMenu = useCallback((e, type, item) => {
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setContextMenu({ type, item, x: rect.right, y: rect.bottom });
}, []);
const _action = useCallback(async (action, item) => {
setContextMenu(null);
if (action === 'rename') {
const newTitle = prompt('Rename:', item.title || item.name || '');
if (newTitle && newTitle.trim()) {
if (item.name !== undefined) await onRenameFolder(item.id, newTitle.trim());
else await onRename(item.id, newTitle.trim());
}
} else if (action === 'delete') {
const label = item.name !== undefined ? 'folder' : 'chat';
if (confirm('Delete this ' + label + '?')) {
if (item.name !== undefined) await onDeleteFolder(item.id);
else await onDelete(item.id);
}
} else if (action === 'move-out') {
await onMoveToFolder(item.id, null);
} else if (action === 'new-folder') {
const name = prompt('Folder name:');
if (name && name.trim()) await onCreateFolder(name.trim());
} else if (action.startsWith('move-to:')) {
const fid = action.slice(8);
await onMoveToFolder(item.id, fid);
}
}, [onRename, onDelete, onMoveToFolder, onCreateFolder, onRenameFolder, onDeleteFolder]);
// Count total chats
const totalChats = Object.values(chatsByFolder).reduce((sum, arr) => sum + arr.length, 0);
const unfolderedChats = chatsByFolder[''] || [];
return html`
<div class="sw-chat-surface__section">
<button class="sw-chat-surface__section-header"
onClick=${onToggle}
aria-expanded=${!collapsed}>
<span class=${'sw-chat-surface__chevron' + (collapsed ? '' : ' sw-chat-surface__chevron--open')}>
${CHEVRON_SVG}
</span>
<span class="sw-chat-surface__section-label">Chats</span>
${totalChats > 0 && html`
<span class="sw-chat-surface__section-count">${totalChats}</span>`}
</button>
${!collapsed && html`
<div class="sw-chat-surface__section-body" role="listbox">
${/* Folders first */''}
${folders.map(folder => {
const folderChats = chatsByFolder[folder.id] || [];
return html`
<div key=${'f-' + folder.id} class="sw-chat-surface__folder">
<div class="sw-chat-surface__folder-header">
<span class="sw-chat-surface__item-icon">${FOLDER_SVG}</span>
<span class="sw-chat-surface__folder-name">${folder.name}</span>
<span class="sw-chat-surface__folder-count">${folderChats.length}</span>
<button class="sw-chat-surface__item-menu"
onClick=${(e) => _openMenu(e, 'folder', folder)}
title="Folder actions"
aria-label="Folder actions">
${DOTS_SVG}
</button>
</div>
${folderChats.map(ch => html`
<${ChatItem} key=${ch.id} chat=${ch} activeId=${activeId}
onSelect=${onSelect} onOpenMenu=${_openMenu}
indent=${true} />`)}
</div>`;
})}
${/* Unfoldered chats */''}
${unfolderedChats.map(ch => html`
<${ChatItem} key=${ch.id} chat=${ch} activeId=${activeId}
onSelect=${onSelect} onOpenMenu=${_openMenu} />`)}
${totalChats === 0 && html`
<div class="sw-chat-surface__empty">No chats yet</div>`}
</div>`}
${/* Context Menu */''}
${contextMenu && html`
<div class="sw-chat-surface__context-menu" ref=${menuRef}
style=${'left:' + contextMenu.x + 'px;top:' + contextMenu.y + 'px;'}>
${contextMenu.type === 'chat' && html`
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
${folders.length > 0 && folders.map(f => html`
<button key=${f.id} onClick=${() => _action('move-to:' + f.id, contextMenu.item)}>
Move to ${f.name}
</button>`)}
${contextMenu.item.folder_id && html`
<button onClick=${() => _action('move-out', contextMenu.item)}>Remove from folder</button>`}
<button class="sw-chat-surface__ctx-danger"
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
`}
${contextMenu.type === 'folder' && html`
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
<button class="sw-chat-surface__ctx-danger"
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
`}
</div>`}
</div>`;
}
function ChatItem({ chat, activeId, onSelect, onOpenMenu, indent }) {
return html`
<button class=${'sw-chat-surface__item' + (chat.id === activeId ? ' sw-chat-surface__item--active' : '') + (indent ? ' sw-chat-surface__item--indent' : '')}
onClick=${() => onSelect(chat.id)}
onContextMenu=${(e) => { e.preventDefault(); onOpenMenu(e, 'chat', chat); }}
role="option"
aria-selected=${chat.id === activeId}
title=${chat.title || 'Untitled'}>
<span class="sw-chat-surface__item-icon">${CHAT_SVG}</span>
<span class="sw-chat-surface__item-title">${chat.title || 'Untitled'}</span>
<button class="sw-chat-surface__item-menu"
onClick=${(e) => _stopAndOpen(e, onOpenMenu, 'chat', chat)}
title="Chat actions"
aria-label="Chat actions">
${DOTS_SVG}
</button>
</button>`;
}
function _stopAndOpen(e, onOpenMenu, type, item) {
e.stopPropagation();
onOpenMenu(e, type, item);
}

View File

@@ -0,0 +1,109 @@
// ==========================================
// Chat Surface — Sidebar Component
// ==========================================
// Search bar, new chat, channels section, chats section with folders.
// Footer has UserMenu (avatar) — all navigation lives in the menu flyout.
import { SidebarChannels } from './sidebar-channels.js';
import { SidebarChats } from './sidebar-chats.js';
import { UserMenu } from '../../shell/user-menu.js';
const html = window.html;
const { useCallback } = window.hooks;
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>`;
const SEARCH_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">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>`;
const COLLAPSE_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="3" y="3" width="18" height="18" rx="2"/>
<line x1="9" y1="3" x2="9" y2="21"/>
</svg>`;
/**
* @param {{
* sidebar: ReturnType<import('./use-sidebar.js').useSidebar>,
* activeId: string|null,
* onSelectChat: (id: string) => void,
* onSelectChannel: (id: string) => void,
* onNewChat: () => void,
* onCollapse: () => void,
* }} props
*/
export function Sidebar({ sidebar, activeId, onSelectChat, onSelectChannel, onNewChat, onCollapse }) {
const _onSearch = useCallback((e) => {
sidebar.setSearch(e.target.value);
}, [sidebar.setSearch]);
return html`
<div class="sw-chat-surface__sidebar" role="navigation" aria-label="Chat sidebar">
${/* Header: new chat + collapse */''}
<div class="sw-chat-surface__sidebar-header">
<button class="sw-chat-surface__new-chat-btn"
onClick=${onNewChat}
title="New chat"
aria-label="Start new chat">
${PLUS_SVG}
<span>New Chat</span>
</button>
<button class="sw-chat-surface__collapse-btn"
onClick=${onCollapse}
title="Collapse sidebar"
aria-label="Collapse sidebar">
${COLLAPSE_SVG}
</button>
</div>
${/* Search */''}
<div class="sw-chat-surface__search-wrap">
<span class="sw-chat-surface__search-icon">${SEARCH_SVG}</span>
<input class="sw-chat-surface__search"
type="text"
placeholder="Search\u2026"
value=${sidebar.search}
onInput=${_onSearch}
aria-label="Search chats and channels" />
</div>
${/* Scrollable content */''}
<div class="sw-chat-surface__sidebar-body">
<${SidebarChannels}
channels=${sidebar.channels}
activeId=${activeId}
collapsed=${sidebar.collapsed.channels}
onToggle=${() => sidebar.toggleCollapsed('channels')}
onSelect=${onSelectChannel} />
<${SidebarChats}
chatsByFolder=${sidebar.chatsByFolder}
folders=${sidebar.folders}
activeId=${activeId}
collapsed=${sidebar.collapsed.chats}
onToggle=${() => sidebar.toggleCollapsed('chats')}
onSelect=${onSelectChat}
onRename=${sidebar.renameChat}
onDelete=${sidebar.deleteChat}
onMoveToFolder=${sidebar.moveToFolder}
onCreateFolder=${sidebar.createFolder}
onRenameFolder=${sidebar.renameFolder}
onDeleteFolder=${sidebar.deleteFolder} />
</div>
${/* Footer: UserMenu only */''}
<div class="sw-chat-surface__sidebar-footer">
<${UserMenu} placement="up-right" />
</div>
</div>`;
}

View File

@@ -0,0 +1,171 @@
// ==========================================
// Chat Surface — ToolsPopup Component
// ==========================================
// Scrollable popup anchored to a toolbar icon.
// Category-based tool toggles with persistence to localStorage.
const html = window.html;
const { useState, useEffect, useRef, useCallback, useMemo } = window.hooks;
const STORAGE_KEY = 'sw-disabled-tools';
/**
* Load tools list from API and organize by category.
* @returns {{ categories: Array, disabled: Set, toggle: Function, getDisabled: Function }}
*/
export function useTools() {
const [tools, setTools] = useState([]);
const [disabled, setDisabled] = useState(_loadDisabled);
useEffect(() => {
// Load available tools from API
if (window.sw?.api?.admin?.tools?.list) {
window.sw.api.admin.tools.list().then(resp => {
setTools(resp?.data || resp || []);
}).catch(() => {});
} else if (window.sw?.api?.channels?.tools) {
// Fallback — try the channels tools endpoint
window.sw.api.channels.tools?.().then(resp => {
setTools(resp?.data || resp || []);
}).catch(() => {});
}
}, []);
const categories = useMemo(() => {
const map = {};
for (const t of tools) {
const cat = t.category || 'Other';
if (!map[cat]) map[cat] = [];
map[cat].push(t);
}
return Object.entries(map).map(([name, items]) => ({ name, items }));
}, [tools]);
const toggle = useCallback((toolName) => {
setDisabled(prev => {
const next = new Set(prev);
if (next.has(toolName)) next.delete(toolName);
else next.add(toolName);
_saveDisabled(next);
return next;
});
}, []);
const toggleCategory = useCallback((catName) => {
setDisabled(prev => {
const cat = categories.find(c => c.name === catName);
if (!cat) return prev;
const next = new Set(prev);
const allDisabled = cat.items.every(t => next.has(t.name));
for (const t of cat.items) {
if (allDisabled) next.delete(t.name);
else next.add(t.name);
}
_saveDisabled(next);
return next;
});
}, [categories]);
const getDisabled = useCallback(() => [...disabled], [disabled]);
return { categories, disabled, toggle, toggleCategory, getDisabled };
}
/**
* @param {{
* open: boolean,
* onClose: () => void,
* categories: Array<{name: string, items: Array<{name: string, description?: string}>}>,
* disabled: Set<string>,
* onToggle: (name: string) => void,
* onToggleCategory: (catName: string) => void,
* }} props
*/
export function ToolsPopup({ open, onClose, categories, disabled, onToggle, onToggleCategory }) {
const panelRef = useRef(null);
const [expandedCats, setExpandedCats] = useState({});
// Close on Escape or outside click
useEffect(() => {
if (!open) return;
function _onKey(e) {
if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
}
function _onClick(e) {
if (panelRef.current && !panelRef.current.contains(e.target)) onClose();
}
document.addEventListener('keydown', _onKey, true);
setTimeout(() => document.addEventListener('click', _onClick), 0);
return () => {
document.removeEventListener('keydown', _onKey, true);
document.removeEventListener('click', _onClick);
};
}, [open, onClose]);
const _toggleCat = useCallback((name) => {
setExpandedCats(prev => ({ ...prev, [name]: !prev[name] }));
}, []);
if (!open) return null;
const disabledCount = disabled.size;
return html`
<div class="sw-tools-popup" ref=${panelRef} role="dialog" aria-label="Tools toggle">
<div class="sw-tools-popup__header">
<span class="sw-tools-popup__title">Tools</span>
${disabledCount > 0 && html`
<span class="sw-tools-popup__badge">${disabledCount} disabled</span>`}
</div>
<div class="sw-tools-popup__body">
${categories.length === 0 && html`
<div class="sw-tools-popup__empty">No tools available</div>`}
${categories.map(cat => {
const allDisabled = cat.items.every(t => disabled.has(t.name));
const someDisabled = cat.items.some(t => disabled.has(t.name));
const isExpanded = expandedCats[cat.name] !== false; // default open
return html`
<div key=${cat.name} class="sw-tools-popup__category">
<div class="sw-tools-popup__cat-header">
<button class="sw-tools-popup__cat-toggle"
onClick=${() => _toggleCat(cat.name)}
aria-expanded=${isExpanded}>
<span class=${'sw-tools-popup__chevron' + (isExpanded ? ' sw-tools-popup__chevron--open' : '')}>
\u25B6
</span>
<span>${cat.name}</span>
<span class="sw-tools-popup__cat-count">${cat.items.length}</span>
</button>
<label class="sw-tools-popup__cat-switch">
<input type="checkbox"
checked=${!allDisabled}
indeterminate=${someDisabled && !allDisabled}
onChange=${() => onToggleCategory(cat.name)} />
</label>
</div>
${isExpanded && cat.items.map(tool => html`
<label key=${tool.name} class="sw-tools-popup__tool">
<input type="checkbox"
checked=${!disabled.has(tool.name)}
onChange=${() => onToggle(tool.name)} />
<span class="sw-tools-popup__tool-name">${tool.name}</span>
${tool.description && html`
<span class="sw-tools-popup__tool-desc">${tool.description}</span>`}
</label>`)}
</div>`;
})}
</div>
</div>`;
}
function _loadDisabled() {
try {
const arr = JSON.parse(localStorage.getItem(STORAGE_KEY));
return new Set(arr || []);
} catch (_) { return new Set(); }
}
function _saveDisabled(set) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...set])); } catch (_) {}
}

View File

@@ -0,0 +1,175 @@
// ==========================================
// Chat Surface — useSidebar Hook
// ==========================================
// Loads and manages sidebar data: channels (DMs + named),
// personal chats, and folders. Subscribes to WS events
// for live updates.
const { useState, useEffect, useCallback, useMemo } = window.hooks;
const COLLAPSE_KEY = 'sw-chat-sidebar-collapsed';
/**
* @param {{ activeId: string|null }} opts
*/
export function useSidebar(opts = {}) {
const { activeId } = opts;
const [channels, setChannels] = useState([]); // named channels + DMs
const [chats, setChats] = useState([]); // personal AI chats (direct, no participants)
const [folders, setFolders] = useState([]);
const [search, setSearch] = useState('');
const [collapsed, setCollapsed] = useState(_loadCollapsed);
const [loading, setLoading] = useState(true);
// ── Load data ───────────────────────────
const reload = useCallback(async () => {
if (!window.sw?.api?.channels?.list) return;
try {
const foldersPromise = window.sw.api.folders?.list
? window.sw.api.folders.list().catch(() => [])
: Promise.resolve([]);
const [chResp, dmResp, fResp] = await Promise.all([
// Named channels (group chats, DMs with participants)
window.sw.api.channels.list({ page: 1, per_page: 100, channel_type: 'channel' }).catch(() => []),
// Personal AI chats
window.sw.api.channels.list({ page: 1, per_page: 100, channel_type: 'direct' }).catch(() => []),
// Folders
foldersPromise,
]);
setChannels(_extract(chResp));
setChats(_extract(dmResp));
setFolders(_extract(fResp));
} catch (_) {}
setLoading(false);
}, []);
useEffect(() => { reload(); }, [reload]);
// ── WebSocket live updates ──────────────
useEffect(() => {
if (!window.sw?.on) return;
const handlers = {
'channel.created': reload,
'channel.updated': reload,
'channel.deleted': reload,
'folder.created': reload,
'folder.updated': reload,
'folder.deleted': reload,
};
Object.entries(handlers).forEach(([ev, fn]) => window.sw.on(ev, fn));
return () => {
Object.entries(handlers).forEach(([ev, fn]) => window.sw.off?.(ev, fn));
};
}, [reload]);
// ── Search filter ───────────────────────
const filteredChannels = useMemo(() => {
if (!search) return channels;
const q = search.toLowerCase();
return channels.filter(c => (c.title || '').toLowerCase().includes(q));
}, [channels, search]);
const filteredChats = useMemo(() => {
if (!search) return chats;
const q = search.toLowerCase();
return chats.filter(c => (c.title || '').toLowerCase().includes(q));
}, [chats, search]);
// Group chats by folder
const chatsByFolder = useMemo(() => {
const map = { '': [] };
for (const f of (folders || [])) map[f.id] = [];
for (const c of filteredChats) {
const fid = c.folder_id || '';
if (!map[fid]) map[fid] = [];
map[fid].push(c);
}
return map;
}, [filteredChats, folders]);
// ── Collapse sections ───────────────────
const toggleCollapsed = useCallback((key) => {
setCollapsed(prev => {
const next = { ...prev, [key]: !prev[key] };
try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next)); } catch (_) {}
return next;
});
}, []);
// ── CRUD helpers ────────────────────────
const createFolder = useCallback(async (name) => {
if (!window.sw?.api?.folders?.create) return;
await window.sw.api.folders.create(name);
reload();
}, [reload]);
const renameChat = useCallback(async (id, title) => {
await window.sw.api.channels.update(id, { title });
reload();
}, [reload]);
const deleteChat = useCallback(async (id) => {
await window.sw.api.channels.del(id);
reload();
}, [reload]);
const moveToFolder = useCallback(async (chatId, folderId) => {
await window.sw.api.channels.update(chatId, { folder_id: folderId || null });
reload();
}, [reload]);
const renameFolder = useCallback(async (id, name) => {
await window.sw.api.folders.update(id, { name });
reload();
}, [reload]);
const deleteFolder = useCallback(async (id) => {
await window.sw.api.folders.del(id);
reload();
}, [reload]);
return {
channels: filteredChannels,
chats: filteredChats,
chatsByFolder,
folders,
search,
setSearch,
collapsed,
toggleCollapsed,
loading,
reload,
// CRUD
createFolder,
renameChat,
deleteChat,
moveToFolder,
renameFolder,
deleteFolder,
};
}
function _extract(resp) {
if (!resp) return [];
if (Array.isArray(resp)) return resp;
if (Array.isArray(resp.data)) return resp.data;
// Handle wrapped responses like { folders: [], channels: [] }
const vals = Object.values(resp);
for (const v of vals) {
if (Array.isArray(v)) return v;
}
return [];
}
function _loadCollapsed() {
try {
return JSON.parse(localStorage.getItem(COLLAPSE_KEY)) || {};
} catch (_) { return {}; }
}

View File

@@ -0,0 +1,78 @@
// ==========================================
// Chat Surface — useWorkspace Hook
// ==========================================
// Coordinator hook: manages active conversation, sidebar state.
// Persists active selection to sessionStorage for reload restore.
const { useState, useCallback, useEffect } = window.hooks;
const STORAGE_KEY = 'sw-chat-active';
/**
* @returns {{
* activeId: string|null,
* activeType: 'chat'|'channel'|null,
* sidebarOpen: boolean,
* setSidebarOpen: (v: boolean) => void,
* selectChat: (id: string) => void,
* selectChannel: (id: string) => void,
* clearSelection: () => void,
* }}
*/
export function useWorkspace() {
// Restore from sessionStorage
const stored = _loadStored();
const [activeId, setActiveId] = useState(stored.id);
const [activeType, setActiveType] = useState(stored.type);
const [sidebarOpen, setSidebarOpen] = useState(true);
// Persist on change
useEffect(() => {
if (activeId) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ id: activeId, type: activeType }));
} catch (_) {}
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
}, [activeId, activeType]);
const selectChat = useCallback((id) => {
setActiveId(id);
setActiveType('chat');
// Auto-close sidebar on mobile
if (window.innerWidth < 768) setSidebarOpen(false);
}, []);
const selectChannel = useCallback((id) => {
setActiveId(id);
setActiveType('channel');
if (window.innerWidth < 768) setSidebarOpen(false);
}, []);
const clearSelection = useCallback(() => {
setActiveId(null);
setActiveType(null);
}, []);
return {
activeId,
activeType,
sidebarOpen,
setSidebarOpen,
selectChat,
selectChannel,
clearSelection,
};
}
function _loadStored() {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) {
const obj = JSON.parse(raw);
return { id: obj.id || null, type: obj.type || null };
}
} catch (_) {}
return { id: null, type: null };
}

View File

@@ -1,167 +0,0 @@
// ==========================================
// Chat Switchboard Token Estimation
// ==========================================
// Context tracking, token estimation, and context warning.
//
// Exports: window.Tokens,window.updateInputTokens,window.updateContextWarning
// ── Token Estimation + Context Tracking ─────
const Tokens = {
// Rough heuristic: ~4 chars per token for English (GPT/Claude average).
// Not exact, but good enough for a UI indicator.
estimate(text) {
if (!text) return 0;
return Math.ceil(text.length / 4);
},
// Estimate tokens for the full conversation context sent to the model
estimateConversation(messages, systemPrompt) {
let total = 0;
// System prompt
if (systemPrompt) total += this.estimate(systemPrompt) + 4; // +4 for role/delimiters
// Messages
for (const m of messages) {
total += this.estimate(m.content) + 4; // +4 per message overhead (role, delimiters)
}
return total;
},
// Estimate tokens for a set of files (staged or sent).
// Images: ~765 tokens (base tile estimate, reasonable cross-provider).
// Documents with extracted text: text length / 4.
// Other/unknown: raw size / 4 (rough fallback).
estimateFiles(files) {
if (!files?.length) return 0;
let total = 0;
for (const att of files) {
if (att.content_type?.startsWith('image/')) {
total += 765; // base tile estimate
} else if (att.extracted_text) {
total += this.estimate(att.extracted_text);
} else if (att.size_bytes) {
total += Math.ceil(att.size_bytes / 4);
}
}
return total;
},
// Get context budget for current model
getContextBudget() {
const caps = UI.getSelectedModelCaps();
return {
maxContext: caps.max_context || 0,
maxOutput: caps.max_output_tokens || 0,
};
},
// Format token count for display
format(n) {
if (n >= 100000) return (n / 1000).toFixed(0) + 'K';
if (n >= 10000) return (n / 1000).toFixed(1) + 'K';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return String(n);
},
_warningDismissed: false,
};
// Update the token counter below the input
function updateInputTokens() {
const el = document.getElementById('inputTokenCount');
if (!el) return;
const inputText = (typeof ChatInput !== 'undefined') ? ChatInput.getValue() : '';
const inputTokens = Tokens.estimate(inputText);
if (!inputText.trim()) {
el.textContent = '';
el.className = 'input-token-count';
return;
}
const budget = Tokens.getContextBudget();
if (budget.maxContext > 0) {
// Show relative to available context
const chat = App.chats.find(c => c.id === App.activeId);
const convTokens = Tokens.estimateConversation(chat?.messages || [], App.settings.systemPrompt);
// Include staged file estimates
const fileTokens = Tokens.estimateFiles(
(App.stagedFiles || []).filter(a => a.status !== 'error').map(a => ({
content_type: a.contentType,
size_bytes: a.sizeBytes,
}))
);
const totalWithInput = convTokens + inputTokens + fileTokens;
const pct = totalWithInput / budget.maxContext;
const attLabel = fileTokens > 0 ? ` +${Tokens.format(fileTokens)} files` : '';
el.textContent = `~${Tokens.format(inputTokens)} tokens${attLabel} · ${Tokens.format(totalWithInput)} / ${Tokens.format(budget.maxContext)} context`;
el.className = 'input-token-count' + (pct > 0.9 ? ' danger' : pct > 0.75 ? ' warning' : '');
} else {
el.textContent = `~${Tokens.format(inputTokens)} tokens`;
el.className = 'input-token-count';
}
}
// Check conversation length and show/hide warning
function updateContextWarning() {
const warning = document.getElementById('contextWarning');
const text = document.getElementById('contextWarningText');
const summarizeBtn = document.getElementById('summarizeBtn');
if (!warning || !text) return;
const budget = Tokens.getContextBudget();
if (budget.maxContext <= 0) {
warning.style.display = 'none';
return;
}
const chat = App.chats.find(c => c.id === App.activeId);
if (!chat || !chat.messages?.length) {
warning.style.display = 'none';
return;
}
const convTokens = Tokens.estimateConversation(chat.messages, App.settings.systemPrompt);
const pct = convTokens / budget.maxContext;
// Show summarize button at ≥75% context usage (if enough messages)
const showSummarize = pct >= 0.75 && chat.messages.length >= 4;
if (summarizeBtn) {
summarizeBtn.style.display = showSummarize ? 'inline-block' : 'none';
}
// Show auto-compact toggle next to summarize button
const autoToggle = document.getElementById('autoCompactToggle');
const autoCheck = document.getElementById('autoCompactCheck');
if (autoToggle && autoCheck) {
autoToggle.style.display = showSummarize ? 'inline-flex' : 'none';
// Reflect current channel setting
const chatSettings = chat?.settings || {};
autoCheck.checked = chatSettings.auto_compaction !== false; // default: on
}
if (pct >= 0.9 && !Tokens._warningDismissed) {
warning.style.display = 'flex';
warning.className = 'context-warning danger';
text.textContent = `This conversation is using ~${Math.round(pct * 100)}% of the model's context window (~${Tokens.format(convTokens)} / ${Tokens.format(budget.maxContext)} tokens). Responses may lose earlier context.`;
} else if (pct >= 0.75 && !Tokens._warningDismissed) {
warning.style.display = 'flex';
warning.className = 'context-warning';
text.textContent = `Conversation is getting long (~${Math.round(pct * 100)}% of context window). The model may start losing track of earlier messages.`;
} else {
warning.style.display = 'none';
}
}
function dismissContextWarning() {
Tokens._warningDismissed = true;
const el = document.getElementById('contextWarning');
if (el) el.style.display = 'none';
}
// ── Exports ─────────────────────────────────
sb.ns('Tokens', Tokens);
sb.register('updateInputTokens', updateInputTokens);
sb.register('updateContextWarning', updateContextWarning);

View File

@@ -1,245 +0,0 @@
// ── tools-toggle.js ─────────────────────────
// Tools toggle popup: category-based enable/disable of server-side tools.
// Categories expand to show individual tool toggles.
// State persisted in localStorage, sent as disabled_tools[] in completion requests.
const ToolsToggle = (() => {
const STORAGE_KEY = 'cs-disabled-tools';
// Category display config
const CATEGORY_META = {
search: { label: 'Web Search', icon: '🔍' },
knowledge: { label: 'Knowledge Bases', icon: '📚' },
notes: { label: 'Notes', icon: '📝' },
utilities: { label: 'Utilities', icon: '🧮' },
workspace: { label: 'Workspace', icon: '🗂️' },
context: { label: 'Context', icon: '📎' },
memory: { label: 'Memory', icon: '🧠' },
browser: { label: 'Extensions', icon: '🧩' },
};
// Cached tool manifest from GET /api/v1/tools
let _tools = []; // [{name, display_name, description, category}]
let _categories = {}; // {category: [{name, displayName}]}
let _disabled = new Set();
let _expanded = new Set(); // which categories are expanded
// ── Public API ──────────────────────────
/** Initialize: load disabled set from storage, fetch tool manifest. */
async function init() {
_loadDisabled();
try {
const resp = await API.getTools();
_tools = resp.tools || [];
_buildCategories();
_render();
_show();
} catch (e) {
console.warn('Tools toggle: failed to load tools', e);
}
_wireEvents();
}
/** Returns array of disabled tool names for the completion request. */
function getDisabledTools() {
return [..._disabled];
}
/** Refresh tool list (e.g. after browser extension connects). */
async function refresh() {
try {
const resp = await API.getTools();
_tools = resp.tools || [];
_buildCategories();
_render();
} catch (e) { /* silent */ }
}
// ── Internal ────────────────────────────
function _loadDisabled() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
_disabled = raw ? new Set(JSON.parse(raw)) : new Set();
} catch { _disabled = new Set(); }
}
function _saveDisabled() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([..._disabled]));
_updateBtnState();
}
function _buildCategories() {
_categories = {};
for (const t of _tools) {
const cat = t.category || 'other';
if (!_categories[cat]) _categories[cat] = [];
_categories[cat].push({
name: t.name,
displayName: t.display_name || t.name,
});
}
}
/** Returns 'all' | 'none' | 'partial' for a category's enabled state. */
function _categoryState(cat) {
const tools = _categories[cat] || [];
if (tools.length === 0) return 'none';
const enabledCount = tools.filter(t => !_disabled.has(t.name)).length;
if (enabledCount === tools.length) return 'all';
if (enabledCount === 0) return 'none';
return 'partial';
}
function _toggleCategory(cat) {
const tools = _categories[cat] || [];
const state = _categoryState(cat);
if (state === 'all') {
// All on → all off
tools.forEach(t => _disabled.add(t.name));
} else {
// Partial or none → all on
tools.forEach(t => _disabled.delete(t.name));
}
_saveDisabled();
_render();
}
function _toggleTool(name) {
if (_disabled.has(name)) {
_disabled.delete(name);
} else {
_disabled.add(name);
}
_saveDisabled();
_render();
}
function _toggleExpand(cat) {
if (_expanded.has(cat)) {
_expanded.delete(cat);
} else {
_expanded.add(cat);
}
_render();
}
function _show() {
const wrap = document.querySelector('.tools-toggle-wrap');
if (wrap && _tools.length > 0) {
wrap.style.display = '';
_updateBtnState();
}
}
function _updateBtnState() {
const btn = document.getElementById('toolsToggleBtn');
if (!btn) return;
btn.classList.toggle('has-disabled', _disabled.size > 0);
const count = _disabled.size;
btn.title = count > 0 ? `Tools (${count} disabled)` : 'Tools';
}
function _render() {
const popup = document.getElementById('toolsPopup');
if (!popup) return;
const cats = Object.keys(_categories);
const order = Object.keys(CATEGORY_META);
cats.sort((a, b) => {
const ai = order.indexOf(a), bi = order.indexOf(b);
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
});
let html = '';
for (const cat of cats) {
const meta = CATEGORY_META[cat] || { label: cat, icon: '🔧' };
const state = _categoryState(cat);
const tools = _categories[cat];
const expanded = _expanded.has(cat);
const switchClass = state === 'all' ? 'on' : state === 'partial' ? 'on partial' : '';
const chevron = tools.length > 1
? `<span class="tools-popup-expand ${expanded ? 'open' : ''}" data-expand="${cat}"></span>`
: '';
const labelExpand = tools.length > 1 ? ` data-expand="${cat}"` : '';
html += `<div class="tools-popup-category" data-category="${cat}">
<span class="tools-popup-switch ${switchClass}" data-switch="${cat}"><span class="tools-popup-switch-knob"></span></span>
<span class="tools-popup-label"${labelExpand}>${meta.icon} ${meta.label}</span>
${chevron}
</div>`;
// Expanded children
if (expanded && tools.length > 1) {
for (const t of tools) {
const on = !_disabled.has(t.name);
html += `<div class="tools-popup-tool" data-tool="${t.name}">
<span class="tools-popup-switch ${on ? 'on' : ''}" data-tool-switch="${t.name}"><span class="tools-popup-switch-knob"></span></span>
<span class="tools-popup-label">${t.displayName}</span>
</div>`;
}
}
}
popup.innerHTML = html;
}
function _wireEvents() {
const btn = document.getElementById('toolsToggleBtn');
const popup = document.getElementById('toolsPopup');
if (!btn || !popup) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
popup.classList.toggle('open');
});
popup.addEventListener('click', (e) => {
e.stopPropagation();
// Toggle switch on category row
const catSwitch = e.target.closest('[data-switch]');
if (catSwitch) {
_toggleCategory(catSwitch.dataset.switch);
return;
}
// Toggle switch on individual tool row
const toolSwitch = e.target.closest('[data-tool-switch]');
if (toolSwitch) {
_toggleTool(toolSwitch.dataset.toolSwitch);
return;
}
// Expand/collapse: chevron or label click on multi-tool categories
const expander = e.target.closest('[data-expand]');
if (expander) {
_toggleExpand(expander.dataset.expand);
return;
}
// Individual tool label (no expand, just toggle)
const toolRow = e.target.closest('.tools-popup-tool');
if (toolRow) {
_toggleTool(toolRow.dataset.tool);
return;
}
// Single-tool category label click (no chevron) — toggle
const catRow = e.target.closest('.tools-popup-category');
if (catRow) {
_toggleCategory(catRow.dataset.category);
return;
}
});
// Close on outside click
document.addEventListener('click', () => popup.classList.remove('open'));
}
return { init, getDisabledTools, refresh };
})();
sb.ns('ToolsToggle', ToolsToggle);

File diff suppressed because it is too large Load Diff