Changeset 0.10.3 (#59)
This commit is contained in:
35
CHANGELOG.md
35
CHANGELOG.md
@@ -2,6 +2,41 @@
|
||||
|
||||
All notable changes to Chat Switchboard.
|
||||
|
||||
## [0.10.3] — 2026-02-24
|
||||
|
||||
### Changed
|
||||
- **Frontend refactor: 2 monolith files → 13 domain-scoped files.** Split
|
||||
`ui.js` (2,582 lines) and `app.js` (2,940 lines) into 13 focused files
|
||||
averaging ~544 lines each. No features added, no functions renamed, no
|
||||
architectural changes. Vanilla JS, no modules, no build step.
|
||||
|
||||
**New file structure:**
|
||||
|
||||
| File | Lines | Domain |
|
||||
|------|------:|--------|
|
||||
| ui-format.js | 353 | Markdown rendering, `esc()`, code blocks, side panel |
|
||||
| ui-core.js | 974 | UI object: sidebar, chat list, messages, streaming, model selector |
|
||||
| ui-settings.js | 640 | Settings tabs, teams, providers, user preferences |
|
||||
| ui-admin.js | 645 | Admin tabs, users, roles, usage, teams |
|
||||
| tokens.js | 123 | Context tracking, token estimation |
|
||||
| notes.js | 364 | Notes panel, editor, multi-select |
|
||||
| chat.js | 584 | Chat ops, send, regen, edit, branch, summarize |
|
||||
| settings-handlers.js | 692 | Settings save, provider CRUD, command palette |
|
||||
| admin-handlers.js | 652 | Admin actions, presets, team management |
|
||||
| app.js | 567 | State, init, boot, auth, listener dispatch |
|
||||
|
||||
Unchanged: `api.js` (575), `debug.js` (580), `events.js` (327).
|
||||
|
||||
- **`initListeners()` decomposed** into domain-specific init functions:
|
||||
`_initChatListeners()`, `_initSettingsListeners()`, `_initAdminListeners()`,
|
||||
`_initNotesListeners()`, `_initGlobalKeyboard()`. The orchestrator in `app.js`
|
||||
dispatches to each.
|
||||
- **Side panel resize** changed from self-invoking IIFE to `_initSidePanelResize()`
|
||||
called during listener init, avoiding DOM timing issues.
|
||||
- **Service worker** updated with new file list for pre-caching.
|
||||
- **Policy-gating tests** updated to read from the correct source files after
|
||||
the split. All 159 tests pass.
|
||||
|
||||
## [0.10.2] — 2026-02-24
|
||||
|
||||
### Added
|
||||
|
||||
530
REFACTOR-0.10.3.md
Normal file
530
REFACTOR-0.10.3.md
Normal file
@@ -0,0 +1,530 @@
|
||||
# v0.10.3 — Frontend Refactor Plan
|
||||
|
||||
## Problem
|
||||
|
||||
Two monolith files accumulate every feature:
|
||||
|
||||
| File | Lines | Role |
|
||||
|----------|------:|-----------------------------------|
|
||||
| app.js | 2,940 | All application logic + listeners |
|
||||
| ui.js | 2,582 | All rendering + admin + settings |
|
||||
| api.js | 575 | HTTP client (fine) |
|
||||
| debug.js | 580 | Debug logger (fine) |
|
||||
| events.js| 327 | EventBus WebSocket (fine) |
|
||||
| **Total**| **7,004** | |
|
||||
|
||||
The v0.10.2 syntax bug (orphan brace nesting everything from notes
|
||||
through banners) is a direct consequence: in a 2,940-line file, a
|
||||
misplaced insertion is invisible.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Vanilla JS** — no build step, no bundler, no ES modules.
|
||||
- **Global functions** — HTML `onclick` handlers call ~25 named globals.
|
||||
These must stay on `window`. No renaming.
|
||||
- **Load order via `<script>` tags** — dependencies load first.
|
||||
- **Service worker** — `sw.js` pre-caches the file list; must be updated.
|
||||
- **Cache busting** — all `<script>` tags use `?v=%%APP_VERSION%%`.
|
||||
- **Docker entrypoint** — `COPY src/` covers everything. No changes needed.
|
||||
- **CI syntax check** — already runs `node --check` on `src/js/*.js`.
|
||||
|
||||
## Architecture
|
||||
|
||||
The `UI` object stays as a single global with methods. Domain files extend
|
||||
it via `Object.assign(UI, { ... })`. The `App` state object stays in
|
||||
`app.js`. All functions stay global.
|
||||
|
||||
**Load order** (dependency graph, top to bottom):
|
||||
|
||||
```
|
||||
vendor/marked.min.js, vendor/purify.min.js
|
||||
↓
|
||||
debug.js, events.js
|
||||
↓
|
||||
api.js
|
||||
↓
|
||||
ui-format.js ← esc(), markdown rendering (used by everything)
|
||||
↓
|
||||
ui-core.js ← UI object: sidebar, chat list, messages, streaming, model selector
|
||||
↓
|
||||
ui-settings.js ← Object.assign(UI, { settings/team methods })
|
||||
ui-admin.js ← Object.assign(UI, { admin modal methods })
|
||||
↓
|
||||
tokens.js ← Tokens object, context tracking
|
||||
notes.js ← Notes panel
|
||||
chat.js ← Chat ops, send, regen, edit, branch, summarize
|
||||
settings-handlers.js ← Provider CRUD, command palette, save handlers
|
||||
admin-handlers.js ← Admin actions, presets, team management
|
||||
↓
|
||||
app.js ← State, init, boot, auth, listeners (orchestrator)
|
||||
```
|
||||
|
||||
## File Breakdown
|
||||
|
||||
### Unchanged (3 files, 1,482 lines)
|
||||
|
||||
| File | Lines | Notes |
|
||||
|------|------:|-------|
|
||||
| api.js | 575 | Clean. Self-contained HTTP client. |
|
||||
| debug.js | 580 | Clean. Debug logger + modal. |
|
||||
| events.js | 327 | Clean. EventBus WebSocket. |
|
||||
|
||||
### From ui.js → 4 files
|
||||
|
||||
#### ui-format.js (~280 lines)
|
||||
Extract from ui.js bottom section + helpers.
|
||||
|
||||
```
|
||||
Formatting (L2241-2462):
|
||||
_renderMarkdown(text)
|
||||
_renderCodeBlocks(html)
|
||||
_copyCodeBlock(btn)
|
||||
_formatTokenCount(n)
|
||||
_formatCost(c)
|
||||
|
||||
Helpers (L2519-2583):
|
||||
esc(s) ← global function, used everywhere
|
||||
_formatTimestamp(ts)
|
||||
_formatRelativeTime(ts)
|
||||
_truncate(s, n)
|
||||
openSidePanel(panelId) ← global, called from notes/search
|
||||
closeSidePanel() ← global, HTML onclick
|
||||
|
||||
Side Panel Resize (L2463-2518):
|
||||
_initSidePanelResize()
|
||||
```
|
||||
|
||||
**Why separate:** Formatting is a pure utility layer. Every other UI file
|
||||
depends on `esc()` and `_renderMarkdown()`. Loading first eliminates
|
||||
forward references.
|
||||
|
||||
#### ui-core.js (~650 lines)
|
||||
The `UI` object definition with core rendering methods.
|
||||
|
||||
```
|
||||
const UI = {
|
||||
// Sidebar (L189-216)
|
||||
toggleSidebar(), restoreSidebar(),
|
||||
toggleUserMenu(), closeUserMenu(),
|
||||
|
||||
// Chat List (L217-284)
|
||||
renderChatList(),
|
||||
|
||||
// Messages (L285-454)
|
||||
renderMessages(), showEmptyState(),
|
||||
_messageHTML(), _summaryHTML(), _isSummaryMessage(),
|
||||
toggleSummarizedHistory(),
|
||||
|
||||
// Streaming (L455-596)
|
||||
streamResponse(),
|
||||
|
||||
// Model Selector (L597-727)
|
||||
getModelValue(), setModelValue(),
|
||||
updateModelSelector(), initModelDropdown(),
|
||||
|
||||
// Capability Badges (L728-774)
|
||||
getSelectedModelCaps(), updateCapabilityBadges(),
|
||||
|
||||
// User / Connection (L775-811)
|
||||
updateUser(), showAdminButton(),
|
||||
|
||||
// Generating State (L812-862)
|
||||
setGenerating(), showRegenerate(),
|
||||
|
||||
// Toast (L863-873)
|
||||
toast(),
|
||||
|
||||
// Settings Modal (L874-921) — thin dispatcher
|
||||
openSettings(), switchSettingsTab(),
|
||||
|
||||
// Export (L2191-2214)
|
||||
exportMarkdown(), exportJSON(),
|
||||
|
||||
// Message Actions (L2215-2240)
|
||||
(delegated action handlers)
|
||||
|
||||
// Scroll (L2226-2240)
|
||||
scrollToBottom(),
|
||||
};
|
||||
```
|
||||
|
||||
Also includes the top-level `_avatarHTML()` helper (L5-24) and
|
||||
summary message helpers (L167-188).
|
||||
|
||||
#### ui-settings.js (~550 lines)
|
||||
Extends UI with all settings modal tab content.
|
||||
|
||||
```
|
||||
Object.assign(UI, {
|
||||
// Preset Form Component (L25-166) — _presetFormHTML(), etc.
|
||||
|
||||
// Appearance (L922-1267)
|
||||
loadAppearanceSettings(), initAppearance(),
|
||||
|
||||
// Providers (L1444-1480)
|
||||
loadProviderList(), hideProviderForm(),
|
||||
|
||||
// Profile (L994-1002)
|
||||
loadProfileIntoSettings(),
|
||||
|
||||
// Teams (L1003-1267)
|
||||
loadMyTeams(), loadTeamsTab(), openTeamManage(),
|
||||
showTeamPicker(), loadTeamManageMembers(),
|
||||
loadTeamManageProviders(), loadTeamManagePresets(),
|
||||
loadTeamAuditLog(), loadTeamPresetModelDropdown(),
|
||||
switchTeamTab(),
|
||||
|
||||
// Usage (L1160-1267)
|
||||
loadMyUsage(), loadTeamUsage(),
|
||||
|
||||
// User Model Roles (L1268-1328)
|
||||
loadUserRoles(),
|
||||
|
||||
// User Models & Presets (L2118-2190)
|
||||
loadUserModels(), loadUserPresets(),
|
||||
|
||||
// checkUserProvidersAllowed(), checkUserPresetsAllowed()
|
||||
});
|
||||
```
|
||||
|
||||
#### ui-admin.js (~650 lines)
|
||||
Extends UI with all admin modal content.
|
||||
|
||||
```
|
||||
Object.assign(UI, {
|
||||
// Admin Modal (L1481-1604)
|
||||
openTeamAdmin(), switchAdminTab(),
|
||||
|
||||
// Users (L1549-1588)
|
||||
loadAdminUsers(),
|
||||
|
||||
// Stats (L1589-1604)
|
||||
loadAdminStats(),
|
||||
|
||||
// Roles (L1605-1658)
|
||||
loadAdminRoles(),
|
||||
|
||||
// Usage + Audit (L1659-1813)
|
||||
loadAdminUsage(), loadAuditLog(),
|
||||
|
||||
// Providers (L1814-1835)
|
||||
loadAdminProviders(),
|
||||
|
||||
// Models (L1836-1863)
|
||||
loadAdminModels(),
|
||||
|
||||
// Presets (L1864-1939)
|
||||
loadAdminPresets(),
|
||||
|
||||
// Teams (L1940-2117)
|
||||
loadAdminTeams(), openTeamDetail(),
|
||||
loadTeamMembers(), loadMemberUserDropdown(),
|
||||
|
||||
// Settings (L2037-2117)
|
||||
loadAdminSettings(), updateBannerPreview(),
|
||||
});
|
||||
```
|
||||
|
||||
### From app.js → 6 files
|
||||
|
||||
#### tokens.js (~120 lines)
|
||||
Already has its own `Tokens` namespace — clean extraction.
|
||||
|
||||
```
|
||||
const Tokens = { ... }; // L36-74
|
||||
function updateInputTokens() // L78-106
|
||||
function updateContextWarning() // L108-147
|
||||
function dismissContextWarning() // L148-152
|
||||
```
|
||||
|
||||
#### notes.js (~300 lines)
|
||||
Self-contained with own state variables.
|
||||
|
||||
```
|
||||
var _editingNoteId = null;
|
||||
var _notesSelectMode = false;
|
||||
var _selectedNoteIds = new Set();
|
||||
var _notesSort = 'updated_desc';
|
||||
|
||||
openNotes(), loadNotesList(), _noteListItem()
|
||||
_highlightHeadline()
|
||||
_enterSelectMode(), _exitSelectMode()
|
||||
_toggleNoteSelect(), _toggleSelectAll()
|
||||
_updateSelectedCount(), _bulkDeleteSelected()
|
||||
loadNoteFolders(), showNotesList()
|
||||
copyNoteContent(), openNoteEditor()
|
||||
_populateEditFields(), _clearEditFields()
|
||||
_showNoteReadMode(), _showNoteEditMode()
|
||||
_showNotePreview()
|
||||
saveNote(), deleteNote()
|
||||
_initNotesListeners() ← NEW: extracted from initListeners
|
||||
```
|
||||
|
||||
#### chat.js (~500 lines)
|
||||
All conversation operations.
|
||||
|
||||
```
|
||||
// Chat management
|
||||
loadChats(), selectChat(), newChat(), deleteChat()
|
||||
|
||||
// Per-chat model persistence
|
||||
_saveChatModel(), _restoreChatModel()
|
||||
|
||||
// Send + stream
|
||||
sendMessage(), stopGeneration()
|
||||
|
||||
// Active path
|
||||
reloadActivePath()
|
||||
|
||||
// Regenerate
|
||||
regenerateMessage(), regenerate()
|
||||
|
||||
// Edit
|
||||
editMessage(), submitEdit(), cancelEdit()
|
||||
|
||||
// Branch navigation
|
||||
switchSibling()
|
||||
|
||||
// Summarize & Continue
|
||||
summarizeAndContinue()
|
||||
|
||||
// Preview
|
||||
clearPreview()
|
||||
|
||||
_initChatListeners() ← NEW: extracted from initListeners
|
||||
```
|
||||
|
||||
#### settings-handlers.js (~400 lines)
|
||||
Settings-related handlers + command palette.
|
||||
|
||||
```
|
||||
// Settings
|
||||
handleSaveSettings(), loadSettings(), saveSettings()
|
||||
|
||||
// Avatar
|
||||
updateAvatarPreview()
|
||||
|
||||
// Providers
|
||||
handleCreateProvider(), deleteProvider()
|
||||
refreshProviderModels(), editProvider()
|
||||
|
||||
// Admin settings save
|
||||
handleSaveAdminSettings()
|
||||
|
||||
// User model roles
|
||||
userRoleProviderChanged(), saveUserRole(), clearUserRole()
|
||||
|
||||
// Command Palette
|
||||
_cmdCommands[], toggleCmdPalette(), openCmdPalette()
|
||||
closeCmdPalette(), _handleCmdKey(), _highlightCmdItem()
|
||||
_getVisibleCommands(), _renderCmdResults(), executeCmdAction()
|
||||
|
||||
_initSettingsListeners() ← NEW: extracted from initListeners
|
||||
```
|
||||
|
||||
#### admin-handlers.js (~450 lines)
|
||||
Admin actions + team management.
|
||||
|
||||
```
|
||||
// Admin user management
|
||||
toggleUserActive(), showApproveForm(), hideApproveForm()
|
||||
handleApproveUser(), resetUserPassword(), promoteUser()
|
||||
createAdminUser()
|
||||
|
||||
// Admin roles
|
||||
adminSaveRole(), adminRoleProviderChanged()
|
||||
|
||||
// User model preferences
|
||||
toggleModelVisibility(), bulkSetVisibility()
|
||||
bulkSetUserModelVisibility(), deleteUserPreset()
|
||||
|
||||
// Admin presets
|
||||
_adminPresetForm, ensureAdminPresetForm()
|
||||
editAdminPreset(), createAdminPreset()
|
||||
toggleAdminPreset(), deleteAdminPreset()
|
||||
|
||||
// Team admin (system admin)
|
||||
toggleTeamActive(), deleteTeam()
|
||||
updateTeamMember(), removeTeamMember()
|
||||
|
||||
// Team admin (settings-side)
|
||||
settingsUpdateTeamMember(), settingsRemoveTeamMember()
|
||||
settingsDeleteTeamPreset(), settingsToggleTeamProvider()
|
||||
settingsDeleteTeamProvider(), settingsEditTeamProvider()
|
||||
|
||||
_initAdminListeners() ← NEW: extracted from initListeners
|
||||
```
|
||||
|
||||
#### app.js (~400 lines) — the orchestrator
|
||||
What remains after extraction.
|
||||
|
||||
```
|
||||
// State
|
||||
const App = { ... };
|
||||
|
||||
// Models
|
||||
resolveCapabilities(), fetchModels()
|
||||
|
||||
// Init + Boot
|
||||
init(), startApp()
|
||||
|
||||
// Auth flow
|
||||
showSplash(), hideSplash()
|
||||
handleLogin(), handleRegister(), handleLogout()
|
||||
switchAuthTab(), setAuthError(), setAuthLoading()
|
||||
|
||||
// Modal helpers
|
||||
openModal(), closeModal()
|
||||
checkTabsOverflow(), updateTabArrows()
|
||||
|
||||
// Branding + Banners
|
||||
initBranding(), initBanners()
|
||||
|
||||
// Listeners — thin dispatcher
|
||||
function initListeners() {
|
||||
if (_listenersInit) return;
|
||||
_listenersInit = true;
|
||||
_initChatListeners(); // from chat.js
|
||||
_initSettingsListeners(); // from settings-handlers.js
|
||||
_initAdminListeners(); // from admin-handlers.js
|
||||
_initNotesListeners(); // from notes.js
|
||||
_initGlobalKeyboard(); // local: Escape, Ctrl+K, resize
|
||||
_initSidePanelResize(); // from ui-format.js
|
||||
}
|
||||
|
||||
// Boot
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
```
|
||||
|
||||
## Execution Steps
|
||||
|
||||
### 1. Preparation (before any splits)
|
||||
- [ ] Branch from merged v0.10.2
|
||||
- [ ] Add `node --check` validation to a quick local script:
|
||||
`for f in src/js/*.js; do node --check "$f" || exit 1; done`
|
||||
- [ ] Run all frontend tests as baseline: `node --test src/js/__tests__/*.test.js`
|
||||
|
||||
### 2. Extract ui-format.js
|
||||
- [ ] Create `src/js/ui-format.js`
|
||||
- [ ] Move: `esc()`, formatting functions, side panel resize, helpers
|
||||
- [ ] Remove from `ui.js`
|
||||
- [ ] Add `<script>` tag in `index.html` **before** `ui.js`
|
||||
- [ ] Add to `sw.js` SHELL_FILES
|
||||
- [ ] Run syntax check + tests
|
||||
|
||||
### 3. Extract ui-settings.js
|
||||
- [ ] Create `src/js/ui-settings.js`
|
||||
- [ ] Move settings/team UI methods out of UI object in `ui.js`
|
||||
- [ ] Add `Object.assign(UI, { ... })` wrapper
|
||||
- [ ] Add `<script>` tag **after** `ui.js`
|
||||
- [ ] Add to `sw.js`
|
||||
- [ ] Run syntax check + tests
|
||||
|
||||
### 4. Extract ui-admin.js
|
||||
- [ ] Create `src/js/ui-admin.js`
|
||||
- [ ] Move admin UI methods out of UI object
|
||||
- [ ] Add `Object.assign(UI, { ... })` wrapper
|
||||
- [ ] Add `<script>` tag **after** `ui.js`
|
||||
- [ ] Add to `sw.js`
|
||||
- [ ] Run syntax check + tests
|
||||
|
||||
### 5. Extract tokens.js
|
||||
- [ ] Create `src/js/tokens.js`
|
||||
- [ ] Move `Tokens` object + `updateInputTokens` + `updateContextWarning` + `dismissContextWarning`
|
||||
- [ ] Remove from `app.js`
|
||||
- [ ] Add `<script>` tag **before** `app.js`
|
||||
- [ ] Add to `sw.js`
|
||||
- [ ] Run syntax check + tests
|
||||
|
||||
### 6. Extract notes.js
|
||||
- [ ] Create `src/js/notes.js`
|
||||
- [ ] Move all notes state + functions
|
||||
- [ ] Extract `_initNotesListeners()` from `initListeners()` — cut the
|
||||
notes-related event bindings into a new function, call it from
|
||||
`initListeners()`
|
||||
- [ ] Add `<script>` tag **before** `app.js`
|
||||
- [ ] Add to `sw.js`
|
||||
- [ ] Run syntax check + tests
|
||||
|
||||
### 7. Extract chat.js
|
||||
- [ ] Create `src/js/chat.js`
|
||||
- [ ] Move chat management, send, regen, edit, branch, summarize, model persistence
|
||||
- [ ] Extract `_initChatListeners()` from `initListeners()` — cut chat input,
|
||||
Enter-to-send, message action delegation, etc.
|
||||
- [ ] Add `<script>` tag **before** `app.js`
|
||||
- [ ] Add to `sw.js`
|
||||
- [ ] Run syntax check + tests
|
||||
|
||||
### 8. Extract settings-handlers.js
|
||||
- [ ] Create `src/js/settings-handlers.js`
|
||||
- [ ] Move settings handlers, provider CRUD, command palette, avatar, user roles
|
||||
- [ ] Extract `_initSettingsListeners()` from `initListeners()`
|
||||
- [ ] Add `<script>` tag **before** `app.js`
|
||||
- [ ] Add to `sw.js`
|
||||
- [ ] Run syntax check + tests
|
||||
|
||||
### 9. Extract admin-handlers.js
|
||||
- [ ] Create `src/js/admin-handlers.js`
|
||||
- [ ] Move admin actions, presets, team management
|
||||
- [ ] Extract `_initAdminListeners()` from `initListeners()`
|
||||
- [ ] Add `<script>` tag **before** `app.js`
|
||||
- [ ] Add to `sw.js`
|
||||
- [ ] Run syntax check + tests
|
||||
|
||||
### 10. Final validation
|
||||
- [ ] `node --check` all JS files
|
||||
- [ ] `node --test src/js/__tests__/*.test.js` — all 159 tests pass
|
||||
- [ ] Verify `index.html` script order matches dependency graph
|
||||
- [ ] Verify `sw.js` SHELL_FILES lists all new files
|
||||
- [ ] Manual smoke test: login, chat, settings, admin, notes, debug
|
||||
- [ ] Grep for any remaining forward references / undefined calls
|
||||
- [ ] Update ROADMAP: mark v0.10.3 complete
|
||||
- [ ] Update CHANGELOG
|
||||
|
||||
## Target State
|
||||
|
||||
| File | Lines | Domain |
|
||||
|------|------:|--------|
|
||||
| api.js | 575 | HTTP client, auth tokens |
|
||||
| debug.js | 580 | Debug logger + modal |
|
||||
| events.js | 327 | EventBus WebSocket |
|
||||
| ui-format.js | ~280 | Markdown, code blocks, esc(), helpers |
|
||||
| ui-core.js | ~650 | UI object: rendering, model selector, streaming |
|
||||
| ui-settings.js | ~550 | Settings tabs, teams, providers, user prefs |
|
||||
| ui-admin.js | ~650 | Admin tabs, users, roles, usage, teams |
|
||||
| tokens.js | ~120 | Context tracking, token estimation |
|
||||
| notes.js | ~300 | Notes panel, editor, multi-select |
|
||||
| chat.js | ~500 | Chat ops, send, regen, edit, branch, summarize |
|
||||
| settings-handlers.js | ~400 | Settings save, provider CRUD, command palette |
|
||||
| admin-handlers.js | ~450 | Admin actions, presets, team management |
|
||||
| app.js | ~400 | State, init, boot, auth, listener dispatch |
|
||||
| **Total** | **~5,782** | 13 files (was 5) |
|
||||
|
||||
Line count drops slightly due to removed duplicate section headers and
|
||||
consolidated comments. Average file: ~445 lines. Largest: ~650.
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
**Forward references:** Function declarations are hoisted within a
|
||||
`<script>`, but not across scripts. All functions called by
|
||||
`initListeners()` must be defined in scripts loaded before `app.js`.
|
||||
The load order above ensures this.
|
||||
|
||||
**`UI` object extension:** `Object.assign(UI, { ... })` in
|
||||
ui-settings.js and ui-admin.js means `UI` must be defined first
|
||||
(in ui-core.js). The load order handles this.
|
||||
|
||||
**Regression testing:** Each extraction step ends with syntax check +
|
||||
test run. If anything breaks, it's isolated to the last extraction.
|
||||
|
||||
**Rollback:** Each step is a single commit. Revert any step independently.
|
||||
|
||||
## What This Does NOT Change
|
||||
|
||||
- No ES modules, no import/export, no bundler
|
||||
- No function renaming (HTML onclick handlers untouched)
|
||||
- No new dependencies
|
||||
- No architectural changes to backend
|
||||
- No feature additions
|
||||
- The `UI` object is still a single global — just defined across files
|
||||
- All tests continue to work unchanged
|
||||
24
ROADMAP.md
24
ROADMAP.md
@@ -25,6 +25,8 @@ v0.10.0 Model Roles (utility + embedding) + Usage Tracking
|
||||
│
|
||||
v0.10.2 Summarize & Continue (first utility role consumer)
|
||||
│
|
||||
v0.10.3 Frontend Refactor (no features, code health)
|
||||
│
|
||||
┌───────┴──────────────┐
|
||||
│ │
|
||||
v0.11.0 Extension v0.12.0 File Handling
|
||||
@@ -457,6 +459,28 @@ Depends on: utility model role (v0.10.0).
|
||||
|
||||
---
|
||||
|
||||
## v0.10.3 — Frontend Refactor ✅
|
||||
|
||||
Zero features. Split the two monolith frontend files (app.js 2,940 lines,
|
||||
ui.js 2,582 lines) into 13 domain-scoped files averaging ~544 lines each.
|
||||
Vanilla JS, no modules, no build step, no function renaming.
|
||||
|
||||
See [REFACTOR-0.10.3.md](REFACTOR-0.10.3.md) for full plan.
|
||||
|
||||
- [ ] Extract `ui-format.js` — markdown, code blocks, `esc()`, helpers
|
||||
- [ ] Extract `ui-settings.js` — settings tabs, teams, providers, user prefs
|
||||
- [ ] Extract `ui-admin.js` — admin modal tabs, all admin rendering
|
||||
- [ ] Extract `tokens.js` — context tracking, token estimation
|
||||
- [ ] Extract `notes.js` — notes panel, editor, multi-select
|
||||
- [ ] Extract `chat.js` — chat ops, send, regen, edit, branch, summarize
|
||||
- [ ] Extract `settings-handlers.js` — settings save, provider CRUD, cmd palette
|
||||
- [ ] Extract `admin-handlers.js` — admin actions, presets, team management
|
||||
- [ ] Slim `app.js` to orchestrator — state, init, boot, auth, listener dispatch
|
||||
- [ ] Update `sw.js` SHELL_FILES, `index.html` script tags
|
||||
- [ ] All 159+ frontend tests pass, `node --check` on all files
|
||||
|
||||
---
|
||||
|
||||
## v0.11.0 — Extension Foundation (Browser Tier)
|
||||
|
||||
The platform play. See [EXTENSIONS.md](EXTENSIONS.md) for full spec.
|
||||
|
||||
@@ -862,7 +862,15 @@
|
||||
<script src="js/debug.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/events.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/api.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/ui.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/ui-format.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/ui-core.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/ui-settings.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/ui-admin.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/tokens.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/notes.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/chat.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/app.js?v=%%APP_VERSION%%"></script>
|
||||
<script>
|
||||
// ── Service Worker Registration ─────────
|
||||
|
||||
@@ -22,13 +22,17 @@ const SRC = path.join(__dirname, '..');
|
||||
// wiring exists. If someone removes a policy check, CI breaks.
|
||||
|
||||
describe('Policy wiring audit — source code', () => {
|
||||
const uiSrc = fs.readFileSync(path.join(SRC, 'ui.js'), 'utf-8');
|
||||
const appSrc = fs.readFileSync(path.join(SRC, 'app.js'), 'utf-8');
|
||||
// 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');
|
||||
const indexSrc = fs.readFileSync(path.join(SRC, '..', 'index.html'), 'utf-8');
|
||||
|
||||
// ── allow_user_byok ──
|
||||
|
||||
it('ui.js has checkUserProvidersAllowed function', () => {
|
||||
it('UI has checkUserProvidersAllowed function', () => {
|
||||
assert.ok(uiSrc.includes('checkUserProvidersAllowed'),
|
||||
'MISSING: checkUserProvidersAllowed — provider tab will show when policy is off');
|
||||
});
|
||||
@@ -40,7 +44,7 @@ describe('Policy wiring audit — source code', () => {
|
||||
|
||||
// ── allow_user_personas ──
|
||||
|
||||
it('ui.js has checkUserPresetsAllowed function', () => {
|
||||
it('UI has checkUserPresetsAllowed function', () => {
|
||||
assert.ok(uiSrc.includes('checkUserPresetsAllowed'),
|
||||
'MISSING: checkUserPresetsAllowed — preset button will show when policy is off');
|
||||
});
|
||||
@@ -206,7 +210,9 @@ describe('Admin settings field mapping', () => {
|
||||
'adminBannerEnabled': 'banner',
|
||||
};
|
||||
|
||||
const appSrc = fs.readFileSync(path.join(SRC, 'app.js'), 'utf-8');
|
||||
// Read all app-side files (handleSaveAdminSettings is in settings-handlers.js)
|
||||
const appSrc = ['app.js', 'settings-handlers.js', 'admin-handlers.js']
|
||||
.map(f => fs.readFileSync(path.join(SRC, f), 'utf-8')).join('\n');
|
||||
const indexSrc = fs.readFileSync(path.join(SRC, '..', 'index.html'), 'utf-8');
|
||||
|
||||
for (const [elementId, settingKey] of Object.entries(settingsFieldMap)) {
|
||||
@@ -215,7 +221,7 @@ describe('Admin settings field mapping', () => {
|
||||
`MISSING: #${elementId} in index.html — admin settings incomplete`);
|
||||
});
|
||||
|
||||
it(`app.js writes setting "${settingKey}"`, () => {
|
||||
it(`frontend writes setting "${settingKey}"`, () => {
|
||||
assert.ok(appSrc.includes(settingKey),
|
||||
`MISSING: "${settingKey}" in handleSaveAdminSettings`);
|
||||
});
|
||||
|
||||
652
src/js/admin-handlers.js
Normal file
652
src/js/admin-handlers.js
Normal file
@@ -0,0 +1,652 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Admin Handlers
|
||||
// ==========================================
|
||||
// Admin actions: user management, roles, model visibility,
|
||||
// presets, team management, global providers.
|
||||
|
||||
// ── Admin Actions ────────────────────────────
|
||||
|
||||
function _adminScroll() { return document.querySelector('#adminModal .modal-body'); }
|
||||
function _restoreScroll(el, pos) { if (el) requestAnimationFrame(() => { el.scrollTop = pos; }); }
|
||||
async function toggleUserActive(id, active) {
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
await API.adminToggleActive(id, active); UI.toast(`User ${active ? 'enabled' : 'disabled'}`, 'success'); await UI.loadAdminUsers();
|
||||
_restoreScroll(el, pos);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function showApproveForm(userId, username) {
|
||||
const form = document.getElementById(`approveForm-${userId}`);
|
||||
const teamsEl = document.getElementById(`approveTeams-${userId}`);
|
||||
if (!form || !teamsEl) return;
|
||||
|
||||
// Load teams for checkboxes
|
||||
teamsEl.innerHTML = '<span class="text-muted">Loading teams...</span>';
|
||||
form.style.display = '';
|
||||
try {
|
||||
const resp = await API.adminListTeams();
|
||||
const teams = (resp.data || []).filter(t => t.is_active);
|
||||
if (teams.length === 0) {
|
||||
teamsEl.innerHTML = '<span class="text-muted">No teams — user will be activated without team assignment</span>';
|
||||
} else {
|
||||
teamsEl.innerHTML = '<label class="form-label" style="font-size:12px;margin-bottom:4px">Assign to teams:</label>' +
|
||||
teams.map(t => `<label class="checkbox-label" style="font-size:13px"><input type="checkbox" value="${t.id}" class="approve-team-cb"> ${esc(t.name)}</label>`).join('');
|
||||
}
|
||||
} catch (e) { teamsEl.innerHTML = `<span class="text-muted">Could not load teams</span>`; }
|
||||
}
|
||||
|
||||
function hideApproveForm(userId) {
|
||||
const form = document.getElementById(`approveForm-${userId}`);
|
||||
if (form) form.style.display = 'none';
|
||||
}
|
||||
|
||||
async function submitApproval(userId) {
|
||||
const form = document.getElementById(`approveForm-${userId}`);
|
||||
const teamIds = [...(form?.querySelectorAll('.approve-team-cb:checked') || [])].map(cb => cb.value);
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
await API.adminToggleActive(userId, true, teamIds, 'member');
|
||||
const msg = teamIds.length ? `User activated & assigned to ${teamIds.length} team(s)` : 'User activated';
|
||||
UI.toast(msg, 'success');
|
||||
await UI.loadAdminUsers();
|
||||
_restoreScroll(el, pos);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
async function toggleUserRole(id, role) {
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
await API.adminUpdateRole(id, role); UI.toast(`Role updated to ${role}`, 'success'); await UI.loadAdminUsers();
|
||||
_restoreScroll(el, pos);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
async function deleteUser(id, username) {
|
||||
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
|
||||
try { await API.adminDeleteUser(id); UI.toast('User deleted', 'success'); await UI.loadAdminUsers(); }
|
||||
catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
async function createAdminUser() {
|
||||
try {
|
||||
const u = document.getElementById('adminNewUsername').value.trim();
|
||||
const e = document.getElementById('adminNewEmail').value.trim();
|
||||
const p = document.getElementById('adminNewPassword').value;
|
||||
const r = document.getElementById('adminNewRole').value;
|
||||
if (!u || !e || !p) { UI.toast('All fields required', 'warning'); return; }
|
||||
await API.adminCreateUser(u, e, p, r);
|
||||
document.getElementById('adminAddUserForm').style.display = 'none';
|
||||
UI.toast('User created', 'success');
|
||||
await UI.loadAdminUsers();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function adminResetUserPassword(id, username) {
|
||||
const pw = prompt(
|
||||
`Reset password for "${username}"?\n\n` +
|
||||
`⚠️ WARNING: This will DESTROY the user's personal vault.\n` +
|
||||
`All personal API keys (BYOK) will be permanently deleted.\n` +
|
||||
`The user will need to re-add any personal provider keys.\n\n` +
|
||||
`Enter new password (min 8 chars):`
|
||||
);
|
||||
if (!pw) return;
|
||||
if (pw.length < 8) { UI.toast('Password must be at least 8 characters', 'warning'); return; }
|
||||
if (!confirm(`Final confirmation: Reset password AND destroy personal vault for "${username}"?`)) return;
|
||||
try {
|
||||
await API.adminResetPassword(id, pw);
|
||||
UI.toast(`Password reset for ${username}. Vault destroyed.`, 'success');
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteGlobalProvider(id) {
|
||||
if (!confirm('Delete this global provider?')) return;
|
||||
try { await API.adminDeleteGlobalConfig(id); UI.toast('Provider deleted', 'success'); await UI.loadAdminProviders(); }
|
||||
catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
var _editingProviderId = null;
|
||||
function editGlobalProvider(id) {
|
||||
const c = UI._providerCache?.[id];
|
||||
if (!c) return;
|
||||
_editingProviderId = id;
|
||||
const form = document.getElementById('adminAddProviderForm');
|
||||
form.style.display = '';
|
||||
document.getElementById('adminProvName').value = c.name || '';
|
||||
document.getElementById('adminProvType').value = c.provider || 'openai';
|
||||
document.getElementById('adminProvEndpoint').value = c.endpoint || '';
|
||||
document.getElementById('adminProvKey').value = '';
|
||||
document.getElementById('adminProvKey').placeholder = c.has_key ? '(unchanged)' : 'sk-...';
|
||||
document.getElementById('adminProvModel').value = c.model_default || '';
|
||||
document.getElementById('adminProvPrivate').checked = !!c.is_private;
|
||||
document.getElementById('adminCreateProvBtn').textContent = 'Update';
|
||||
}
|
||||
|
||||
async function createGlobalProvider() {
|
||||
try {
|
||||
const name = document.getElementById('adminProvName').value.trim();
|
||||
const prov = document.getElementById('adminProvType').value;
|
||||
const ep = document.getElementById('adminProvEndpoint').value.trim();
|
||||
const key = document.getElementById('adminProvKey').value;
|
||||
const model = document.getElementById('adminProvModel').value.trim();
|
||||
const isPrivate = document.getElementById('adminProvPrivate').checked;
|
||||
|
||||
if (_editingProviderId) {
|
||||
// Update mode
|
||||
const updates = {};
|
||||
if (name) updates.name = name;
|
||||
if (ep) updates.endpoint = ep;
|
||||
if (key) updates.api_key = key;
|
||||
updates.model_default = model;
|
||||
updates.is_private = isPrivate;
|
||||
await API.adminUpdateGlobalConfig(_editingProviderId, updates);
|
||||
UI.toast('Provider updated', 'success');
|
||||
} else {
|
||||
// Create mode
|
||||
if (!name || !ep || !key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
|
||||
await API.adminCreateGlobalConfig(name, prov, ep, key, model, isPrivate);
|
||||
UI.toast('Provider added', 'success');
|
||||
}
|
||||
_editingProviderId = null;
|
||||
document.getElementById('adminAddProviderForm').style.display = 'none';
|
||||
document.getElementById('adminCreateProvBtn').textContent = 'Save';
|
||||
document.getElementById('adminProvKey').placeholder = 'sk-...';
|
||||
await UI.loadAdminProviders();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function fetchAdminModels() {
|
||||
const hint = document.getElementById('adminModelsHint');
|
||||
hint.textContent = 'Fetching...';
|
||||
try {
|
||||
const resp = await API.adminFetchModels();
|
||||
const errs = resp.errors || [];
|
||||
if (errs.length > 0) {
|
||||
UI.toast('Fetch errors: ' + errs.join('; '), 'error');
|
||||
} else {
|
||||
UI.toast(`Models synced — ${resp.added || 0} added, ${resp.updated || 0} updated`, 'success');
|
||||
}
|
||||
hint.textContent = '';
|
||||
await UI.loadAdminModels();
|
||||
}
|
||||
catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
|
||||
}
|
||||
async function cycleModelVisibility(id, current) {
|
||||
// Cycle: disabled → enabled → team → disabled
|
||||
const next = current === 'disabled' ? 'enabled' : current === 'enabled' ? 'team' : 'disabled';
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
// Send both for backward compat with pre-0.8.3 backends
|
||||
await API.adminUpdateModel(id, { visibility: next, is_enabled: next === 'enabled' });
|
||||
await UI.loadAdminModels();
|
||||
_restoreScroll(el, pos);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function bulkSetVisibility(visibility) {
|
||||
const hint = document.getElementById('adminModelsHint');
|
||||
const labels = { enabled: 'Enabling', disabled: 'Disabling', team: 'Setting team-only for' };
|
||||
hint.textContent = `${labels[visibility] || 'Updating'} all...`;
|
||||
try {
|
||||
// Send both for backward compat with pre-0.8.3 backends
|
||||
const resp = await API.adminBulkUpdateModels(visibility);
|
||||
const doneLabels = { enabled: 'enabled', disabled: 'disabled', team: 'set to team-only' };
|
||||
hint.textContent = `${resp.count || 'All'} models ${doneLabels[visibility]}`;
|
||||
setTimeout(() => { hint.textContent = ''; }, 3000);
|
||||
await UI.loadAdminModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
|
||||
}
|
||||
|
||||
// ── Admin Roles ────────────────────────────
|
||||
|
||||
async function adminRoleProviderChanged(role, slot) {
|
||||
const provId = document.getElementById(`role-${role}-${slot}-provider`)?.value;
|
||||
const modelSelect = document.getElementById(`role-${role}-${slot}-model`);
|
||||
if (!modelSelect) return;
|
||||
|
||||
modelSelect.innerHTML = '<option value="">— select model —</option>';
|
||||
if (!provId || !window._adminModelList) return;
|
||||
|
||||
const models = window._adminModelList.filter(m => m.provider_config_id === provId);
|
||||
models.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.model_id;
|
||||
opt.textContent = m.display_name || m.model_id;
|
||||
modelSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
async function adminSaveRole(role) {
|
||||
const status = document.getElementById(`role-${role}-status`);
|
||||
try {
|
||||
const getBinding = (slot) => {
|
||||
const prov = document.getElementById(`role-${role}-${slot}-provider`)?.value;
|
||||
const model = document.getElementById(`role-${role}-${slot}-model`)?.value;
|
||||
return (prov && model) ? { provider_config_id: prov, model_id: model } : null;
|
||||
};
|
||||
await API.adminUpdateRole(role, {
|
||||
primary: getBinding('primary'),
|
||||
fallback: getBinding('fallback')
|
||||
});
|
||||
if (status) { status.textContent = '✓ Saved'; status.style.color = 'var(--success)'; }
|
||||
setTimeout(() => { if (status) status.textContent = ''; }, 3000);
|
||||
} catch (e) {
|
||||
if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--error)'; }
|
||||
}
|
||||
}
|
||||
|
||||
async function adminTestRole(role) {
|
||||
const status = document.getElementById(`role-${role}-status`);
|
||||
if (status) { status.textContent = 'Testing...'; status.style.color = 'var(--text-muted)'; }
|
||||
try {
|
||||
const result = await API.adminTestRole(role);
|
||||
if (status) {
|
||||
const fb = result.used_fallback ? ' (fallback)' : '';
|
||||
status.textContent = `✓ ${result.model}${fb}`;
|
||||
status.style.color = 'var(--success)';
|
||||
}
|
||||
} catch (e) {
|
||||
if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--error)'; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── User Model Preferences ──────────────────
|
||||
|
||||
async function toggleUserModelVisibility(modelId, currentlyHidden) {
|
||||
try {
|
||||
await API.setModelPreference(modelId, !currentlyHidden);
|
||||
if (currentlyHidden) App.hiddenModels.delete(modelId);
|
||||
else App.hiddenModels.add(modelId);
|
||||
await UI.loadUserModels();
|
||||
await fetchModels(); // refresh model selector
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function bulkSetUserModelVisibility(show) {
|
||||
const models = App.models.filter(m => !m.isPreset);
|
||||
if (!models.length) return;
|
||||
const shouldHide = !show;
|
||||
// Only update models not already in the desired state
|
||||
const toUpdate = models
|
||||
.map(m => m.baseModelId || m.id)
|
||||
.filter(mid => App.hiddenModels.has(mid) !== shouldHide);
|
||||
if (!toUpdate.length) { UI.toast(show ? 'All already visible' : 'All already hidden'); return; }
|
||||
try {
|
||||
await API.bulkSetModelPreferences(toUpdate, shouldHide);
|
||||
toUpdate.forEach(mid => shouldHide ? App.hiddenModels.add(mid) : App.hiddenModels.delete(mid));
|
||||
await UI.loadUserModels();
|
||||
await fetchModels();
|
||||
UI.toast(show ? `${toUpdate.length} model(s) shown` : `${toUpdate.length} model(s) hidden`);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteUserPreset(id, name) {
|
||||
if (!confirm(`Delete preset "${name}"?`)) return;
|
||||
try {
|
||||
await API.deleteUserPreset(id);
|
||||
UI.toast('Preset deleted');
|
||||
await UI.loadUserPresets();
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Admin Presets ────────────────────────────
|
||||
|
||||
var _adminPresetForm = null;
|
||||
function ensureAdminPresetForm() {
|
||||
if (_adminPresetForm) return _adminPresetForm;
|
||||
const container = document.getElementById('adminAddPresetForm');
|
||||
if (!container) return null;
|
||||
_adminPresetForm = renderPresetForm(container, {
|
||||
prefix: 'adminPreset',
|
||||
showAvatar: true,
|
||||
showProviderConfig: true,
|
||||
onSubmit: (vals) => createAdminPreset(vals),
|
||||
onCancel: () => {
|
||||
container.style.display = 'none';
|
||||
_editingPresetId = null;
|
||||
_adminPresetForm.setSubmitLabel('Create');
|
||||
_adminPresetForm.clearForm();
|
||||
}
|
||||
});
|
||||
// Override avatar handlers for edit-mode (upload immediately for existing presets)
|
||||
const fileInput = document.getElementById('adminPreset_avatarFileInput');
|
||||
if (fileInput) {
|
||||
// Remove default handler set by renderPresetForm, add edit-aware one
|
||||
const newInput = fileInput.cloneNode(true);
|
||||
fileInput.parentNode.replaceChild(newInput, fileInput);
|
||||
newInput.addEventListener('change', async function() {
|
||||
const file = this.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
if (_editingPresetId) {
|
||||
try {
|
||||
const data = await API.adminUploadPresetAvatar(_editingPresetId, reader.result);
|
||||
if (data.avatar) {
|
||||
_adminPresetForm.updateAvatarPreview(data.avatar);
|
||||
await UI.loadAdminPresets(true);
|
||||
await fetchModels();
|
||||
UI.toast('Preset avatar updated');
|
||||
}
|
||||
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
|
||||
} else {
|
||||
_adminPresetForm.updateAvatarPreview(reader.result);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
this.value = '';
|
||||
});
|
||||
document.getElementById('adminPreset_avatarUploadBtn')?.addEventListener('click', () => newInput.click());
|
||||
}
|
||||
const removeBtn = document.getElementById('adminPreset_avatarRemoveBtn');
|
||||
if (removeBtn) {
|
||||
const newBtn = removeBtn.cloneNode(true);
|
||||
removeBtn.parentNode.replaceChild(newBtn, removeBtn);
|
||||
newBtn.addEventListener('click', async () => {
|
||||
if (_editingPresetId) {
|
||||
try {
|
||||
await API.adminDeletePresetAvatar(_editingPresetId);
|
||||
_adminPresetForm.updateAvatarPreview(null);
|
||||
await UI.loadAdminPresets(true);
|
||||
await fetchModels();
|
||||
UI.toast('Preset avatar removed');
|
||||
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
|
||||
} else {
|
||||
_adminPresetForm.updateAvatarPreview(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
return _adminPresetForm;
|
||||
}
|
||||
|
||||
var _editingPresetId = null;
|
||||
function editAdminPreset(id) {
|
||||
const p = UI._presetCache?.[id];
|
||||
if (!p) return;
|
||||
_editingPresetId = id;
|
||||
ensureAdminPresetForm();
|
||||
const form = _adminPresetForm;
|
||||
if (!form) return;
|
||||
document.getElementById('adminAddPresetForm').style.display = '';
|
||||
form.setValues(p);
|
||||
form.setSubmitLabel('Update');
|
||||
}
|
||||
|
||||
async function createAdminPreset(vals) {
|
||||
if (!vals) {
|
||||
if (_adminPresetForm) vals = _adminPresetForm.getValues();
|
||||
else return;
|
||||
}
|
||||
if (!vals.name || !vals.base_model_id) { UI.toast('Name and base model are required', 'warning'); return; }
|
||||
|
||||
const preset = {
|
||||
name: vals.name,
|
||||
base_model_id: vals.base_model_id,
|
||||
description: vals.description || '',
|
||||
system_prompt: vals.system_prompt || '',
|
||||
};
|
||||
if (vals.provider_config_id) preset.provider_config_id = vals.provider_config_id;
|
||||
if (vals.temperature != null) preset.temperature = vals.temperature;
|
||||
if (vals.max_tokens != null) preset.max_tokens = vals.max_tokens;
|
||||
|
||||
try {
|
||||
let presetId = _editingPresetId;
|
||||
if (_editingPresetId) {
|
||||
await API.adminUpdatePreset(_editingPresetId, preset);
|
||||
UI.toast('Preset updated', 'success');
|
||||
} else {
|
||||
const result = await API.adminCreatePreset(preset);
|
||||
presetId = result?.id || result?.preset?.id;
|
||||
UI.toast('Preset created', 'success');
|
||||
}
|
||||
|
||||
// Upload pending avatar for new presets
|
||||
if (!_editingPresetId && presetId && vals._pendingAvatar) {
|
||||
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
|
||||
catch (e) { console.warn('Preset avatar upload failed:', e.message); }
|
||||
}
|
||||
|
||||
_editingPresetId = null;
|
||||
document.getElementById('adminAddPresetForm').style.display = 'none';
|
||||
if (_adminPresetForm) {
|
||||
_adminPresetForm.setSubmitLabel('Create');
|
||||
_adminPresetForm.clearForm();
|
||||
}
|
||||
await UI.loadAdminPresets();
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function toggleAdminPreset(id, active) {
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
await API.adminUpdatePreset(id, { is_active: active });
|
||||
await UI.loadAdminPresets(true);
|
||||
_restoreScroll(el, pos);
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteAdminPreset(id, name) {
|
||||
if (!confirm(`Delete preset "${name}"?`)) return;
|
||||
try {
|
||||
await API.adminDeletePreset(id);
|
||||
UI.toast('Preset deleted', 'success');
|
||||
await UI.loadAdminPresets();
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Team Admin Functions ────────────────────
|
||||
|
||||
async function toggleTeamActive(id, active) {
|
||||
try {
|
||||
await API.adminUpdateTeam(id, { is_active: active });
|
||||
UI.toast(active ? 'Team activated' : 'Team deactivated');
|
||||
await UI.loadAdminTeams(true);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteTeam(id, name) {
|
||||
if (!confirm(`Delete team "${name}"? All members will be removed.`)) return;
|
||||
try {
|
||||
await API.adminDeleteTeam(id);
|
||||
UI.toast('Team deleted');
|
||||
await UI.loadAdminTeams(true);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function updateTeamMember(teamId, memberId, role) {
|
||||
try {
|
||||
await API.adminUpdateMember(teamId, memberId, role);
|
||||
UI.toast('Role updated');
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
await UI.loadTeamMembers(teamId);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTeamMember(teamId, memberId, email) {
|
||||
if (!confirm(`Remove ${email} from team?`)) return;
|
||||
try {
|
||||
await API.adminRemoveMember(teamId, memberId);
|
||||
UI.toast('Member removed');
|
||||
await UI.loadTeamMembers(teamId);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// Settings-side team management (uses team admin API, not sys-admin)
|
||||
async function settingsUpdateTeamMember(teamId, memberId, role) {
|
||||
try {
|
||||
await API.teamUpdateMember(teamId, memberId, role);
|
||||
UI.toast('Role updated');
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
await UI.loadTeamManageMembers(teamId);
|
||||
}
|
||||
}
|
||||
|
||||
async function settingsRemoveTeamMember(teamId, memberId, email) {
|
||||
if (!confirm(`Remove ${email} from team?`)) return;
|
||||
try {
|
||||
await API.teamRemoveMember(teamId, memberId);
|
||||
UI.toast('Member removed');
|
||||
await UI.loadTeamManageMembers(teamId);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function settingsDeleteTeamPreset(teamId, presetId, name) {
|
||||
if (!confirm(`Delete team preset "${name}"?`)) return;
|
||||
try {
|
||||
await API.teamDeletePreset(teamId, presetId);
|
||||
UI.toast('Preset deleted');
|
||||
await UI.loadTeamManagePresets(teamId);
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function settingsToggleTeamProvider(teamId, providerId, currentlyActive) {
|
||||
try {
|
||||
await API.teamUpdateProvider(teamId, providerId, { is_active: !currentlyActive });
|
||||
UI.toast(currentlyActive ? 'Provider deactivated' : 'Provider activated');
|
||||
await UI.loadTeamManageProviders(teamId);
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function settingsDeleteTeamProvider(teamId, providerId, name) {
|
||||
if (!confirm(`Delete team provider "${name}"? This will remove all models from this provider for your team.`)) return;
|
||||
try {
|
||||
await API.teamDeleteProvider(teamId, providerId);
|
||||
UI.toast('Provider deleted');
|
||||
await UI.loadTeamManageProviders(teamId);
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
function settingsEditTeamProvider(teamId, provId, name, endpoint, defaultModel, isPrivate) {
|
||||
UI._editingTeamProviderId = provId;
|
||||
document.getElementById('settingsTeamAddProvider').style.display = 'none';
|
||||
const form = document.getElementById('settingsTeamEditProvider');
|
||||
form.style.display = '';
|
||||
document.getElementById('teamProviderEditName').value = name;
|
||||
document.getElementById('teamProviderEditEndpoint').value = endpoint;
|
||||
document.getElementById('teamProviderEditKey').value = '';
|
||||
document.getElementById('teamProviderEditDefault').value = defaultModel;
|
||||
document.getElementById('teamProviderEditPrivate').checked = isPrivate;
|
||||
}
|
||||
|
||||
// ── Admin Listeners (extracted from initListeners) ──
|
||||
|
||||
function _initAdminListeners() {
|
||||
// Admin modal (elements may not exist for non-admin users)
|
||||
document.getElementById('adminCloseBtn')?.addEventListener('click', UI.closeAdmin);
|
||||
document.querySelectorAll('.admin-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => UI.switchAdminTab(tab.dataset.tab));
|
||||
});
|
||||
document.getElementById('adminSaveSettings')?.addEventListener('click', handleSaveAdminSettings);
|
||||
|
||||
// Admin — users
|
||||
document.getElementById('adminAddUserBtn')?.addEventListener('click', () => {
|
||||
const f = document.getElementById('adminAddUserForm');
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
document.getElementById('adminCancelUserBtn')?.addEventListener('click', () => { document.getElementById('adminAddUserForm').style.display = 'none'; });
|
||||
document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser);
|
||||
|
||||
// Admin — providers
|
||||
document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => {
|
||||
_editingProviderId = null;
|
||||
document.getElementById('adminCreateProvBtn').textContent = 'Save';
|
||||
document.getElementById('adminProvKey').placeholder = 'sk-...';
|
||||
document.getElementById('adminProvName').value = '';
|
||||
document.getElementById('adminProvEndpoint').value = '';
|
||||
document.getElementById('adminProvKey').value = '';
|
||||
document.getElementById('adminProvModel').value = '';
|
||||
document.getElementById('adminProvPrivate').checked = false;
|
||||
const f = document.getElementById('adminAddProviderForm');
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
document.getElementById('adminCancelProvBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminAddProviderForm').style.display = 'none';
|
||||
_editingProviderId = null;
|
||||
document.getElementById('adminCreateProvBtn').textContent = 'Save';
|
||||
document.getElementById('adminProvKey').placeholder = 'sk-...';
|
||||
});
|
||||
document.getElementById('adminCreateProvBtn')?.addEventListener('click', createGlobalProvider);
|
||||
|
||||
// Admin — models
|
||||
document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels);
|
||||
|
||||
// Admin — presets (shared form)
|
||||
document.getElementById('adminAddPresetBtn')?.addEventListener('click', () => {
|
||||
const form = ensureAdminPresetForm();
|
||||
if (!form) return;
|
||||
_editingPresetId = null;
|
||||
form.setSubmitLabel('Create');
|
||||
form.clearForm();
|
||||
const f = document.getElementById('adminAddPresetForm');
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
|
||||
// Admin — Teams
|
||||
document.getElementById('adminAddTeamBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminAddTeamForm').style.display = '';
|
||||
document.getElementById('adminTeamName').focus();
|
||||
});
|
||||
document.getElementById('adminCancelTeamBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminAddTeamForm').style.display = 'none';
|
||||
document.getElementById('adminTeamName').value = '';
|
||||
document.getElementById('adminTeamDesc').value = '';
|
||||
});
|
||||
document.getElementById('adminCreateTeamBtn')?.addEventListener('click', async () => {
|
||||
const name = document.getElementById('adminTeamName').value.trim();
|
||||
const desc = document.getElementById('adminTeamDesc').value.trim();
|
||||
if (!name) return UI.toast('Team name required', 'error');
|
||||
try {
|
||||
await API.adminCreateTeam(name, desc);
|
||||
document.getElementById('adminAddTeamForm').style.display = 'none';
|
||||
document.getElementById('adminTeamName').value = '';
|
||||
document.getElementById('adminTeamDesc').value = '';
|
||||
UI.toast('Team created');
|
||||
await UI.loadAdminTeams(true);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
document.getElementById('adminTeamBackBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminTeamDetail').style.display = 'none';
|
||||
document.getElementById('adminTeamList').style.display = '';
|
||||
UI.loadAdminTeams(true);
|
||||
});
|
||||
document.getElementById('adminAddMemberBtn')?.addEventListener('click', async () => {
|
||||
document.getElementById('adminAddMemberForm').style.display = '';
|
||||
await UI.loadMemberUserDropdown(UI._teamEditId);
|
||||
});
|
||||
document.getElementById('adminTeamPrivatePolicy')?.addEventListener('change', async function() {
|
||||
try {
|
||||
await API.adminUpdateTeam(UI._teamEditId, {
|
||||
settings: JSON.stringify({ require_private_providers: this.checked })
|
||||
});
|
||||
UI.toast(this.checked ? 'Private providers required' : 'Private provider policy removed');
|
||||
} catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; }
|
||||
});
|
||||
document.getElementById('adminTeamAllowProviders')?.addEventListener('change', async function() {
|
||||
try {
|
||||
await API.adminUpdateTeam(UI._teamEditId, {
|
||||
settings: JSON.stringify({ allow_team_providers: this.checked })
|
||||
});
|
||||
UI.toast(this.checked ? 'Team providers enabled' : 'Team providers disabled');
|
||||
} catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; }
|
||||
});
|
||||
document.getElementById('adminCancelMemberBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminAddMemberForm').style.display = 'none';
|
||||
});
|
||||
document.getElementById('adminAddMemberSubmit')?.addEventListener('click', async () => {
|
||||
const userId = document.getElementById('adminMemberUser').value;
|
||||
const role = document.getElementById('adminMemberRole').value;
|
||||
if (!userId) return UI.toast('Select a user', 'error');
|
||||
try {
|
||||
await API.adminAddMember(UI._teamEditId, userId, role);
|
||||
document.getElementById('adminAddMemberForm').style.display = 'none';
|
||||
UI.toast('Member added');
|
||||
await UI.loadTeamMembers(UI._teamEditId);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
}
|
||||
2525
src/js/app.js
2525
src/js/app.js
File diff suppressed because it is too large
Load Diff
584
src/js/chat.js
Normal file
584
src/js/chat.js
Normal file
@@ -0,0 +1,584 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Chat Operations
|
||||
// ==========================================
|
||||
// Chat management, send, stream, regenerate, edit, branch,
|
||||
// summarize, per-chat model persistence.
|
||||
|
||||
// ── Summarize & Continue ──────────────────
|
||||
|
||||
async function summarizeAndContinue() {
|
||||
const channelId = App.currentChatId;
|
||||
if (!channelId) return;
|
||||
|
||||
const btn = document.getElementById('summarizeBtn');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Summarizing…';
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await API.summarizeChannel(channelId);
|
||||
UI.toast(`Conversation summarized (${result.summarized_count} messages)`, 'success');
|
||||
|
||||
// Reload the active path to get the updated message tree
|
||||
const resp = await API.getActivePath(channelId);
|
||||
const chat = App.chats.find(c => c.id === channelId);
|
||||
if (chat && resp) {
|
||||
chat.messages = (resp.path || []).map(m => ({
|
||||
id: m.id,
|
||||
parent_id: m.parent_id || null,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
model: m.model || '',
|
||||
modelName: '',
|
||||
timestamp: m.created_at,
|
||||
tool_calls: m.tool_calls || null,
|
||||
metadata: m.metadata || null,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
}));
|
||||
UI.renderMessages(chat.messages);
|
||||
updateContextWarning();
|
||||
updateInputTokens();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Summarize failed:', err);
|
||||
UI.toast(err.message || 'Summarization failed', 'error');
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '📝 Summarize & Continue';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chat Management ──────────────────────────
|
||||
|
||||
async function loadChats() {
|
||||
try {
|
||||
const resp = await API.listChannels(1, 100, 'direct');
|
||||
App.chats = (resp.data || []).map(c => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
type: c.type || 'direct',
|
||||
model: c.model || '',
|
||||
messageCount: c.message_count || 0,
|
||||
messages: [],
|
||||
updatedAt: c.updated_at
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to load chats:', e.message);
|
||||
UI.toast('Failed to load chats', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function selectChat(chatId) {
|
||||
App.currentChatId = chatId;
|
||||
UI.renderChatList();
|
||||
if (window.innerWidth <= 768) {
|
||||
document.getElementById('sidebar').classList.add('collapsed');
|
||||
const ov = document.getElementById('sidebarOverlay');
|
||||
if (ov) ov.style.display = 'none';
|
||||
}
|
||||
|
||||
const chat = App.chats.find(c => c.id === chatId);
|
||||
if (!chat) return;
|
||||
|
||||
if (chat.messages.length === 0 && chat.messageCount > 0) {
|
||||
try {
|
||||
const resp = await API.getActivePath(chatId);
|
||||
chat.messages = (resp.path || []).map(m => ({
|
||||
id: m.id,
|
||||
parent_id: m.parent_id || null,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
model: m.model || '',
|
||||
modelName: '',
|
||||
timestamp: m.created_at,
|
||||
tool_calls: m.tool_calls || null,
|
||||
metadata: m.metadata || null,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to load messages:', e.message);
|
||||
UI.toast('Failed to load messages', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the last-used model/preset for this chat
|
||||
_restoreChatModel(chatId, chat);
|
||||
|
||||
UI.renderMessages(chat.messages);
|
||||
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
||||
Tokens._warningDismissed = false;
|
||||
updateContextWarning();
|
||||
updateInputTokens();
|
||||
}
|
||||
|
||||
// ── Per-Chat Model/Preset Persistence ────────
|
||||
// BANDAID: localStorage — doesn't roam across devices.
|
||||
// TODO: move to channel.settings.last_selector_id (server-side). See ROADMAP TBD.
|
||||
|
||||
function _saveChatModel(chatId) {
|
||||
if (!chatId) return;
|
||||
const selectorId = UI.getModelValue();
|
||||
if (!selectorId) return;
|
||||
try {
|
||||
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
||||
map[chatId] = selectorId;
|
||||
localStorage.setItem('cs-chat-models', JSON.stringify(map));
|
||||
} catch (e) { /* quota exceeded, etc */ }
|
||||
}
|
||||
|
||||
function _restoreChatModel(chatId, chat) {
|
||||
// Priority: stored selector ID → channel base model → keep current
|
||||
let targetId = null;
|
||||
|
||||
// 1. Check localStorage for exact selector ID (handles presets)
|
||||
try {
|
||||
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
||||
if (map[chatId]) targetId = map[chatId];
|
||||
} catch (e) { /* */ }
|
||||
|
||||
// 2. Verify the stored ID still exists in available models
|
||||
if (targetId) {
|
||||
const found = App.models.find(m => m.id === targetId);
|
||||
if (found) {
|
||||
UI.setModelValue(found.id, found.name + (found.provider ? ` (${found.provider})` : ''));
|
||||
return;
|
||||
}
|
||||
// Stored model removed — clean up stale entry
|
||||
try {
|
||||
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
||||
delete map[chatId];
|
||||
localStorage.setItem('cs-chat-models', JSON.stringify(map));
|
||||
} catch (e) { /* */ }
|
||||
}
|
||||
|
||||
// 3. Fall back to channel's base model ID (match by baseModelId)
|
||||
if (chat?.model) {
|
||||
const match = App.models.find(m => m.baseModelId === chat.model && !m.isPreset && !m.hidden);
|
||||
if (match) {
|
||||
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Stored model gone — fall back to admin default → first visible
|
||||
const visible = App.models.filter(m => !m.hidden);
|
||||
const def = App.defaultModel
|
||||
? visible.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel)
|
||||
: null;
|
||||
const fallback = def || visible[0];
|
||||
if (fallback) {
|
||||
UI.setModelValue(fallback.id, fallback.name + (fallback.provider ? ` (${fallback.provider})` : ''));
|
||||
}
|
||||
}
|
||||
|
||||
async function newChat() {
|
||||
App.currentChatId = null;
|
||||
UI.renderChatList();
|
||||
UI.showEmptyState();
|
||||
UI.showRegenerate(false);
|
||||
Tokens._warningDismissed = false;
|
||||
updateContextWarning();
|
||||
updateInputTokens();
|
||||
document.getElementById('messageInput').focus();
|
||||
if (window.innerWidth <= 768) {
|
||||
document.getElementById('sidebar').classList.add('collapsed');
|
||||
const ov = document.getElementById('sidebarOverlay');
|
||||
if (ov) ov.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChat(chatId) {
|
||||
if (!confirm('Delete this chat?')) return;
|
||||
try {
|
||||
await API.deleteChannel(chatId);
|
||||
App.chats = App.chats.filter(c => c.id !== chatId);
|
||||
// Clean up stored model preference
|
||||
try {
|
||||
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
||||
delete map[chatId];
|
||||
localStorage.setItem('cs-chat-models', JSON.stringify(map));
|
||||
} catch (e) { /* */ }
|
||||
if (App.currentChatId === chatId) { clearPreview(); newChat(); }
|
||||
UI.renderChatList();
|
||||
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Send Message ─────────────────────────────
|
||||
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById('messageInput');
|
||||
const text = input.value.trim();
|
||||
if (!text || App.isGenerating) return;
|
||||
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
|
||||
const selectedId = UI.getModelValue();
|
||||
const modelInfo = App.findModel(selectedId);
|
||||
// For presets, send the base model ID; for regular models, send the model ID
|
||||
const model = modelInfo?.baseModelId || selectedId;
|
||||
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||||
|
||||
if (!App.currentChatId) {
|
||||
try {
|
||||
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
|
||||
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.currentChatId = chat.id;
|
||||
UI.renderChatList();
|
||||
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
|
||||
}
|
||||
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!chat) return;
|
||||
|
||||
// Optimistic: show user message immediately
|
||||
chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
|
||||
UI.renderMessages(chat.messages);
|
||||
|
||||
App.isGenerating = true;
|
||||
App.abortController = new AbortController();
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId);
|
||||
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
||||
|
||||
// Reload active path from server to get proper IDs and sibling metadata
|
||||
await reloadActivePath();
|
||||
UI.showRegenerate(true);
|
||||
|
||||
// Persist the model/preset selection for this chat
|
||||
_saveChatModel(App.currentChatId);
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
UI.toast('Generation stopped', 'warning');
|
||||
await reloadActivePath();
|
||||
} else {
|
||||
console.error('Completion error:', e);
|
||||
const msg = e.message || '';
|
||||
if (e.proxyBlocked) {
|
||||
UI.toast('Network proxy blocked this request — contact your network admin', 'error');
|
||||
} else if (msg.includes('provider error') && msg.includes('401')) {
|
||||
UI.toast('Provider API key rejected — check Settings → Providers', 'error');
|
||||
} else if (msg.includes('provider error') && msg.includes('429')) {
|
||||
UI.toast('Provider rate limit — wait and retry', 'warning');
|
||||
} else if (msg.includes('no API config') || msg.includes('no model')) {
|
||||
UI.toast('No provider configured — add one in Settings', 'error');
|
||||
} else {
|
||||
UI.toast(msg, 'error');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
App.isGenerating = false;
|
||||
App.abortController = null;
|
||||
UI.setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function stopGeneration() { if (App.abortController) App.abortController.abort(); }
|
||||
|
||||
// ── Reload Active Path from Server ──────────
|
||||
|
||||
async function reloadActivePath() {
|
||||
if (!App.currentChatId) return;
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!chat) return;
|
||||
|
||||
try {
|
||||
const resp = await API.getActivePath(App.currentChatId);
|
||||
chat.messages = (resp.path || []).map(m => ({
|
||||
id: m.id,
|
||||
parent_id: m.parent_id || null,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
model: m.model || '',
|
||||
modelName: '',
|
||||
timestamp: m.created_at,
|
||||
tool_calls: m.tool_calls || null,
|
||||
metadata: m.metadata || null,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
}));
|
||||
chat.messageCount = chat.messages.length;
|
||||
UI.renderMessages(chat.messages);
|
||||
updateContextWarning();
|
||||
} catch (e) {
|
||||
console.error('Failed to reload path:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Regenerate (tree-aware: creates sibling) ─
|
||||
|
||||
async function regenerateMessage(messageId) {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
|
||||
const selectedId = UI.getModelValue();
|
||||
const modelInfo = App.findModel(selectedId);
|
||||
const model = modelInfo?.baseModelId || selectedId;
|
||||
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||||
|
||||
// Truncate display to the parent of the message being regenerated.
|
||||
// This makes the stream appear in the correct position — as a fresh
|
||||
// response to the parent user message, not appended at the bottom.
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (chat) {
|
||||
const msgIdx = chat.messages.findIndex(m => m.id === messageId);
|
||||
if (msgIdx > 0) {
|
||||
const truncated = chat.messages.slice(0, msgIdx);
|
||||
UI.renderMessages(truncated);
|
||||
}
|
||||
}
|
||||
|
||||
App.isGenerating = true;
|
||||
App.abortController = new AbortController();
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const resp = await API.streamRegenerate(
|
||||
App.currentChatId, messageId, App.abortController.signal,
|
||||
model, presetId, modelInfo?.configId
|
||||
);
|
||||
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
|
||||
|
||||
// Reload from server to get proper tree state
|
||||
await reloadActivePath();
|
||||
UI.showRegenerate(true);
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
UI.toast('Generation stopped', 'warning');
|
||||
await reloadActivePath();
|
||||
} else {
|
||||
const msg = e.message || '';
|
||||
if (e.proxyBlocked) {
|
||||
UI.toast('Network proxy blocked this request', 'error');
|
||||
} else if (msg.includes('provider error') && msg.includes('401')) {
|
||||
UI.toast('Provider API key rejected — check Settings', 'error');
|
||||
} else { UI.toast(msg, 'error'); }
|
||||
}
|
||||
} finally {
|
||||
App.isGenerating = false;
|
||||
App.abortController = null;
|
||||
UI.setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy: bottom-bar regenerate button targets the last assistant message
|
||||
async function regenerate() {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!chat || chat.messages.length === 0) return;
|
||||
|
||||
// Find the last assistant message with an ID
|
||||
for (let i = chat.messages.length - 1; i >= 0; i--) {
|
||||
if (chat.messages[i].role === 'assistant' && chat.messages[i].id) {
|
||||
return regenerateMessage(chat.messages[i].id);
|
||||
}
|
||||
}
|
||||
UI.toast('No assistant message to regenerate', 'warning');
|
||||
}
|
||||
|
||||
// ── Edit Message (creates sibling, triggers completion) ──
|
||||
|
||||
async function editMessage(messageId) {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!chat) return;
|
||||
|
||||
const msg = chat.messages.find(m => m.id === messageId);
|
||||
if (!msg || msg.role !== 'user') return;
|
||||
|
||||
// Show inline edit UI
|
||||
UI.showEditInline(messageId, msg.content);
|
||||
}
|
||||
|
||||
async function submitEdit(messageId, newContent) {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
if (!newContent.trim()) return;
|
||||
|
||||
const selectedId = UI.getModelValue();
|
||||
const modelInfo = App.findModel(selectedId);
|
||||
const model = modelInfo?.baseModelId || selectedId;
|
||||
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||||
|
||||
App.isGenerating = true;
|
||||
App.abortController = new AbortController();
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
|
||||
// Step 1: Create sibling user message via edit endpoint
|
||||
const editResp = await API.editMessage(App.currentChatId, messageId, newContent.trim());
|
||||
|
||||
// Step 2: Reload path to show the new edited message
|
||||
await reloadActivePath();
|
||||
|
||||
// Step 3: Generate assistant response as child of the new edit.
|
||||
// Uses regenerate endpoint (which handles user messages by creating
|
||||
// a child response, not a sibling). This avoids the duplicate user
|
||||
// message that streamCompletion would create.
|
||||
const resp = await API.streamRegenerate(
|
||||
App.currentChatId, editResp.id, App.abortController.signal,
|
||||
model, presetId, modelInfo?.configId
|
||||
);
|
||||
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
|
||||
|
||||
// Step 4: Final reload to get the complete tree state
|
||||
await reloadActivePath();
|
||||
UI.showRegenerate(true);
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
UI.toast('Generation stopped', 'warning');
|
||||
await reloadActivePath();
|
||||
} else {
|
||||
console.error('Edit error:', e);
|
||||
UI.toast(e.message || 'Edit failed', 'error');
|
||||
await reloadActivePath();
|
||||
}
|
||||
} finally {
|
||||
App.isGenerating = false;
|
||||
App.abortController = null;
|
||||
UI.setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
// Re-render to remove the edit form
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (chat) UI.renderMessages(chat.messages);
|
||||
}
|
||||
|
||||
// ── Switch Branch (sibling navigation) ──────
|
||||
|
||||
async function switchSibling(messageId, direction) {
|
||||
if (App.isGenerating || !App.currentChatId) return;
|
||||
|
||||
try {
|
||||
// Get siblings for this message
|
||||
const data = await API.listSiblings(App.currentChatId, messageId);
|
||||
const siblings = data.siblings || [];
|
||||
const currentIdx = data.current_index ?? 0;
|
||||
|
||||
const targetIdx = currentIdx + direction;
|
||||
if (targetIdx < 0 || targetIdx >= siblings.length) return;
|
||||
|
||||
const targetSibling = siblings[targetIdx];
|
||||
|
||||
// Update cursor to the target sibling (backend walks to leaf)
|
||||
const resp = await API.updateCursor(App.currentChatId, targetSibling.id);
|
||||
|
||||
// Update local state with the new path
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (chat && resp.path) {
|
||||
chat.messages = resp.path.map(m => ({
|
||||
id: m.id,
|
||||
parent_id: m.parent_id || null,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
model: m.model || '',
|
||||
modelName: '',
|
||||
timestamp: m.created_at,
|
||||
tool_calls: m.tool_calls || null,
|
||||
metadata: m.metadata || null,
|
||||
siblingCount: m.sibling_count || 1,
|
||||
siblingIndex: m.sibling_index || 0,
|
||||
}));
|
||||
chat.messageCount = chat.messages.length;
|
||||
UI.renderMessages(chat.messages);
|
||||
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Branch switch failed:', e.message);
|
||||
UI.toast('Failed to switch branch', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chat Listeners (extracted from initListeners) ──
|
||||
|
||||
function _initChatListeners() {
|
||||
// Sidebar
|
||||
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
|
||||
document.getElementById('newChatBtn').addEventListener('click', newChat);
|
||||
|
||||
// Split button dropdown
|
||||
document.getElementById('newChatDropBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
document.getElementById('newChatDropdown').classList.toggle('open');
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.split-btn')) {
|
||||
document.getElementById('newChatDropdown').classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// User flyout
|
||||
document.getElementById('userMenuBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
UI.toggleUserMenu();
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.sidebar-bottom')) UI.closeUserMenu();
|
||||
});
|
||||
|
||||
// Flyout items
|
||||
document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); });
|
||||
document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); });
|
||||
document.getElementById('menuTeamAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openTeamAdmin(); });
|
||||
document.getElementById('teamAdminCloseBtn')?.addEventListener('click', () => UI.closeTeamAdmin());
|
||||
document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
|
||||
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
|
||||
|
||||
// Sidebar chat search
|
||||
const _chatSearchInput = document.getElementById('chatSearchInput');
|
||||
_chatSearchInput?.addEventListener('input', () => UI.renderChatList());
|
||||
document.getElementById('chatSearchClear')?.addEventListener('click', () => {
|
||||
if (_chatSearchInput) { _chatSearchInput.value = ''; UI.renderChatList(); _chatSearchInput.focus(); }
|
||||
});
|
||||
|
||||
// Chat actions
|
||||
document.getElementById('sendBtn').addEventListener('click', sendMessage);
|
||||
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
|
||||
|
||||
// Model selector (custom dropdown)
|
||||
UI.initModelDropdown();
|
||||
UI.initAppearance();
|
||||
|
||||
// Mobile hamburger
|
||||
document.getElementById('mobileMenuBtn')?.addEventListener('click', UI.toggleSidebar);
|
||||
document.getElementById('sidebarOverlay')?.addEventListener('click', () => {
|
||||
document.getElementById('sidebar').classList.add('collapsed');
|
||||
document.getElementById('sidebarOverlay').style.display = 'none';
|
||||
localStorage.setItem('sb_sidebar', '1');
|
||||
});
|
||||
document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
|
||||
await fetchModels();
|
||||
const visible = App.models.filter(m => !m.hidden).length;
|
||||
UI.toast(`Loaded ${visible} model${visible !== 1 ? 's' : ''}`, 'success');
|
||||
});
|
||||
|
||||
// Input
|
||||
const input = document.getElementById('messageInput');
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||||
});
|
||||
input.addEventListener('input', function() {
|
||||
this.style.height = 'auto';
|
||||
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
|
||||
updateInputTokens();
|
||||
});
|
||||
|
||||
// Close modals on overlay click
|
||||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) closeModal(overlay.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
364
src/js/notes.js
Normal file
364
src/js/notes.js
Normal file
@@ -0,0 +1,364 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Notes Panel
|
||||
// ==========================================
|
||||
// Notes panel: editor, multi-select, folders, CRUD.
|
||||
|
||||
var _editingNoteId = null;
|
||||
var _notesSelectMode = false;
|
||||
var _selectedNoteIds = new Set();
|
||||
var _notesSort = 'updated_desc';
|
||||
|
||||
// ── Notes ─────────────────────────────────────
|
||||
|
||||
async function openNotes() {
|
||||
openSidePanel('notes');
|
||||
_exitSelectMode();
|
||||
await loadNotesList();
|
||||
await loadNoteFolders();
|
||||
}
|
||||
|
||||
async function loadNotesList(folder, searchQuery) {
|
||||
const list = document.getElementById('notesList');
|
||||
list.innerHTML = '<div class="notes-loading">Loading…</div>';
|
||||
|
||||
try {
|
||||
let data;
|
||||
if (searchQuery) {
|
||||
data = await API.searchNotes(searchQuery);
|
||||
const results = data.data || [];
|
||||
if (results.length === 0) {
|
||||
list.innerHTML = '<div class="notes-empty">No results found</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = results.map(n => _noteListItem(n, true)).join('');
|
||||
} else {
|
||||
const folderVal = folder || document.getElementById('notesFolderFilter')?.value || '';
|
||||
data = await API.listNotes(100, 0, folderVal, '', _notesSort);
|
||||
const notes = data.data || [];
|
||||
if (notes.length === 0) {
|
||||
list.innerHTML = `<div class="notes-empty">${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = notes.map(n => _noteListItem(n, false)).join('');
|
||||
}
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div class="notes-empty">Failed to load: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _noteListItem(note, isSearch) {
|
||||
const tags = (note.tags || []).map(t => `<span class="note-tag">${esc(t)}</span>`).join('');
|
||||
const folder = note.folder_path && note.folder_path !== '/' ? `<span class="note-folder">${esc(note.folder_path)}</span>` : '';
|
||||
const preview = isSearch && note.headline
|
||||
? `<div class="note-headline">${_highlightHeadline(note.headline)}</div>`
|
||||
: `<div class="note-preview">${esc((note.preview || note.content || '').slice(0, 120))}</div>`;
|
||||
const time = _relativeTime(note.updated_at);
|
||||
const checked = _selectedNoteIds.has(note.id) ? 'checked' : '';
|
||||
|
||||
return `
|
||||
<div class="note-item ${_selectedNoteIds.has(note.id) ? 'selected' : ''}" data-note-id="${note.id}">
|
||||
<div class="note-select-col" style="display:${_notesSelectMode ? '' : 'none'}">
|
||||
<input type="checkbox" class="note-checkbox" data-id="${note.id}" ${checked}
|
||||
onclick="event.stopPropagation(); _toggleNoteSelect('${note.id}', this.checked)">
|
||||
</div>
|
||||
<div class="note-content-col" onclick="${_notesSelectMode ? `_toggleNoteSelect('${note.id}')` : `openNoteEditor('${note.id}')`}">
|
||||
<div class="note-item-header">
|
||||
<span class="note-item-title">${esc(note.title)}</span>
|
||||
<span class="note-item-time">${time}</span>
|
||||
</div>
|
||||
${preview}
|
||||
<div class="note-item-meta">${folder}${tags}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _highlightHeadline(headline) {
|
||||
const escaped = esc(headline);
|
||||
return escaped.replace(/\*\*(.+?)\*\*/g, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
// ── Multi-select ────────────────
|
||||
|
||||
function _enterSelectMode() {
|
||||
_notesSelectMode = true;
|
||||
_selectedNoteIds.clear();
|
||||
document.getElementById('notesSelectionBar').style.display = '';
|
||||
document.querySelectorAll('.note-select-col').forEach(el => el.style.display = '');
|
||||
_updateSelectedCount();
|
||||
}
|
||||
|
||||
function _exitSelectMode() {
|
||||
_notesSelectMode = false;
|
||||
_selectedNoteIds.clear();
|
||||
document.getElementById('notesSelectionBar').style.display = 'none';
|
||||
document.querySelectorAll('.note-select-col').forEach(el => el.style.display = 'none');
|
||||
document.querySelectorAll('.note-item.selected').forEach(el => el.classList.remove('selected'));
|
||||
document.querySelectorAll('.note-checkbox').forEach(cb => cb.checked = false);
|
||||
const sa = document.getElementById('notesSelectAll');
|
||||
if (sa) sa.checked = false;
|
||||
}
|
||||
|
||||
function _toggleNoteSelect(id, forceState) {
|
||||
if (!_notesSelectMode) {
|
||||
_enterSelectMode();
|
||||
}
|
||||
const has = _selectedNoteIds.has(id);
|
||||
const newState = forceState !== undefined ? forceState : !has;
|
||||
|
||||
if (newState) _selectedNoteIds.add(id);
|
||||
else _selectedNoteIds.delete(id);
|
||||
|
||||
const item = document.querySelector(`.note-item[data-note-id="${id}"]`);
|
||||
if (item) {
|
||||
item.classList.toggle('selected', newState);
|
||||
const cb = item.querySelector('.note-checkbox');
|
||||
if (cb) cb.checked = newState;
|
||||
}
|
||||
_updateSelectedCount();
|
||||
}
|
||||
|
||||
function _toggleSelectAll(checked) {
|
||||
document.querySelectorAll('.note-checkbox').forEach(cb => {
|
||||
const id = cb.dataset.id;
|
||||
if (checked) _selectedNoteIds.add(id);
|
||||
else _selectedNoteIds.delete(id);
|
||||
cb.checked = checked;
|
||||
cb.closest('.note-item')?.classList.toggle('selected', checked);
|
||||
});
|
||||
_updateSelectedCount();
|
||||
}
|
||||
|
||||
function _updateSelectedCount() {
|
||||
const el = document.getElementById('notesSelectedCount');
|
||||
if (el) el.textContent = _selectedNoteIds.size;
|
||||
}
|
||||
|
||||
async function _bulkDeleteSelected() {
|
||||
if (_selectedNoteIds.size === 0) return;
|
||||
const count = _selectedNoteIds.size;
|
||||
if (!confirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
|
||||
|
||||
try {
|
||||
const resp = await API.bulkDeleteNotes([..._selectedNoteIds]);
|
||||
UI.toast(`Deleted ${resp.deleted} note${resp.deleted !== 1 ? 's' : ''}`, 'success');
|
||||
_exitSelectMode();
|
||||
await loadNotesList();
|
||||
await loadNoteFolders();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Folders / Editor / CRUD ─────
|
||||
|
||||
async function loadNoteFolders() {
|
||||
const sel = document.getElementById('notesFolderFilter');
|
||||
if (!sel) return;
|
||||
try {
|
||||
const data = await API.listNoteFolders();
|
||||
const folders = data.folders || [];
|
||||
sel.innerHTML = '<option value="">All folders</option>';
|
||||
folders.forEach(f => {
|
||||
sel.innerHTML += `<option value="${esc(f.path)}">${esc(f.path)} (${f.count})</option>`;
|
||||
});
|
||||
} catch (e) { /* non-critical */ }
|
||||
}
|
||||
|
||||
function showNotesList() {
|
||||
document.getElementById('notesListView').style.display = '';
|
||||
document.getElementById('notesEditorView').style.display = 'none';
|
||||
_editingNoteId = null;
|
||||
}
|
||||
|
||||
var _currentNote = null; // cached note for read mode
|
||||
|
||||
function copyNoteContent() {
|
||||
if (!_currentNote) return;
|
||||
const text = `# ${_currentNote.title || ''}\n\n${_currentNote.content || ''}`;
|
||||
navigator.clipboard.writeText(text).then(() => UI.toast('Copied', 'success'));
|
||||
}
|
||||
|
||||
async function openNoteEditor(noteId) {
|
||||
document.getElementById('notesListView').style.display = 'none';
|
||||
document.getElementById('notesEditorView').style.display = '';
|
||||
|
||||
if (noteId) {
|
||||
_editingNoteId = noteId;
|
||||
try {
|
||||
_currentNote = await API.getNote(noteId);
|
||||
_populateEditFields(_currentNote);
|
||||
_showNoteReadMode();
|
||||
} catch (e) {
|
||||
UI.toast('Failed to load note: ' + e.message, 'error');
|
||||
showNotesList();
|
||||
}
|
||||
} else {
|
||||
// New note — straight to edit mode
|
||||
_editingNoteId = null;
|
||||
_currentNote = null;
|
||||
_clearEditFields();
|
||||
_showNoteEditMode();
|
||||
document.getElementById('noteEditorTitle').focus();
|
||||
}
|
||||
}
|
||||
|
||||
function _populateEditFields(note) {
|
||||
document.getElementById('noteEditorTitle').value = note.title || '';
|
||||
document.getElementById('noteEditorFolder').value = note.folder_path || '';
|
||||
document.getElementById('noteEditorTags').value = (note.tags || []).join(', ');
|
||||
document.getElementById('noteEditorContent').value = note.content || '';
|
||||
}
|
||||
|
||||
function _clearEditFields() {
|
||||
document.getElementById('noteEditorTitle').value = '';
|
||||
document.getElementById('noteEditorFolder').value = '';
|
||||
document.getElementById('noteEditorTags').value = '';
|
||||
document.getElementById('noteEditorContent').value = '';
|
||||
}
|
||||
|
||||
function _showNoteReadMode() {
|
||||
if (!_currentNote) return;
|
||||
const n = _currentNote;
|
||||
|
||||
// Title
|
||||
document.getElementById('noteReadTitle').textContent = n.title || '';
|
||||
|
||||
// Meta: folder + tags
|
||||
const parts = [];
|
||||
if (n.folder_path && n.folder_path !== '/') parts.push(`<span class="note-folder">${esc(n.folder_path)}</span>`);
|
||||
(n.tags || []).forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
|
||||
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
|
||||
|
||||
// Rendered markdown content — reuse the chat formatter
|
||||
document.getElementById('noteReadContent').innerHTML = formatMessage(n.content || '');
|
||||
|
||||
// Show/hide delete
|
||||
document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none';
|
||||
|
||||
// Toggle visibility
|
||||
document.getElementById('noteReadMode').style.display = '';
|
||||
document.getElementById('noteEditMode').style.display = 'none';
|
||||
document.getElementById('noteEditBtn').style.display = '';
|
||||
document.getElementById('notePreviewBtn').style.display = 'none';
|
||||
}
|
||||
|
||||
function _showNoteEditMode() {
|
||||
document.getElementById('noteReadMode').style.display = 'none';
|
||||
document.getElementById('noteEditMode').style.display = '';
|
||||
document.getElementById('noteDeleteBtn').style.display = _editingNoteId ? '' : 'none';
|
||||
document.getElementById('noteCancelEditBtn').style.display = _editingNoteId ? '' : 'none';
|
||||
document.getElementById('noteEditBtn').style.display = 'none';
|
||||
document.getElementById('notePreviewBtn').style.display = '';
|
||||
}
|
||||
|
||||
function _showNotePreview() {
|
||||
// Live preview from textarea without saving
|
||||
const title = document.getElementById('noteEditorTitle').value.trim();
|
||||
const content = document.getElementById('noteEditorContent').value;
|
||||
const folderVal = document.getElementById('noteEditorFolder').value.trim();
|
||||
const tagsStr = document.getElementById('noteEditorTags').value.trim();
|
||||
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
|
||||
document.getElementById('noteReadTitle').textContent = title || 'Untitled';
|
||||
const parts = [];
|
||||
if (folderVal) parts.push(`<span class="note-folder">${esc(folderVal)}</span>`);
|
||||
tags.forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
|
||||
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
|
||||
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
|
||||
|
||||
document.getElementById('noteReadMode').style.display = '';
|
||||
document.getElementById('noteEditMode').style.display = 'none';
|
||||
document.getElementById('noteEditBtn').style.display = '';
|
||||
document.getElementById('notePreviewBtn').style.display = 'none';
|
||||
// Keep delete hidden in preview-from-edit
|
||||
document.getElementById('noteDeleteBtn2').style.display = 'none';
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
const title = document.getElementById('noteEditorTitle').value.trim();
|
||||
const content = document.getElementById('noteEditorContent').value;
|
||||
const folder = document.getElementById('noteEditorFolder').value.trim();
|
||||
const tagsStr = document.getElementById('noteEditorTags').value.trim();
|
||||
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
|
||||
if (!title) { UI.toast('Title is required', 'warning'); return; }
|
||||
if (!content) { UI.toast('Content is required', 'warning'); return; }
|
||||
|
||||
try {
|
||||
if (_editingNoteId) {
|
||||
const updated = await API.updateNote(_editingNoteId, { title, content, folder_path: folder, tags, mode: 'replace' });
|
||||
_currentNote = updated;
|
||||
UI.toast('Note updated', 'success');
|
||||
_showNoteReadMode();
|
||||
} else {
|
||||
const created = await API.createNote(title, content, folder, tags);
|
||||
_editingNoteId = created.id;
|
||||
_currentNote = created;
|
||||
UI.toast('Note created', 'success');
|
||||
_showNoteReadMode();
|
||||
}
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteNote() {
|
||||
if (!_editingNoteId) return;
|
||||
if (!confirm('Delete this note?')) return;
|
||||
try {
|
||||
await API.deleteNote(_editingNoteId);
|
||||
UI.toast('Note deleted', 'success');
|
||||
showNotesList();
|
||||
await loadNotesList();
|
||||
await loadNoteFolders();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Notes Listeners (extracted from initListeners) ──
|
||||
|
||||
function _initNotesListeners() {
|
||||
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
|
||||
document.getElementById('notesNewBtn')?.addEventListener('click', () => openNoteEditor(null));
|
||||
document.getElementById('notesSelectModeBtn')?.addEventListener('click', () => {
|
||||
if (_notesSelectMode) _exitSelectMode();
|
||||
else _enterSelectMode();
|
||||
});
|
||||
document.getElementById('notesBackBtn')?.addEventListener('click', () => { showNotesList(); loadNotesList(); loadNoteFolders(); });
|
||||
document.getElementById('noteSaveBtn')?.addEventListener('click', saveNote);
|
||||
document.getElementById('noteDeleteBtn')?.addEventListener('click', deleteNote);
|
||||
document.getElementById('noteDeleteBtn2')?.addEventListener('click', deleteNote);
|
||||
document.getElementById('noteEditBtn')?.addEventListener('click', _showNoteEditMode);
|
||||
document.getElementById('noteEditBtn2')?.addEventListener('click', _showNoteEditMode);
|
||||
document.getElementById('notePreviewBtn')?.addEventListener('click', _showNotePreview);
|
||||
document.getElementById('noteCancelEditBtn')?.addEventListener('click', () => {
|
||||
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }
|
||||
else showNotesList();
|
||||
});
|
||||
document.getElementById('notesFolderFilter')?.addEventListener('change', (e) => {
|
||||
document.getElementById('notesSearchInput').value = '';
|
||||
_exitSelectMode();
|
||||
loadNotesList(e.target.value);
|
||||
});
|
||||
document.getElementById('notesSortSelect')?.addEventListener('change', (e) => {
|
||||
_notesSort = e.target.value;
|
||||
_exitSelectMode();
|
||||
loadNotesList();
|
||||
});
|
||||
document.getElementById('notesSelectAll')?.addEventListener('change', (e) => {
|
||||
_toggleSelectAll(e.target.checked);
|
||||
});
|
||||
document.getElementById('notesDeleteSelectedBtn')?.addEventListener('click', _bulkDeleteSelected);
|
||||
document.getElementById('notesCancelSelectBtn')?.addEventListener('click', _exitSelectMode);
|
||||
|
||||
// Notes search with debounce
|
||||
let _notesSearchTimer;
|
||||
document.getElementById('notesSearchInput')?.addEventListener('input', (e) => {
|
||||
clearTimeout(_notesSearchTimer);
|
||||
const q = e.target.value.trim();
|
||||
_notesSearchTimer = setTimeout(() => {
|
||||
_exitSelectMode();
|
||||
if (q.length >= 2) {
|
||||
document.getElementById('notesFolderFilter').value = '';
|
||||
loadNotesList(null, q);
|
||||
} else if (q.length === 0) {
|
||||
loadNotesList();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
692
src/js/settings-handlers.js
Normal file
692
src/js/settings-handlers.js
Normal file
@@ -0,0 +1,692 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Settings Handlers
|
||||
// ==========================================
|
||||
// Settings save, provider CRUD, avatar, command palette,
|
||||
// user model roles.
|
||||
|
||||
// ── Settings ─────────────────────────────────
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const remote = await API.getSettings();
|
||||
if (remote && typeof remote === 'object') {
|
||||
if (remote.model) App.settings.model = remote.model;
|
||||
if (remote.system_prompt !== undefined) App.settings.systemPrompt = remote.system_prompt;
|
||||
if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens;
|
||||
if (remote.temperature !== undefined) App.settings.temperature = remote.temperature;
|
||||
}
|
||||
} catch (e) { console.warn('Settings load failed:', e.message); }
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
await API.updateSettings({
|
||||
model: App.settings.model,
|
||||
system_prompt: App.settings.systemPrompt,
|
||||
max_tokens: App.settings.maxTokens,
|
||||
temperature: App.settings.temperature,
|
||||
});
|
||||
} catch (e) { console.warn('Settings sync failed:', e.message); }
|
||||
}
|
||||
|
||||
// ── Avatar Preview ──────────────────────────
|
||||
function updateAvatarPreview(dataURI) {
|
||||
const preview = document.getElementById('avatarPreview');
|
||||
const letter = document.getElementById('avatarPreviewLetter');
|
||||
const removeBtn = document.getElementById('avatarRemoveBtn');
|
||||
const existingImg = preview.querySelector('img');
|
||||
|
||||
if (dataURI) {
|
||||
letter.style.display = 'none';
|
||||
if (existingImg) {
|
||||
existingImg.src = dataURI;
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.src = dataURI;
|
||||
img.alt = 'Avatar';
|
||||
preview.insertBefore(img, letter);
|
||||
}
|
||||
removeBtn.style.display = '';
|
||||
} else {
|
||||
if (existingImg) existingImg.remove();
|
||||
const name = API.user?.display_name || API.user?.username || '?';
|
||||
letter.textContent = name[0].toUpperCase();
|
||||
letter.style.display = '';
|
||||
removeBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings Handlers ────────────────────────
|
||||
|
||||
function handleSaveSettings() {
|
||||
App.settings.model = document.getElementById('settingsModel').value || App.settings.model;
|
||||
App.settings.systemPrompt = document.getElementById('settingsSystemPrompt').value.trim();
|
||||
App.settings.maxTokens = parseInt(document.getElementById('settingsMaxTokens').value) || 0; // 0 = auto
|
||||
App.settings.temperature = parseFloat(document.getElementById('settingsTemp').value) || 0.7;
|
||||
App.settings.showThinking = document.getElementById('settingsThinking').checked;
|
||||
saveSettings();
|
||||
UI.updateModelSelector();
|
||||
UI.updateCapabilityBadges();
|
||||
UI.closeSettings();
|
||||
UI.toast('Settings saved', 'success');
|
||||
}
|
||||
|
||||
async function handleChangePassword() {
|
||||
const cur = document.getElementById('profileCurrentPw').value;
|
||||
const pw = document.getElementById('profileNewPw').value;
|
||||
if (!cur || !pw) return UI.toast('Fill in both fields', 'warning');
|
||||
if (pw.length < 8) return UI.toast('Min 8 characters', 'warning');
|
||||
try {
|
||||
await API.changePassword(cur, pw);
|
||||
UI.toast('Password updated', 'success');
|
||||
document.getElementById('profileChangePwForm').style.display = 'none';
|
||||
document.getElementById('profileCurrentPw').value = '';
|
||||
document.getElementById('profileNewPw').value = '';
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function handleCreateProvider() {
|
||||
const name = document.getElementById('providerName').value.trim();
|
||||
const provider = document.getElementById('providerType').value;
|
||||
const endpoint = document.getElementById('providerEndpoint').value.trim();
|
||||
const apiKey = document.getElementById('providerApiKey').value.trim();
|
||||
const model = document.getElementById('providerDefaultModel').value.trim();
|
||||
|
||||
const editId = UI._editingProviderId;
|
||||
|
||||
if (editId) {
|
||||
// Update mode — api_key optional (blank = keep existing)
|
||||
if (!name || !endpoint) return UI.toast('Fill in required fields', 'warning');
|
||||
const patch = { name, provider, endpoint, model_default: model };
|
||||
if (apiKey) patch.api_key = apiKey;
|
||||
try {
|
||||
await API.updateConfig(editId, patch);
|
||||
UI.toast('Provider updated', 'success');
|
||||
UI._editingProviderId = null;
|
||||
document.getElementById('providerApiKey').placeholder = 'API key';
|
||||
UI.hideProviderForm();
|
||||
UI.loadProviderList();
|
||||
fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
} else {
|
||||
// Create mode
|
||||
if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning');
|
||||
try {
|
||||
const result = await API.createConfig(name, provider, endpoint, apiKey, model);
|
||||
if (result.warning) {
|
||||
UI.toast(`Provider added — ${result.warning}`, 'warning');
|
||||
} else {
|
||||
const count = result.models_fetched || 0;
|
||||
UI.toast(`Provider added — ${count} model${count !== 1 ? 's' : ''} synced`, 'success');
|
||||
}
|
||||
UI.hideProviderForm();
|
||||
UI.loadProviderList();
|
||||
fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProvider(id, name) {
|
||||
if (!confirm(`Remove provider "${name}"?`)) return;
|
||||
try {
|
||||
await API.deleteConfig(id);
|
||||
UI.toast('Provider removed', 'success');
|
||||
UI.loadProviderList();
|
||||
fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function refreshProviderModels(id, name) {
|
||||
try {
|
||||
UI.toast(`Fetching models for ${name}...`, 'info');
|
||||
const result = await API.fetchProviderModels(id);
|
||||
const total = result.total || 0;
|
||||
UI.toast(`${name}: ${total} model${total !== 1 ? 's' : ''} synced`, 'success');
|
||||
fetchModels(); // refresh the model selector
|
||||
} catch (e) {
|
||||
UI.toast(`Failed to fetch models: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function editProvider(id) {
|
||||
try {
|
||||
const cfg = await API.getConfig(id);
|
||||
// Populate the add form with existing values
|
||||
document.getElementById('providerName').value = cfg.name || '';
|
||||
document.getElementById('providerType').value = cfg.provider || 'openai';
|
||||
document.getElementById('providerEndpoint').value = cfg.endpoint || '';
|
||||
document.getElementById('providerApiKey').value = ''; // Don't expose key
|
||||
document.getElementById('providerApiKey').placeholder = cfg.has_key ? '(unchanged — leave blank to keep)' : 'API key';
|
||||
document.getElementById('providerDefaultModel').value = cfg.model_default || '';
|
||||
// Show form and switch to edit mode
|
||||
UI.showProviderForm();
|
||||
UI._editingProviderId = id;
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function handleSaveAdminSettings() {
|
||||
try {
|
||||
// Admin system prompt → global_settings (JSON value)
|
||||
const sysPromptContent = document.getElementById('adminSystemPrompt').value.trim();
|
||||
await API.adminUpdateSetting('system_prompt', { value: { content: sysPromptContent } });
|
||||
|
||||
// Policies — send as string "true"/"false" so backend routes to platform_policies
|
||||
const reg = document.getElementById('adminRegToggle').checked;
|
||||
await API.adminUpdateSetting('allow_registration', { value: reg ? 'true' : 'false' });
|
||||
|
||||
// Registration default state → default_user_active policy
|
||||
const regState = document.getElementById('adminRegDefaultState').value;
|
||||
await API.adminUpdateSetting('default_user_active', { value: regState === 'active' ? 'true' : 'false' });
|
||||
|
||||
// User BYOK providers → allow_user_byok policy
|
||||
const userProviders = document.getElementById('adminUserProvidersToggle').checked;
|
||||
await API.adminUpdateSetting('allow_user_byok', { value: userProviders ? 'true' : 'false' });
|
||||
|
||||
// User presets → allow_user_personas policy
|
||||
const userPresets = document.getElementById('adminUserPresetsToggle').checked;
|
||||
await API.adminUpdateSetting('allow_user_personas', { value: userPresets ? 'true' : 'false' });
|
||||
|
||||
// Default model → default_model policy
|
||||
const defaultModel = document.getElementById('adminDefaultModel').value;
|
||||
await API.adminUpdateSetting('default_model', { value: defaultModel });
|
||||
|
||||
// Banner → global_settings (JSON value)
|
||||
const banner = {
|
||||
enabled: document.getElementById('adminBannerEnabled').checked,
|
||||
text: document.getElementById('adminBannerText').value,
|
||||
position: document.getElementById('adminBannerPosition').value,
|
||||
bg: document.getElementById('adminBannerBg').value,
|
||||
fg: document.getElementById('adminBannerFg').value,
|
||||
};
|
||||
await API.adminUpdateSetting('banner', { value: banner });
|
||||
|
||||
UI.toast('Settings saved', 'success');
|
||||
// Live-apply: refresh policies and dependent UI
|
||||
await initBanners();
|
||||
UI.checkUserProvidersAllowed();
|
||||
UI.checkUserPresetsAllowed();
|
||||
fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── User Model Role Functions ─────────────────
|
||||
|
||||
async function userRoleProviderChanged(role) {
|
||||
const provSel = document.getElementById(`userRole-${role}-provider`);
|
||||
const modelSel = document.getElementById(`userRole-${role}-model`);
|
||||
if (!provSel || !modelSel) return;
|
||||
|
||||
const providerId = provSel.value;
|
||||
modelSel.innerHTML = '<option value="">— select model —</option>';
|
||||
if (!providerId) return;
|
||||
|
||||
// Load models for the selected provider (App.models uses configId, not provider_config_id)
|
||||
const allModels = App.models || [];
|
||||
const provModels = allModels.filter(m => m.configId === providerId);
|
||||
provModels.forEach(m => {
|
||||
modelSel.innerHTML += `<option value="${m.baseModelId}">${esc(m.name || m.baseModelId)}</option>`;
|
||||
});
|
||||
}
|
||||
|
||||
async function saveUserRole(role) {
|
||||
const provSel = document.getElementById(`userRole-${role}-provider`);
|
||||
const modelSel = document.getElementById(`userRole-${role}-model`);
|
||||
if (!provSel || !modelSel) return;
|
||||
|
||||
const providerId = provSel.value;
|
||||
const modelId = modelSel.value;
|
||||
|
||||
if (!providerId || !modelId) {
|
||||
UI.toast('Select both a provider and model', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await API.getSettings();
|
||||
const roles = settings.model_roles || {};
|
||||
roles[role] = { primary: { provider_config_id: providerId, model_id: modelId } };
|
||||
await API.updateSettings({ model_roles: roles });
|
||||
UI.toast(`${role} role override saved`, 'success');
|
||||
UI.loadUserRoles();
|
||||
} catch (e) {
|
||||
UI.toast(e.message || 'Failed to save role', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function clearUserRole(role) {
|
||||
try {
|
||||
const settings = await API.getSettings();
|
||||
const roles = settings.model_roles || {};
|
||||
delete roles[role];
|
||||
await API.updateSettings({ model_roles: Object.keys(roles).length > 0 ? roles : null });
|
||||
UI.toast(`${role} role override removed — using org default`, 'success');
|
||||
UI.loadUserRoles();
|
||||
} catch (e) {
|
||||
UI.toast(e.message || 'Failed to clear role', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command Palette ──────────────────────────
|
||||
|
||||
const _cmdCommands = [
|
||||
// Navigation
|
||||
{ id: 'new-chat', label: 'New Chat', group: 'Navigation', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>', action: () => newChat() },
|
||||
{ id: 'search-chats', label: 'Search Chats', group: 'Navigation', hint: '⌘⇧S', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>', action: () => { const sb = document.getElementById('sidebar'); if (sb.classList.contains('collapsed')) UI.toggleSidebar(); document.getElementById('chatSearchInput')?.focus(); } },
|
||||
{ id: 'toggle-sidebar', label: 'Toggle Sidebar', group: 'Navigation', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>', action: () => UI.toggleSidebar() },
|
||||
|
||||
// Tools
|
||||
{ id: 'notes', label: 'Open Notes', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>', action: () => openNotes() },
|
||||
{ id: 'export-md', label: 'Export Chat (Markdown)', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', action: () => UI.exportChat('md') },
|
||||
{ id: 'export-json', label: 'Export Chat (JSON)', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', action: () => UI.exportChat('json') },
|
||||
|
||||
// Settings
|
||||
{ id: 'settings', label: 'Settings', group: 'Settings', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>', action: () => UI.openSettings() },
|
||||
{ id: 'admin', label: 'Admin Panel', group: 'Settings', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>', action: () => UI.openAdmin(), visible: () => API.user?.role === 'admin' },
|
||||
{ id: 'debug', label: 'Debug Log', group: 'Settings', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 11V6a2 2 0 0 0-2-2h-1a2 2 0 0 0-2 2"/><path d="M9 6a2 2 0 0 0-2 2v3"/><circle cx="12" cy="14" r="6"/></svg>', action: () => openDebugModal() },
|
||||
|
||||
// Account
|
||||
{ id: 'sign-out', label: 'Sign Out', group: 'Account', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>', action: () => handleLogout() },
|
||||
];
|
||||
|
||||
let _cmdActiveIndex = 0;
|
||||
|
||||
function toggleCmdPalette() {
|
||||
const el = document.getElementById('cmdPalette');
|
||||
if (el.classList.contains('active')) { closeCmdPalette(); return; }
|
||||
openCmdPalette();
|
||||
}
|
||||
|
||||
function openCmdPalette() {
|
||||
const el = document.getElementById('cmdPalette');
|
||||
const input = document.getElementById('cmdInput');
|
||||
el.classList.add('active');
|
||||
input.value = '';
|
||||
_cmdActiveIndex = 0;
|
||||
_renderCmdResults('');
|
||||
input.focus();
|
||||
|
||||
// Wire up input events (use named handler to avoid duplicates)
|
||||
input.oninput = () => { _cmdActiveIndex = 0; _renderCmdResults(input.value); };
|
||||
input.onkeydown = _handleCmdKey;
|
||||
|
||||
// Close on overlay click
|
||||
el.onclick = (e) => { if (e.target === el) closeCmdPalette(); };
|
||||
}
|
||||
|
||||
function closeCmdPalette() {
|
||||
const el = document.getElementById('cmdPalette');
|
||||
el.classList.remove('active');
|
||||
document.getElementById('cmdInput').oninput = null;
|
||||
document.getElementById('cmdInput').onkeydown = null;
|
||||
}
|
||||
|
||||
function _handleCmdKey(e) {
|
||||
const results = document.getElementById('cmdResults');
|
||||
const items = results.querySelectorAll('.cmd-item');
|
||||
if (!items.length) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
_cmdActiveIndex = (_cmdActiveIndex + 1) % items.length;
|
||||
_highlightCmdItem(items);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
_cmdActiveIndex = (_cmdActiveIndex - 1 + items.length) % items.length;
|
||||
_highlightCmdItem(items);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
items[_cmdActiveIndex]?.click();
|
||||
}
|
||||
}
|
||||
|
||||
function _highlightCmdItem(items) {
|
||||
items.forEach((it, i) => it.classList.toggle('active', i === _cmdActiveIndex));
|
||||
items[_cmdActiveIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function _getVisibleCommands() {
|
||||
return _cmdCommands.filter(c => !c.visible || c.visible());
|
||||
}
|
||||
|
||||
function _renderCmdResults(query) {
|
||||
const el = document.getElementById('cmdResults');
|
||||
const q = query.trim().toLowerCase();
|
||||
let commands = _getVisibleCommands();
|
||||
|
||||
// Add recent chats as commands when searching
|
||||
if (q) {
|
||||
const chatMatches = App.chats
|
||||
.filter(c => (c.title || '').toLowerCase().includes(q))
|
||||
.slice(0, 5)
|
||||
.map(c => ({
|
||||
id: 'chat-' + c.id,
|
||||
label: c.title || 'Untitled',
|
||||
group: 'Chats',
|
||||
hint: _relativeTime(c.updatedAt),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>',
|
||||
action: () => selectChat(c.id),
|
||||
}));
|
||||
commands = [...chatMatches, ...commands];
|
||||
}
|
||||
|
||||
// Filter by query
|
||||
if (q) {
|
||||
commands = commands.filter(c => c.label.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
if (commands.length === 0) {
|
||||
el.innerHTML = '<div class="cmd-group-label" style="padding:16px 12px;text-transform:none;letter-spacing:0">No matching commands</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Clamp active index
|
||||
_cmdActiveIndex = Math.min(_cmdActiveIndex, commands.length - 1);
|
||||
|
||||
// Render grouped
|
||||
let html = '';
|
||||
let lastGroup = '';
|
||||
commands.forEach((c, i) => {
|
||||
if (c.group !== lastGroup) {
|
||||
lastGroup = c.group;
|
||||
html += `<div class="cmd-group-label">${c.group}</div>`;
|
||||
}
|
||||
html += `<div class="cmd-item${i === _cmdActiveIndex ? ' active' : ''}" data-cmd="${c.id}">
|
||||
${c.icon}
|
||||
<span class="cmd-item-label">${c.label}</span>
|
||||
${c.hint ? `<span class="cmd-item-hint">${c.hint}</span>` : ''}
|
||||
</div>`;
|
||||
});
|
||||
el.innerHTML = html;
|
||||
|
||||
// Attach click handlers
|
||||
el.querySelectorAll('.cmd-item').forEach((item, i) => {
|
||||
item.addEventListener('click', () => {
|
||||
closeCmdPalette();
|
||||
commands[i].action();
|
||||
});
|
||||
item.addEventListener('mouseenter', () => {
|
||||
_cmdActiveIndex = i;
|
||||
_highlightCmdItem(el.querySelectorAll('.cmd-item'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Settings Listeners (extracted from initListeners) ──
|
||||
|
||||
function _initSettingsListeners() {
|
||||
// Settings modal
|
||||
document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings);
|
||||
document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings);
|
||||
document.querySelectorAll('.settings-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => UI.switchSettingsTab(tab.dataset.stab));
|
||||
});
|
||||
document.getElementById('settingsModel').addEventListener('change', function() {
|
||||
const model = App.findModel(this.value);
|
||||
const caps = model?.capabilities || {};
|
||||
const hint = document.getElementById('settingsMaxHint');
|
||||
if (hint) {
|
||||
hint.textContent = caps.max_output_tokens > 0
|
||||
? `(model max: ${(caps.max_output_tokens / 1000).toFixed(0)}K)`
|
||||
: '';
|
||||
}
|
||||
});
|
||||
document.getElementById('profileChangePwBtn').addEventListener('click', () => {
|
||||
document.getElementById('profileChangePwForm').style.display = '';
|
||||
});
|
||||
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
|
||||
|
||||
// Avatar upload
|
||||
document.getElementById('avatarUploadBtn').addEventListener('click', () => {
|
||||
document.getElementById('avatarFileInput').click();
|
||||
});
|
||||
document.getElementById('avatarFileInput').addEventListener('change', async function() {
|
||||
const file = this.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const data = await API.uploadAvatar(reader.result);
|
||||
if (data.avatar) {
|
||||
API.user.avatar = data.avatar;
|
||||
API.saveTokens();
|
||||
updateAvatarPreview(data.avatar);
|
||||
UI.updateUser();
|
||||
UI.toast('Avatar updated');
|
||||
}
|
||||
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
this.value = '';
|
||||
});
|
||||
document.getElementById('avatarRemoveBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
await API.deleteAvatar();
|
||||
API.user.avatar = null;
|
||||
API.saveTokens();
|
||||
updateAvatarPreview(null);
|
||||
UI.updateUser();
|
||||
UI.toast('Avatar removed');
|
||||
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
|
||||
});
|
||||
|
||||
document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm);
|
||||
document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm);
|
||||
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
|
||||
document.getElementById('providerType').addEventListener('change', function() {
|
||||
const endpoints = {
|
||||
openai: 'https://api.openai.com/v1',
|
||||
anthropic: 'https://api.anthropic.com',
|
||||
venice: 'https://api.venice.ai/api/v1',
|
||||
openrouter: 'https://openrouter.ai/api/v1',
|
||||
};
|
||||
const ep = document.getElementById('providerEndpoint');
|
||||
if (!ep.value || Object.values(endpoints).includes(ep.value)) {
|
||||
ep.value = endpoints[this.value] || '';
|
||||
}
|
||||
});
|
||||
|
||||
// Settings — Team management (team admin self-service)
|
||||
document.getElementById('settingsTeamAddMemberBtn')?.addEventListener('click', async () => {
|
||||
document.getElementById('settingsTeamAddMember').style.display = '';
|
||||
const sel = document.getElementById('settingsTeamMemberUser');
|
||||
sel.innerHTML = '<option value="">Loading...</option>';
|
||||
try {
|
||||
const resp = await API.teamListMembers(UI._managingTeamId);
|
||||
const existingIds = new Set((resp.data || []).map(m => m.user_id));
|
||||
sel.innerHTML = '<option value="">Enter user ID or ask sys-admin</option>';
|
||||
} catch (e) { sel.innerHTML = '<option value="">Error</option>'; }
|
||||
});
|
||||
document.getElementById('settingsTeamCancelMember')?.addEventListener('click', () => {
|
||||
document.getElementById('settingsTeamAddMember').style.display = 'none';
|
||||
});
|
||||
var _teamPresetForm = null;
|
||||
document.getElementById('settingsTeamAddPresetBtn')?.addEventListener('click', () => {
|
||||
const container = document.getElementById('settingsTeamAddPreset');
|
||||
container.style.display = '';
|
||||
if (!_teamPresetForm) {
|
||||
_teamPresetForm = renderPresetForm(container, {
|
||||
prefix: 'teamPreset',
|
||||
showAvatar: true,
|
||||
showProviderConfig: false,
|
||||
onSubmit: async (vals) => {
|
||||
const teamId = UI._managingTeamId;
|
||||
if (!vals.name) return UI.toast('Name required', 'error');
|
||||
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
|
||||
try {
|
||||
const result = await API.teamCreatePreset(teamId, vals);
|
||||
const presetId = result?.id;
|
||||
if (presetId && vals._pendingAvatar) {
|
||||
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
|
||||
catch (e) { console.warn('Team preset avatar upload failed:', e.message); }
|
||||
}
|
||||
container.style.display = 'none';
|
||||
_teamPresetForm.clearForm();
|
||||
UI.toast('Team preset created');
|
||||
await UI.loadTeamManagePresets(teamId);
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
onCancel: () => { container.style.display = 'none'; _teamPresetForm.clearForm(); }
|
||||
});
|
||||
} else {
|
||||
_teamPresetForm.clearForm();
|
||||
}
|
||||
UI.loadTeamPresetModelDropdown(UI._managingTeamId);
|
||||
});
|
||||
|
||||
// Team — providers
|
||||
const defaultEndpoints = {
|
||||
openai: 'https://api.openai.com/v1',
|
||||
anthropic: 'https://api.anthropic.com',
|
||||
venice: 'https://api.venice.ai/api/v1',
|
||||
openrouter: 'https://openrouter.ai/api/v1',
|
||||
};
|
||||
document.getElementById('settingsTeamAddProviderBtn')?.addEventListener('click', () => {
|
||||
const form = document.getElementById('settingsTeamAddProvider');
|
||||
form.style.display = form.style.display === 'none' ? '' : 'none';
|
||||
const sel = document.getElementById('teamProviderType');
|
||||
if (sel && sel.options.length === 0) {
|
||||
['openai', 'anthropic', 'venice', 'openrouter'].forEach(p => {
|
||||
const labels = { openai: 'OpenAI-compatible', anthropic: 'Anthropic', venice: 'Venice.ai', openrouter: 'OpenRouter' };
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p;
|
||||
opt.textContent = labels[p] || p;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
});
|
||||
document.getElementById('teamProviderType')?.addEventListener('change', function() {
|
||||
const ep = document.getElementById('teamProviderEndpoint');
|
||||
if (!ep.value || Object.values(defaultEndpoints).includes(ep.value)) {
|
||||
ep.value = defaultEndpoints[this.value] || '';
|
||||
}
|
||||
});
|
||||
document.getElementById('settingsTeamCancelProvider')?.addEventListener('click', () => {
|
||||
document.getElementById('settingsTeamAddProvider').style.display = 'none';
|
||||
});
|
||||
document.getElementById('settingsTeamAddProviderSubmit')?.addEventListener('click', async () => {
|
||||
const teamId = UI._managingTeamId;
|
||||
const name = document.getElementById('teamProviderName').value.trim();
|
||||
const provider = document.getElementById('teamProviderType').value;
|
||||
const endpoint = document.getElementById('teamProviderEndpoint').value.trim();
|
||||
const apiKey = document.getElementById('teamProviderKey').value;
|
||||
const isPrivate = document.getElementById('teamProviderPrivate').checked;
|
||||
if (!name) return UI.toast('Name required', 'error');
|
||||
if (!endpoint) return UI.toast('Endpoint required', 'error');
|
||||
try {
|
||||
await API.teamCreateProvider(teamId, { name, provider, endpoint, api_key: apiKey, is_private: isPrivate });
|
||||
document.getElementById('settingsTeamAddProvider').style.display = 'none';
|
||||
document.getElementById('teamProviderName').value = '';
|
||||
document.getElementById('teamProviderEndpoint').value = '';
|
||||
document.getElementById('teamProviderKey').value = '';
|
||||
document.getElementById('teamProviderPrivate').checked = false;
|
||||
UI.toast('Provider added');
|
||||
await UI.loadTeamManageProviders(teamId);
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
// Team — edit provider
|
||||
document.getElementById('settingsTeamCancelEditProvider')?.addEventListener('click', () => {
|
||||
document.getElementById('settingsTeamEditProvider').style.display = 'none';
|
||||
});
|
||||
document.getElementById('settingsTeamEditProviderSubmit')?.addEventListener('click', async () => {
|
||||
const teamId = UI._managingTeamId;
|
||||
const provId = UI._editingTeamProviderId;
|
||||
if (!provId) return;
|
||||
const updates = {};
|
||||
const name = document.getElementById('teamProviderEditName').value.trim();
|
||||
const endpoint = document.getElementById('teamProviderEditEndpoint').value.trim();
|
||||
const apiKey = document.getElementById('teamProviderEditKey').value;
|
||||
const defaultModel = document.getElementById('teamProviderEditDefault').value.trim();
|
||||
const isPrivate = document.getElementById('teamProviderEditPrivate').checked;
|
||||
if (name) updates.name = name;
|
||||
if (endpoint) updates.endpoint = endpoint;
|
||||
if (apiKey) updates.api_key = apiKey;
|
||||
if (defaultModel) updates.model_default = defaultModel;
|
||||
updates.is_private = isPrivate;
|
||||
try {
|
||||
await API.teamUpdateProvider(teamId, provId, updates);
|
||||
document.getElementById('settingsTeamEditProvider').style.display = 'none';
|
||||
UI.toast('Provider updated');
|
||||
await UI.loadTeamManageProviders(teamId);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
// User — personal presets
|
||||
var _userPresetForm = null;
|
||||
document.getElementById('userAddPresetBtn')?.addEventListener('click', () => {
|
||||
const container = document.getElementById('userAddPresetForm');
|
||||
container.style.display = container.style.display === 'none' ? '' : 'none';
|
||||
if (!_userPresetForm) {
|
||||
_userPresetForm = renderPresetForm(container, {
|
||||
prefix: 'userPreset',
|
||||
showAvatar: true,
|
||||
showProviderConfig: false,
|
||||
onSubmit: async (vals) => {
|
||||
if (!vals.name) return UI.toast('Name required', 'error');
|
||||
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
|
||||
try {
|
||||
const result = await API.createUserPreset(vals);
|
||||
const presetId = result?.id;
|
||||
if (presetId && vals._pendingAvatar) {
|
||||
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
|
||||
catch (e) { console.warn('User preset avatar upload failed:', e.message); }
|
||||
}
|
||||
container.style.display = 'none';
|
||||
_userPresetForm.clearForm();
|
||||
UI.toast('Preset created');
|
||||
await UI.loadUserPresets();
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
onCancel: () => { container.style.display = 'none'; _userPresetForm.clearForm(); }
|
||||
});
|
||||
}
|
||||
const sel = _userPresetForm.getModelSelect();
|
||||
if (sel) {
|
||||
sel.innerHTML = '<option value="">Select base model...</option>';
|
||||
App.models.filter(m => !m.isPreset).forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.baseModelId || m.id;
|
||||
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Admin — banner controls
|
||||
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
|
||||
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
|
||||
});
|
||||
document.getElementById('adminBannerPreset')?.addEventListener('change', (e) => {
|
||||
const preset = UI._bannerPresets[e.target.value];
|
||||
if (preset) {
|
||||
document.getElementById('adminBannerText').value = preset.text || '';
|
||||
document.getElementById('adminBannerBg').value = preset.bg || '#007a33';
|
||||
document.getElementById('adminBannerBgHex').value = preset.bg || '#007a33';
|
||||
document.getElementById('adminBannerFg').value = preset.fg || '#ffffff';
|
||||
document.getElementById('adminBannerFgHex').value = preset.fg || '#ffffff';
|
||||
UI.updateBannerPreview();
|
||||
}
|
||||
});
|
||||
['adminBannerText', 'adminBannerBg', 'adminBannerFg'].forEach(id => {
|
||||
document.getElementById(id)?.addEventListener('input', UI.updateBannerPreview);
|
||||
});
|
||||
document.getElementById('adminBannerBg')?.addEventListener('input', (e) => { document.getElementById('adminBannerBgHex').value = e.target.value; });
|
||||
document.getElementById('adminBannerFg')?.addEventListener('input', (e) => { document.getElementById('adminBannerFgHex').value = e.target.value; });
|
||||
document.getElementById('adminBannerBgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerBg').value = e.target.value; UI.updateBannerPreview(); }});
|
||||
document.getElementById('adminBannerFgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerFg').value = e.target.value; UI.updateBannerPreview(); }});
|
||||
|
||||
// Admin — audit log
|
||||
document.getElementById('auditRefreshBtn')?.addEventListener('click', () => UI.loadAuditLog(1));
|
||||
document.getElementById('auditFilterAction')?.addEventListener('change', () => UI.loadAuditLog(1));
|
||||
document.getElementById('auditFilterResource')?.addEventListener('change', () => UI.loadAuditLog(1));
|
||||
document.getElementById('auditPrevBtn')?.addEventListener('click', () => { if (UI._auditPage > 1) UI.loadAuditLog(UI._auditPage - 1); });
|
||||
document.getElementById('auditNextBtn')?.addEventListener('click', () => UI.loadAuditLog(UI._auditPage + 1));
|
||||
|
||||
// Admin — usage
|
||||
document.getElementById('usageRefreshBtn')?.addEventListener('click', () => UI.loadAdminUsage());
|
||||
document.getElementById('usagePeriod')?.addEventListener('change', () => UI.loadAdminUsage());
|
||||
document.getElementById('usageGroupBy')?.addEventListener('change', () => UI.loadAdminUsage());
|
||||
}
|
||||
123
src/js/tokens.js
Normal file
123
src/js/tokens.js
Normal file
@@ -0,0 +1,123 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Token Estimation
|
||||
// ==========================================
|
||||
// Context tracking, token estimation, and context warning.
|
||||
|
||||
// ── 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;
|
||||
},
|
||||
|
||||
// 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 input = document.getElementById('messageInput');
|
||||
const inputText = input?.value || '';
|
||||
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.currentChatId);
|
||||
const convTokens = Tokens.estimateConversation(chat?.messages || [], App.settings.systemPrompt);
|
||||
const totalWithInput = convTokens + inputTokens;
|
||||
const pct = totalWithInput / budget.maxContext;
|
||||
el.textContent = `~${Tokens.format(inputTokens)} tokens · ${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.currentChatId);
|
||||
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';
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
645
src/js/ui-admin.js
Normal file
645
src/js/ui-admin.js
Normal file
@@ -0,0 +1,645 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – UI Admin
|
||||
// ==========================================
|
||||
// Extends UI with admin modal tabs: users, stats, roles,
|
||||
// usage, audit, providers, models, presets, teams, settings.
|
||||
|
||||
Object.assign(UI, {
|
||||
// ── Admin Modal ──────────────────────────
|
||||
|
||||
openAdmin() { openModal('adminModal'); UI.switchAdminTab('users'); },
|
||||
closeAdmin() { closeModal('adminModal'); },
|
||||
|
||||
openTeamAdmin() {
|
||||
const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin');
|
||||
if (adminTeams.length === 0) {
|
||||
UI.toast('You are not an admin of any teams', 'error');
|
||||
return;
|
||||
}
|
||||
openModal('teamAdminModal');
|
||||
if (adminTeams.length === 1) {
|
||||
// Skip picker, go directly to the team
|
||||
UI.openTeamManage(adminTeams[0].id, adminTeams[0].name);
|
||||
} else {
|
||||
// Show team picker
|
||||
document.getElementById('teamAdminTitle').textContent = 'Team Management';
|
||||
document.getElementById('teamAdminPicker').style.display = '';
|
||||
document.getElementById('teamAdminContent').style.display = 'none';
|
||||
document.getElementById('teamAdminPickerList').innerHTML = adminTeams.map(t => `
|
||||
<div class="team-card" style="cursor:pointer" onclick="UI.openTeamManage('${t.id}', '${esc(t.name)}')">
|
||||
<div class="team-card-info">
|
||||
<strong>${esc(t.name)}</strong>
|
||||
<span class="badge-admin">admin</span>
|
||||
<span style="margin-left:auto;color:var(--text-3);font-size:12px">Manage →</span>
|
||||
</div>
|
||||
${t.description ? `<div class="text-muted" style="font-size:12px">${esc(t.description)}</div>` : ''}
|
||||
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
},
|
||||
closeTeamAdmin() { closeModal('teamAdminModal'); },
|
||||
|
||||
switchTeamTab(tab) {
|
||||
document.querySelectorAll('#teamAdminTabs .admin-tab').forEach(t => t.classList.toggle('active', t.dataset.ttab === tab));
|
||||
document.querySelectorAll('.team-tab-content').forEach(c => c.style.display = 'none');
|
||||
const panel = document.getElementById(`teamTab${tab.charAt(0).toUpperCase() + tab.slice(1)}`);
|
||||
if (panel) panel.style.display = '';
|
||||
|
||||
// Lazy-load tab data
|
||||
const teamId = UI._managingTeamId;
|
||||
if (tab === 'members') UI.loadTeamManageMembers(teamId);
|
||||
if (tab === 'providers') UI.loadTeamManageProviders(teamId);
|
||||
if (tab === 'presets') UI.loadTeamManagePresets(teamId);
|
||||
if (tab === 'usage') UI.loadTeamUsage();
|
||||
if (tab === 'activity') UI.loadTeamAuditLog(1);
|
||||
},
|
||||
|
||||
async switchAdminTab(tab) {
|
||||
document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
||||
document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none');
|
||||
const panel = document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
|
||||
if (panel) panel.style.display = '';
|
||||
|
||||
if (tab === 'users') await this.loadAdminUsers();
|
||||
if (tab === 'stats') await this.loadAdminStats();
|
||||
if (tab === 'audit') await this.loadAuditLog();
|
||||
if (tab === 'providers') await this.loadAdminProviders();
|
||||
if (tab === 'models') await this.loadAdminModels();
|
||||
if (tab === 'presets') await this.loadAdminPresets();
|
||||
if (tab === 'teams') await this.loadAdminTeams();
|
||||
if (tab === 'settings') await this.loadAdminSettings();
|
||||
if (tab === 'roles') await this.loadAdminRoles();
|
||||
if (tab === 'usage') await this.loadAdminUsage();
|
||||
},
|
||||
|
||||
async loadAdminUsers(quiet) {
|
||||
const el = document.getElementById('adminUserList');
|
||||
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const resp = await API.adminListUsers();
|
||||
const users = resp.users || resp.data || [];
|
||||
el.innerHTML = users.map(u => {
|
||||
const teamBadges = (u.teams || []).map(t =>
|
||||
`<span class="badge-team" title="${esc(t.role)}">${esc(t.team_name)}</span>`
|
||||
).join(' ');
|
||||
const isPending = !u.is_active;
|
||||
return `
|
||||
<div class="admin-user-row${isPending ? ' user-inactive' : ''}">
|
||||
<div class="admin-user-info">
|
||||
<div><strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span>
|
||||
${isPending ? '<span class="badge-pending">pending</span>' : ''}
|
||||
${teamBadges}</div>
|
||||
<div class="admin-user-email">${esc(u.email)}</div>
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
${isPending
|
||||
? `<button class="btn-approve" onclick="showApproveForm('${u.id}', '${esc(u.username)}')">Approve</button>`
|
||||
: `<button onclick="toggleUserActive('${u.id}', false)">Disable</button>`
|
||||
}
|
||||
<button onclick="toggleUserRole('${u.id}', '${u.role === 'admin' ? 'user' : 'admin'}')">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
|
||||
<button onclick="adminResetUserPassword('${u.id}', '${esc(u.username)}')">Reset PW</button>
|
||||
<button class="btn-danger" onclick="deleteUser('${u.id}', '${esc(u.username)}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-approve-form" id="approveForm-${u.id}" style="display:none">
|
||||
<div class="approve-teams" id="approveTeams-${u.id}"></div>
|
||||
<div class="form-row" style="margin-top:8px">
|
||||
<button class="btn-small btn-primary" onclick="submitApproval('${u.id}')">Activate & Assign</button>
|
||||
<button class="btn-small" onclick="hideApproveForm('${u.id}')">Cancel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No users</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadAdminStats() {
|
||||
const el = document.getElementById('adminStats');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const s = await API.adminGetStats();
|
||||
const labels = { users: 'Users', channels: 'Channels', messages: 'Messages' };
|
||||
el.innerHTML = '<div class="stats-grid">' +
|
||||
Object.entries(s).map(([k, v]) => `
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${typeof v === 'number' ? v.toLocaleString() : esc(String(v))}</div>
|
||||
<div class="stat-label">${labels[k] || k.replace(/_/g, ' ')}</div>
|
||||
</div>`).join('') +
|
||||
'</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
// ── Admin: Roles ────────────────────────
|
||||
|
||||
async loadAdminRoles() {
|
||||
const el = document.getElementById('adminRolesContent');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const roles = await API.adminListRoles();
|
||||
const configs = await API.adminListGlobalConfigs();
|
||||
const providers = configs.configs || configs || [];
|
||||
const models = await API.adminListModels();
|
||||
const modelList = models.models || models.data || models || [];
|
||||
|
||||
const roleNames = ['utility', 'embedding'];
|
||||
el.innerHTML = roleNames.map(role => {
|
||||
const cfg = roles[role] || { primary: null, fallback: null };
|
||||
return `
|
||||
<div class="settings-section" style="margin-bottom:16px">
|
||||
<h3 style="font-size:14px;margin:0 0 8px;text-transform:capitalize">${role}</h3>
|
||||
<div class="form-row" style="gap:8px;align-items:center">
|
||||
<label style="min-width:60px;font-size:12px">Primary</label>
|
||||
<select id="role-${role}-primary-provider" onchange="adminRoleProviderChanged('${role}','primary')" style="min-width:140px">
|
||||
<option value="">— none —</option>
|
||||
${providers.map(p => `<option value="${p.id}" ${cfg.primary?.provider_config_id === p.id ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
|
||||
</select>
|
||||
<select id="role-${role}-primary-model" style="min-width:200px">
|
||||
<option value="">— select model —</option>
|
||||
${cfg.primary ? modelList.filter(m => m.provider_config_id === cfg.primary.provider_config_id).map(m => `<option value="${m.model_id}" ${m.model_id === cfg.primary.model_id ? 'selected' : ''}>${esc(m.display_name || m.model_id)}</option>`).join('') : ''}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" style="gap:8px;align-items:center;margin-top:6px">
|
||||
<label style="min-width:60px;font-size:12px">Fallback</label>
|
||||
<select id="role-${role}-fallback-provider" onchange="adminRoleProviderChanged('${role}','fallback')" style="min-width:140px">
|
||||
<option value="">— none —</option>
|
||||
${providers.map(p => `<option value="${p.id}" ${cfg.fallback?.provider_config_id === p.id ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
|
||||
</select>
|
||||
<select id="role-${role}-fallback-model" style="min-width:200px">
|
||||
<option value="">— select model —</option>
|
||||
${cfg.fallback ? modelList.filter(m => m.provider_config_id === cfg.fallback.provider_config_id).map(m => `<option value="${m.model_id}" ${m.model_id === cfg.fallback.model_id ? 'selected' : ''}>${esc(m.display_name || m.model_id)}</option>`).join('') : ''}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" style="gap:8px;margin-top:8px">
|
||||
<button class="btn-primary btn-small" onclick="adminSaveRole('${role}')">Save</button>
|
||||
<button class="btn-secondary btn-small" onclick="adminTestRole('${role}')">Test</button>
|
||||
<span id="role-${role}-status" style="font-size:12px"></span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Store models list for provider change handler
|
||||
window._adminModelList = modelList;
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
// ── Admin: Usage ────────────────────────
|
||||
|
||||
async loadAdminUsage() {
|
||||
const period = document.getElementById('usagePeriod')?.value || '30d';
|
||||
const groupBy = document.getElementById('usageGroupBy')?.value || 'model';
|
||||
|
||||
const totalsEl = document.getElementById('adminUsageTotals');
|
||||
const resultsEl = document.getElementById('adminUsageResults');
|
||||
const pricingEl = document.getElementById('adminPricingTable');
|
||||
if (!totalsEl) return;
|
||||
|
||||
totalsEl.innerHTML = '<div class="loading">Loading...</div>';
|
||||
resultsEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
const data = await API.adminGetUsage({ period, group_by: groupBy });
|
||||
const t = data.totals || {};
|
||||
|
||||
totalsEl.innerHTML = `
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="stat-value">${(t.requests || 0).toLocaleString()}</div><div class="stat-label">Requests</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label">Input Tokens</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label">Output Tokens</div></div>
|
||||
<div class="stat-card"><div class="stat-value">$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label">Est. Cost</div></div>
|
||||
</div>`;
|
||||
|
||||
const results = data.results || [];
|
||||
if (results.length === 0) {
|
||||
resultsEl.innerHTML = '<div class="empty-hint">No usage data for this period</div>';
|
||||
} else {
|
||||
resultsEl.innerHTML = `
|
||||
<table class="admin-table" style="margin-top:12px">
|
||||
<thead><tr>
|
||||
<th>${groupBy === 'day' ? 'Date' : groupBy === 'user' ? 'User' : 'Model'}</th>
|
||||
<th style="text-align:right">Requests</th>
|
||||
<th style="text-align:right">Input</th>
|
||||
<th style="text-align:right">Output</th>
|
||||
<th style="text-align:right">Cost</th>
|
||||
</tr></thead>
|
||||
<tbody>${results.map(r => `<tr>
|
||||
<td>${esc(r.label || r.group_key)}</td>
|
||||
<td style="text-align:right">${(r.requests || 0).toLocaleString()}</td>
|
||||
<td style="text-align:right">${(r.input_tokens || 0).toLocaleString()}</td>
|
||||
<td style="text-align:right">${(r.output_tokens || 0).toLocaleString()}</td>
|
||||
<td style="text-align:right">$${(r.total_cost || 0).toFixed(4)}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
} catch (e) { totalsEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
|
||||
// Load pricing table
|
||||
try {
|
||||
const pricing = await API.adminListPricing();
|
||||
const entries = pricing || [];
|
||||
if (entries.length === 0) {
|
||||
pricingEl.innerHTML = '<div class="empty-hint">No pricing configured. Sync providers to populate from catalog.</div>';
|
||||
} else {
|
||||
pricingEl.innerHTML = `
|
||||
<table class="admin-table" style="margin-top:8px">
|
||||
<thead><tr>
|
||||
<th>Model</th>
|
||||
<th style="text-align:right">Input $/M</th>
|
||||
<th style="text-align:right">Output $/M</th>
|
||||
<th>Source</th>
|
||||
</tr></thead>
|
||||
<tbody>${entries.map(p => `<tr>
|
||||
<td>${esc(p.model_id)}</td>
|
||||
<td style="text-align:right">${p.input_per_m != null ? '$' + Number(p.input_per_m).toFixed(4) : '—'}</td>
|
||||
<td style="text-align:right">${p.output_per_m != null ? '$' + Number(p.output_per_m).toFixed(4) : '—'}</td>
|
||||
<td><span class="badge-${p.source === 'manual' ? 'admin' : 'user'}">${p.source}</span></td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
} catch (e) { pricingEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
_auditPage: 1,
|
||||
_auditPerPage: 30,
|
||||
|
||||
async loadAuditLog(page) {
|
||||
if (page) UI._auditPage = page;
|
||||
const el = document.getElementById('adminAuditList');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
|
||||
// Populate action filter on first load
|
||||
const actionSel = document.getElementById('auditFilterAction');
|
||||
if (actionSel && actionSel.options.length <= 1) {
|
||||
try {
|
||||
const resp = await API.adminListAuditActions();
|
||||
(resp.actions || []).forEach(a => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a;
|
||||
opt.textContent = a;
|
||||
actionSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { /* optional */ }
|
||||
}
|
||||
|
||||
const params = {
|
||||
page: UI._auditPage,
|
||||
per_page: UI._auditPerPage,
|
||||
action: document.getElementById('auditFilterAction')?.value || '',
|
||||
resource_type: document.getElementById('auditFilterResource')?.value || '',
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await API.adminListAudit(params);
|
||||
const entries = resp.data || [];
|
||||
const total = resp.total || 0;
|
||||
const totalPages = Math.ceil(total / UI._auditPerPage);
|
||||
|
||||
if (entries.length === 0) {
|
||||
el.innerHTML = '<div class="empty-hint">No audit entries found</div>';
|
||||
} else {
|
||||
el.innerHTML = entries.map(e => {
|
||||
const ts = new Date(e.created_at);
|
||||
const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
|
||||
const actor = e.actor_name || 'system';
|
||||
const meta = UI._formatAuditMeta(e.metadata);
|
||||
return `<div class="audit-row">
|
||||
<div class="audit-main">
|
||||
<span class="audit-action">${esc(e.action)}</span>
|
||||
<span class="audit-actor">${esc(actor)}</span>
|
||||
<span class="audit-resource">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</span>
|
||||
</div>
|
||||
<div class="audit-meta">
|
||||
${meta ? `<span class="audit-details">${meta}</span>` : ''}
|
||||
<span class="audit-time">${timeStr}</span>
|
||||
${e.ip_address ? `<span class="audit-ip">${esc(e.ip_address)}</span>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const pgEl = document.getElementById('auditPagination');
|
||||
if (totalPages > 1) {
|
||||
pgEl.style.display = 'flex';
|
||||
document.getElementById('auditPageInfo').textContent = `Page ${UI._auditPage} of ${totalPages} (${total} entries)`;
|
||||
document.getElementById('auditPrevBtn').disabled = UI._auditPage <= 1;
|
||||
document.getElementById('auditNextBtn').disabled = UI._auditPage >= totalPages;
|
||||
} else {
|
||||
pgEl.style.display = 'none';
|
||||
}
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
_formatAuditMeta(metaStr) {
|
||||
try {
|
||||
const m = typeof metaStr === 'string' ? JSON.parse(metaStr) : metaStr;
|
||||
if (!m || Object.keys(m).length === 0) return '';
|
||||
return Object.entries(m).map(([k,v]) => `${esc(k)}=${esc(String(v))}`).join(' · ');
|
||||
} catch { return ''; }
|
||||
},
|
||||
|
||||
async loadAdminProviders(quiet) {
|
||||
const el = document.getElementById('adminProviderList');
|
||||
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const data = await API.adminListGlobalConfigs();
|
||||
const list = data.configs || data.data || data || [];
|
||||
const arr = Array.isArray(list) ? list : [];
|
||||
UI._providerCache = {};
|
||||
el.innerHTML = arr.map(c => {
|
||||
UI._providerCache[c.id] = c;
|
||||
return `<div class="admin-provider-row">
|
||||
<span class="provider-name">${esc(c.name)}</span>
|
||||
<span class="provider-meta">${esc(c.provider)}</span>
|
||||
${c.is_private ? '<span class="badge-private">private</span>' : ''}
|
||||
<span class="provider-endpoint">${esc(c.endpoint || '')}</span>
|
||||
<button class="btn-edit" onclick="editGlobalProvider('${c.id}')" title="Edit">✎</button>
|
||||
<button class="btn-delete" onclick="deleteGlobalProvider('${c.id}')" title="Delete">✕</button>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No global providers — add one above</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadAdminModels(quiet) {
|
||||
const el = document.getElementById('adminModelList');
|
||||
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const data = await API.adminListModels();
|
||||
const list = data.models || data.data || data || [];
|
||||
const arr = Array.isArray(list) ? list : [];
|
||||
el.innerHTML = arr.map(m => {
|
||||
const caps = m.capabilities || {};
|
||||
const badges = [];
|
||||
if (caps.max_output_tokens > 0) badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K</span>`);
|
||||
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
|
||||
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
|
||||
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
|
||||
// Support both old is_enabled (bool) and new visibility (string)
|
||||
const vis = m.visibility || (m.is_enabled === true ? 'enabled' : m.is_enabled === false ? 'disabled' : 'disabled');
|
||||
const visLabel = vis === 'enabled' ? '✓ Enabled' : vis === 'team' ? '👥 Team' : 'Disabled';
|
||||
const visClass = vis === 'enabled' ? 'enabled' : vis === 'team' ? 'team' : '';
|
||||
return `<div class="admin-model-row">
|
||||
<span class="model-name">${esc(m.model_id || m.id)}</span>
|
||||
<span class="model-caps-inline">${badges.join('')}</span>
|
||||
<span class="provider-meta">${esc(m.provider_name || '')}</span>
|
||||
<button class="admin-model-toggle ${visClass}" onclick="cycleModelVisibility('${m.id}', '${vis}')">${visLabel}</button>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No models — add a provider first, then click Fetch Models</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadAdminPresets(quiet) {
|
||||
const el = document.getElementById('adminPresetList');
|
||||
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const data = await API.adminListPresets();
|
||||
const list = data.presets || [];
|
||||
|
||||
// Ensure form is initialized for dropdown population
|
||||
ensureAdminPresetForm();
|
||||
|
||||
// Populate model dropdown from admin model list (all synced models)
|
||||
const modelSel = _adminPresetForm?.getModelSelect();
|
||||
if (modelSel) {
|
||||
modelSel.innerHTML = '<option value="">Select base model...</option>';
|
||||
try {
|
||||
const modelData = await API.adminListModels();
|
||||
const allModels = modelData.models || modelData.data || [];
|
||||
const arr = Array.isArray(allModels) ? allModels : [];
|
||||
arr.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
const mid = m.model_id || m.id;
|
||||
opt.value = mid;
|
||||
opt.textContent = mid + (m.provider_name ? ` (${m.provider_name})` : '');
|
||||
modelSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load models for preset form:', e.message); }
|
||||
}
|
||||
|
||||
// Populate config dropdown
|
||||
const cfgSel = _adminPresetForm?.getConfigSelect();
|
||||
if (cfgSel && cfgSel.options.length <= 1) {
|
||||
try {
|
||||
const cfgData = await API.adminListGlobalConfigs();
|
||||
const cfgs = cfgData.configs || cfgData.data || [];
|
||||
(Array.isArray(cfgs) ? cfgs : []).forEach(c => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.id;
|
||||
opt.textContent = c.name + ' (' + c.provider + ')';
|
||||
cfgSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { /* optional */ }
|
||||
}
|
||||
|
||||
UI._presetCache = {};
|
||||
el.innerHTML = list.map(p => {
|
||||
UI._presetCache[p.id] = p;
|
||||
const avatarEl = p.avatar
|
||||
? `<img src="${p.avatar}" class="preset-row-avatar" alt="">`
|
||||
: '';
|
||||
// Scope badge with attribution
|
||||
let scopeBadge = '';
|
||||
if (p.scope === 'global') {
|
||||
scopeBadge = '<span class="badge-admin">global</span>';
|
||||
} else if (p.scope === 'team' && p.team_name) {
|
||||
scopeBadge = `<span class="badge-team">👥 ${esc(p.team_name)}</span>`;
|
||||
} else if (p.scope === 'personal') {
|
||||
scopeBadge = '<span class="badge-user">personal</span>';
|
||||
}
|
||||
const creator = p.creator_name ? `<span class="text-muted" style="font-size:11px">by ${esc(p.creator_name)}</span>` : '';
|
||||
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
|
||||
return `<div class="admin-preset-row">
|
||||
<div class="preset-info">
|
||||
<strong>${avatarEl}${esc(p.name)}</strong> ${scopeBadge} ${creator} ${status}
|
||||
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
|
||||
${p.description ? `<div class="preset-desc">${esc(p.description)}</div>` : ''}
|
||||
</div>
|
||||
<div class="preset-actions">
|
||||
<button class="btn-edit" onclick="editAdminPreset('${p.id}')" title="Edit">✎</button>
|
||||
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="toggleAdminPreset('${p.id}', ${!p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
|
||||
<button class="btn-delete" onclick="deleteAdminPreset('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No presets — create one to give users preconfigured model experiences</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
// ── Teams Admin ─────────────────────────
|
||||
_teamEditId: null,
|
||||
|
||||
async loadAdminTeams(quiet) {
|
||||
const el = document.getElementById('adminTeamList');
|
||||
const detail = document.getElementById('adminTeamDetail');
|
||||
if (!quiet) {
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
detail.style.display = 'none';
|
||||
el.style.display = '';
|
||||
document.getElementById('adminAddTeamForm').style.display = 'none';
|
||||
}
|
||||
try {
|
||||
const resp = await API.adminListTeams();
|
||||
const teams = resp.data || [];
|
||||
el.innerHTML = teams.map(t => `
|
||||
<div class="admin-user-row">
|
||||
<div class="admin-user-info">
|
||||
<div><strong>${esc(t.name)}</strong>
|
||||
${t.is_active ? '' : '<span class="badge-pending">inactive</span>'}
|
||||
</div>
|
||||
<div class="text-muted">${esc(t.description || 'No description')} · ${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
<button class="btn-small" onclick="UI.openTeamDetail('${t.id}', '${esc(t.name)}')">Members</button>
|
||||
<button class="admin-model-toggle ${t.is_active ? 'enabled' : ''}" onclick="toggleTeamActive('${t.id}', ${!t.is_active})">${t.is_active ? '✓ Active' : 'Inactive'}</button>
|
||||
<button class="btn-delete" onclick="deleteTeam('${t.id}', '${esc(t.name)}')" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty-hint">No teams yet — create one to organize users and set policies</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async openTeamDetail(teamId, teamName) {
|
||||
this._teamEditId = teamId;
|
||||
document.getElementById('adminTeamList').style.display = 'none';
|
||||
document.getElementById('adminAddTeamForm').style.display = 'none';
|
||||
const detail = document.getElementById('adminTeamDetail');
|
||||
detail.style.display = '';
|
||||
document.getElementById('adminTeamDetailName').textContent = teamName;
|
||||
document.getElementById('adminAddMemberForm').style.display = 'none';
|
||||
|
||||
// Load team settings for policy checkboxes
|
||||
try {
|
||||
const team = await API.adminGetTeam(teamId);
|
||||
const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {});
|
||||
document.getElementById('adminTeamPrivatePolicy').checked = !!settings.require_private_providers;
|
||||
// allow_team_providers defaults to true if not explicitly set
|
||||
document.getElementById('adminTeamAllowProviders').checked = settings.allow_team_providers !== false;
|
||||
} catch (e) { /* proceed with defaults */ }
|
||||
|
||||
await this.loadTeamMembers(teamId);
|
||||
},
|
||||
|
||||
async loadTeamMembers(teamId) {
|
||||
const el = document.getElementById('adminMemberList');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const resp = await API.adminListMembers(teamId);
|
||||
const members = resp.data || [];
|
||||
|
||||
el.innerHTML = members.map(m => `
|
||||
<div class="admin-user-row">
|
||||
<div class="admin-user-info">
|
||||
<div><strong>${esc(m.display_name || m.email)}</strong>
|
||||
<span class="badge-${m.role === 'admin' ? 'admin' : 'user'}">${m.role}</span>
|
||||
${m.user_role === 'admin' ? '<span class="badge-admin">sys-admin</span>' : ''}
|
||||
</div>
|
||||
<div class="text-muted">${esc(m.email)}</div>
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
<select onchange="updateTeamMember('${teamId}', '${m.id}', this.value)" class="inline-select">
|
||||
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
|
||||
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Team Admin</option>
|
||||
</select>
|
||||
<button class="btn-delete" onclick="removeTeamMember('${teamId}', '${m.id}', '${esc(m.email)}')" title="Remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty-hint">No members — add users to this team</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadMemberUserDropdown(teamId) {
|
||||
const sel = document.getElementById('adminMemberUser');
|
||||
sel.innerHTML = '<option value="">Loading...</option>';
|
||||
try {
|
||||
const resp = await API.adminListUsers(1, 200);
|
||||
const users = resp.users || resp.data || [];
|
||||
// Also get existing members to exclude
|
||||
const memberResp = await API.adminListMembers(teamId);
|
||||
const existingIds = new Set((memberResp.data || []).map(m => m.user_id));
|
||||
const available = users.filter(u => u.is_active && !existingIds.has(u.id));
|
||||
sel.innerHTML = '<option value="">Select user...</option>' +
|
||||
available.map(u => `<option value="${u.id}">${esc(u.username || u.email)}</option>`).join('');
|
||||
} catch (e) { sel.innerHTML = `<option value="">Error loading users</option>`; }
|
||||
},
|
||||
|
||||
async loadAdminSettings() {
|
||||
try {
|
||||
const data = await API.adminGetSettings();
|
||||
// v0.9: { settings: { banner: {...}, ... }, policies: { allow_registration: "true", ... } }
|
||||
const settings = data.settings || {};
|
||||
const policies = data.policies || {};
|
||||
|
||||
// Helper to read from settings map (JSONB values)
|
||||
const getSetting = (key, fallback) => {
|
||||
const v = settings[key];
|
||||
if (v === undefined || v === null) return fallback;
|
||||
// Unwrap {value: X} wrapper if present
|
||||
return (v && typeof v === 'object' && 'value' in v) ? v.value : v;
|
||||
};
|
||||
|
||||
// Registration (policy: allow_registration)
|
||||
document.getElementById('adminRegToggle').checked = policies.allow_registration === 'true';
|
||||
|
||||
// Admin system prompt (global_settings)
|
||||
const sysPrompt = getSetting('system_prompt', {}) || {};
|
||||
document.getElementById('adminSystemPrompt').value = sysPrompt.content || '';
|
||||
|
||||
// Registration default state (policy: default_user_active → 'true' means auto-active)
|
||||
const defaultActive = policies.default_user_active === 'true';
|
||||
document.getElementById('adminRegDefaultState').value = defaultActive ? 'active' : 'pending';
|
||||
|
||||
// User providers / BYOK (policy: allow_user_byok)
|
||||
document.getElementById('adminUserProvidersToggle').checked = policies.allow_user_byok === 'true';
|
||||
|
||||
// User presets / personas (policy: allow_user_personas)
|
||||
document.getElementById('adminUserPresetsToggle').checked = policies.allow_user_personas === 'true';
|
||||
|
||||
// Default model (policy: default_model) — populate from current model list
|
||||
const defModelSel = document.getElementById('adminDefaultModel');
|
||||
if (defModelSel) {
|
||||
defModelSel.innerHTML = '<option value="">— None (first visible) —</option>';
|
||||
App.models.filter(m => !m.hidden).forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.baseModelId || m.id;
|
||||
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
|
||||
defModelSel.appendChild(opt);
|
||||
});
|
||||
defModelSel.value = policies.default_model || '';
|
||||
}
|
||||
|
||||
// Banner (global_settings)
|
||||
const banner = getSetting('banner', {}) || {};
|
||||
document.getElementById('adminBannerEnabled').checked = !!banner.enabled;
|
||||
document.getElementById('adminBannerText').value = banner.text || '';
|
||||
document.getElementById('adminBannerPosition').value = banner.position || 'both';
|
||||
document.getElementById('adminBannerBg').value = banner.bg || '#007a33';
|
||||
document.getElementById('adminBannerBgHex').value = banner.bg || '#007a33';
|
||||
document.getElementById('adminBannerFg').value = banner.fg || '#ffffff';
|
||||
document.getElementById('adminBannerFgHex').value = banner.fg || '#ffffff';
|
||||
document.getElementById('bannerConfigFields').style.display = banner.enabled ? '' : 'none';
|
||||
UI.updateBannerPreview();
|
||||
|
||||
// Load banner presets (global_settings)
|
||||
const presets = getSetting('banner_presets', {}) || {};
|
||||
const sel = document.getElementById('adminBannerPreset');
|
||||
sel.innerHTML = '<option value="">Custom</option>';
|
||||
Object.entries(presets).forEach(([key, p]) => {
|
||||
sel.innerHTML += `<option value="${esc(key)}">${esc(p.text || key)}</option>`;
|
||||
});
|
||||
UI._bannerPresets = presets;
|
||||
} catch (e) { console.debug('Failed to load admin settings:', e); }
|
||||
},
|
||||
|
||||
_bannerPresets: {},
|
||||
|
||||
updateBannerPreview() {
|
||||
const prev = document.getElementById('bannerPreview');
|
||||
if (!prev) return;
|
||||
const text = document.getElementById('adminBannerText').value || 'PREVIEW';
|
||||
const bg = document.getElementById('adminBannerBg').value;
|
||||
const fg = document.getElementById('adminBannerFg').value;
|
||||
prev.textContent = text;
|
||||
prev.style.background = bg;
|
||||
prev.style.color = fg;
|
||||
},
|
||||
|
||||
});
|
||||
974
src/js/ui-core.js
Normal file
974
src/js/ui-core.js
Normal file
@@ -0,0 +1,974 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – UI Core
|
||||
// ==========================================
|
||||
// UI object definition: sidebar, chat list, messages, streaming,
|
||||
// model selector, capability badges, user, generating state, toast,
|
||||
// settings modal dispatcher, export, message actions, scroll.
|
||||
// Extended by ui-settings.js and ui-admin.js via Object.assign.
|
||||
|
||||
// ── Avatar Helper ────────────────────────────
|
||||
// Returns HTML for a message avatar: <img> if data URI available, emoji fallback.
|
||||
function avatarHTML(role, avatarDataURI) {
|
||||
if (avatarDataURI) {
|
||||
return `<img src="${avatarDataURI}" alt="" class="msg-avatar-img">`;
|
||||
}
|
||||
return role === 'user' ? '👤' : '🤖';
|
||||
}
|
||||
|
||||
// Look up the current model/preset avatar for assistant messages.
|
||||
function assistantAvatarURI(msg) {
|
||||
// Try to find preset avatar from the model that generated this message
|
||||
const modelId = msg?.model || msg?.preset_id;
|
||||
if (modelId) {
|
||||
const m = App.models.find(x => x.id === modelId || x.presetId === modelId);
|
||||
if (m?.presetAvatar) return m.presetAvatar;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Shared Preset Form Component ────────────
|
||||
// Renders a preset creation/edit form into a container element.
|
||||
// Options: { prefix, showAvatar, showProviderConfig, onSubmit, onCancel }
|
||||
// Returns: { getValues(), setValues(preset), clearForm(), container }
|
||||
function renderPresetForm(containerEl, options = {}) {
|
||||
const pfx = options.prefix || 'pf';
|
||||
const showAvatar = options.showAvatar !== false;
|
||||
const showConfig = !!options.showProviderConfig;
|
||||
|
||||
containerEl.innerHTML = `
|
||||
<div class="form-group"><label>Name</label><input type="text" id="${pfx}_name" placeholder="Code Reviewer"></div>
|
||||
${showAvatar ? `
|
||||
<div class="avatar-upload-row" style="margin-bottom:0.75rem">
|
||||
<div class="avatar-preview avatar-preview-sm" id="${pfx}_avatarPreview">
|
||||
<span id="${pfx}_avatarPlaceholder">🤖</span>
|
||||
</div>
|
||||
<div class="avatar-actions">
|
||||
<button class="btn-small" type="button" id="${pfx}_avatarUploadBtn">Upload Avatar</button>
|
||||
<button class="btn-small btn-danger" type="button" id="${pfx}_avatarRemoveBtn" style="display:none">Remove</button>
|
||||
<input type="file" id="${pfx}_avatarFileInput" accept="image/png,image/jpeg,image/gif" style="display:none">
|
||||
<div class="form-hint">Optional · 128×128 · shown in chat</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<div class="form-group"><label>Description</label><input type="text" id="${pfx}_desc" placeholder="Reviews code for best practices"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Base Model</label><select id="${pfx}_model"></select></div>
|
||||
${showConfig ? `<div class="form-group"><label>Provider Config</label><select id="${pfx}_config"><option value="">Auto-resolve</option></select></div>` : ''}
|
||||
</div>
|
||||
<div class="form-group"><label>System Prompt</label><textarea id="${pfx}_prompt" rows="3" placeholder="You are a code reviewer..."></textarea></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Temperature</label><input type="number" id="${pfx}_temp" step="0.1" min="0" max="2" placeholder="default"></div>
|
||||
<div class="form-group"><label>Max Tokens</label><input type="number" id="${pfx}_maxTokens" placeholder="default"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<button class="btn-small btn-primary" id="${pfx}_submitBtn">Create</button>
|
||||
<button class="btn-small" id="${pfx}_cancelBtn">Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Avatar wiring
|
||||
if (showAvatar) {
|
||||
const uploadBtn = document.getElementById(`${pfx}_avatarUploadBtn`);
|
||||
const fileInput = document.getElementById(`${pfx}_avatarFileInput`);
|
||||
const removeBtn = document.getElementById(`${pfx}_avatarRemoveBtn`);
|
||||
uploadBtn?.addEventListener('click', () => fileInput.click());
|
||||
fileInput?.addEventListener('change', function() {
|
||||
const file = this.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => form.updateAvatarPreview(reader.result);
|
||||
reader.readAsDataURL(file);
|
||||
this.value = '';
|
||||
});
|
||||
removeBtn?.addEventListener('click', () => form.updateAvatarPreview(null));
|
||||
}
|
||||
|
||||
// Cancel wiring
|
||||
document.getElementById(`${pfx}_cancelBtn`)?.addEventListener('click', () => {
|
||||
if (options.onCancel) options.onCancel();
|
||||
});
|
||||
|
||||
// Submit wiring
|
||||
document.getElementById(`${pfx}_submitBtn`)?.addEventListener('click', () => {
|
||||
if (options.onSubmit) options.onSubmit(form.getValues());
|
||||
});
|
||||
|
||||
const form = {
|
||||
getValues() {
|
||||
const v = {
|
||||
name: document.getElementById(`${pfx}_name`)?.value.trim() || '',
|
||||
description: document.getElementById(`${pfx}_desc`)?.value.trim() || '',
|
||||
base_model_id: document.getElementById(`${pfx}_model`)?.value || '',
|
||||
system_prompt: document.getElementById(`${pfx}_prompt`)?.value || '',
|
||||
};
|
||||
if (showConfig) {
|
||||
const cfgId = document.getElementById(`${pfx}_config`)?.value;
|
||||
if (cfgId) v.provider_config_id = cfgId;
|
||||
}
|
||||
const temp = parseFloat(document.getElementById(`${pfx}_temp`)?.value);
|
||||
if (!isNaN(temp)) v.temperature = temp;
|
||||
const maxTok = parseInt(document.getElementById(`${pfx}_maxTokens`)?.value);
|
||||
if (!isNaN(maxTok) && maxTok > 0) v.max_tokens = maxTok;
|
||||
// Pending avatar data URI
|
||||
const img = document.querySelector(`#${pfx}_avatarPreview img`);
|
||||
if (img?.src?.startsWith('data:')) v._pendingAvatar = img.src;
|
||||
return v;
|
||||
},
|
||||
setValues(p) {
|
||||
const el = id => document.getElementById(`${pfx}_${id}`);
|
||||
if (el('name')) el('name').value = p.name || '';
|
||||
if (el('desc')) el('desc').value = p.description || '';
|
||||
if (el('prompt')) el('prompt').value = p.system_prompt || '';
|
||||
if (el('temp')) el('temp').value = p.temperature != null ? p.temperature : '';
|
||||
if (el('maxTokens')) el('maxTokens').value = p.max_tokens != null ? p.max_tokens : '';
|
||||
if (el('model')) el('model').value = p.base_model_id || '';
|
||||
if (showConfig && el('config')) el('config').value = p.provider_config_id || '';
|
||||
if (showAvatar) form.updateAvatarPreview(p.avatar || null);
|
||||
},
|
||||
clearForm() {
|
||||
['name','desc','prompt','temp','maxTokens'].forEach(f => {
|
||||
const el = document.getElementById(`${pfx}_${f}`);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
const modelSel = document.getElementById(`${pfx}_model`);
|
||||
if (modelSel) modelSel.selectedIndex = 0;
|
||||
if (showConfig) {
|
||||
const cfgSel = document.getElementById(`${pfx}_config`);
|
||||
if (cfgSel) cfgSel.selectedIndex = 0;
|
||||
}
|
||||
if (showAvatar) form.updateAvatarPreview(null);
|
||||
},
|
||||
updateAvatarPreview(dataURI) {
|
||||
const preview = document.getElementById(`${pfx}_avatarPreview`);
|
||||
const placeholder = document.getElementById(`${pfx}_avatarPlaceholder`);
|
||||
const removeBtn = document.getElementById(`${pfx}_avatarRemoveBtn`);
|
||||
if (!preview) return;
|
||||
const existing = preview.querySelector('img');
|
||||
if (dataURI) {
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
if (existing) { existing.src = dataURI; }
|
||||
else {
|
||||
const img = document.createElement('img');
|
||||
img.src = dataURI; img.alt = 'Preset avatar';
|
||||
preview.insertBefore(img, placeholder);
|
||||
}
|
||||
if (removeBtn) removeBtn.style.display = '';
|
||||
} else {
|
||||
if (existing) existing.remove();
|
||||
if (placeholder) placeholder.style.display = '';
|
||||
if (removeBtn) removeBtn.style.display = 'none';
|
||||
}
|
||||
},
|
||||
setSubmitLabel(label) {
|
||||
const btn = document.getElementById(`${pfx}_submitBtn`);
|
||||
if (btn) btn.textContent = label;
|
||||
},
|
||||
getModelSelect() { return document.getElementById(`${pfx}_model`); },
|
||||
getConfigSelect() { return showConfig ? document.getElementById(`${pfx}_config`) : null; },
|
||||
};
|
||||
return form;
|
||||
}
|
||||
|
||||
// ── Summary Message Helpers ─────────────────
|
||||
|
||||
function _isSummaryMessage(msg) {
|
||||
if (!msg.metadata) return false;
|
||||
const meta = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata;
|
||||
return meta?.type === 'summary';
|
||||
}
|
||||
|
||||
function toggleSummarizedHistory() {
|
||||
const el = document.getElementById('summarizedHistory');
|
||||
const btn = document.querySelector('.message-summary-divider .summary-toggle');
|
||||
if (!el) return;
|
||||
const show = el.style.display === 'none';
|
||||
el.style.display = show ? 'block' : 'none';
|
||||
if (btn) {
|
||||
const count = el.querySelectorAll('.message').length;
|
||||
btn.textContent = show ? `▾ Hide ${count} earlier messages` : `▸ ${count} earlier messages summarized`;
|
||||
}
|
||||
}
|
||||
|
||||
const UI = {
|
||||
|
||||
// ── Sidebar ──────────────────────────────
|
||||
|
||||
toggleSidebar() {
|
||||
const sb = document.getElementById('sidebar');
|
||||
sb.classList.toggle('collapsed');
|
||||
const collapsed = sb.classList.contains('collapsed');
|
||||
localStorage.setItem('sb_sidebar', collapsed ? '1' : '0');
|
||||
const overlay = document.getElementById('sidebarOverlay');
|
||||
if (overlay) overlay.style.display = collapsed ? 'none' : (window.innerWidth <= 768 ? 'block' : 'none');
|
||||
},
|
||||
|
||||
restoreSidebar() {
|
||||
if (window.innerWidth <= 768 || localStorage.getItem('sb_sidebar') === '1') {
|
||||
document.getElementById('sidebar').classList.add('collapsed');
|
||||
}
|
||||
},
|
||||
|
||||
// ── User Menu Flyout ─────────────────────
|
||||
|
||||
toggleUserMenu() {
|
||||
const flyout = document.getElementById('userFlyout');
|
||||
flyout.classList.toggle('open');
|
||||
},
|
||||
|
||||
closeUserMenu() {
|
||||
document.getElementById('userFlyout').classList.remove('open');
|
||||
},
|
||||
|
||||
// ── Chat List ────────────────────────────
|
||||
|
||||
renderChatList() {
|
||||
const el = document.getElementById('chatHistory');
|
||||
const searchInput = document.getElementById('chatSearchInput');
|
||||
const searchWrap = document.getElementById('sidebarSearch');
|
||||
const query = (searchInput?.value || '').trim().toLowerCase();
|
||||
|
||||
// Toggle clear button visibility
|
||||
if (searchWrap) searchWrap.classList.toggle('has-value', query.length > 0);
|
||||
|
||||
// Filter chats by search query
|
||||
let chats = App.chats;
|
||||
if (query) {
|
||||
chats = chats.filter(c =>
|
||||
(c.title || '').toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
if (chats.length === 0 && query) {
|
||||
el.innerHTML = `<div class="sidebar-search-empty">No chats matching "${esc(query)}"</div>`;
|
||||
return;
|
||||
}
|
||||
if (chats.length === 0) {
|
||||
el.innerHTML = '<div class="sidebar-empty">No conversations yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by time
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today - 86400000);
|
||||
const weekAgo = new Date(today - 7 * 86400000);
|
||||
|
||||
const groups = { today: [], yesterday: [], week: [], older: [] };
|
||||
|
||||
chats.forEach(c => {
|
||||
const d = new Date(c.updatedAt);
|
||||
if (d >= today) groups.today.push(c);
|
||||
else if (d >= yesterday) groups.yesterday.push(c);
|
||||
else if (d >= weekAgo) groups.week.push(c);
|
||||
else groups.older.push(c);
|
||||
});
|
||||
|
||||
let html = '';
|
||||
const renderGroup = (label, chats) => {
|
||||
if (chats.length === 0) return;
|
||||
html += `<div class="chat-group-label">${label}</div>`;
|
||||
chats.forEach(c => {
|
||||
const time = _relativeTime(c.updatedAt);
|
||||
html += `
|
||||
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
|
||||
onclick="selectChat('${c.id}')">
|
||||
<span class="chat-item-title">${esc(c.title)}</span>
|
||||
<span class="chat-item-time">${time}</span>
|
||||
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
|
||||
</div>`;
|
||||
});
|
||||
};
|
||||
|
||||
renderGroup('Today', groups.today);
|
||||
renderGroup('Yesterday', groups.yesterday);
|
||||
renderGroup('Previous 7 days', groups.week);
|
||||
renderGroup('Older', groups.older);
|
||||
|
||||
el.innerHTML = html;
|
||||
},
|
||||
|
||||
// ── Messages ─────────────────────────────
|
||||
|
||||
renderMessages(messages) {
|
||||
const el = document.getElementById('chatMessages');
|
||||
if (!messages || messages.length === 0) {
|
||||
this.showEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the most recent summary boundary
|
||||
let summaryIdx = -1;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (_isSummaryMessage(messages[i])) summaryIdx = i;
|
||||
}
|
||||
|
||||
// Render: if summary exists, show collapsed-history toggle + summary + messages after
|
||||
let html = '';
|
||||
if (summaryIdx >= 0) {
|
||||
const hiddenCount = messages.filter((m, i) => i < summaryIdx && m.role !== 'system').length;
|
||||
if (hiddenCount > 0) {
|
||||
html += `<div class="message-summary-divider" id="summaryDivider">
|
||||
<button class="summary-toggle" onclick="toggleSummarizedHistory()">
|
||||
▸ ${hiddenCount} earlier messages summarized
|
||||
</button>
|
||||
</div>
|
||||
<div id="summarizedHistory" style="display:none">
|
||||
${messages.slice(0, summaryIdx)
|
||||
.filter(m => m.role !== 'system')
|
||||
.map((m, i) => this._messageHTML(m, i, true))
|
||||
.join('')}
|
||||
</div>`;
|
||||
}
|
||||
// Render summary node
|
||||
html += this._summaryHTML(messages[summaryIdx]);
|
||||
// Render messages after summary
|
||||
html += messages.slice(summaryIdx + 1)
|
||||
.filter(m => m.role !== 'system' && !_isSummaryMessage(m))
|
||||
.map((m, i) => this._messageHTML(m, summaryIdx + 1 + i))
|
||||
.join('');
|
||||
} else {
|
||||
html = messages
|
||||
.filter(m => m.role !== 'system')
|
||||
.map((m, i) => this._messageHTML(m, i))
|
||||
.join('');
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
this._scrollToBottom(true);
|
||||
},
|
||||
|
||||
_summaryHTML(msg) {
|
||||
const meta = msg.metadata || {};
|
||||
const count = meta.summarized_count || '?';
|
||||
const model = meta.utility_model || 'utility model';
|
||||
return `
|
||||
<div class="message-summary" data-msg-id="${esc(msg.id || '')}">
|
||||
<div class="summary-header">
|
||||
<span>📝 Conversation summary (${count} messages, by ${esc(model)})</span>
|
||||
</div>
|
||||
<div class="msg-text">${formatMessage(msg.content)}</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
_messageHTML(msg, index, dimmed) {
|
||||
const isUser = msg.role === 'user';
|
||||
// Skip summary messages in normal rendering — they get _summaryHTML
|
||||
if (_isSummaryMessage(msg)) return '';
|
||||
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||||
let assistantLabel = 'Assistant';
|
||||
if (!isUser) {
|
||||
// Use stored modelName, or resolve from current model list, or fall back to model id
|
||||
if (msg.modelName) {
|
||||
assistantLabel = msg.modelName;
|
||||
} else if (msg.model) {
|
||||
const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model);
|
||||
assistantLabel = m?.name || msg.model;
|
||||
}
|
||||
}
|
||||
|
||||
// Branch indicator: show ‹ 2/3 › when siblings exist
|
||||
const hasSiblings = (msg.siblingCount || 1) > 1;
|
||||
const sibPos = (msg.siblingIndex || 0) + 1; // display as 1-indexed
|
||||
const sibTotal = msg.siblingCount || 1;
|
||||
const branchNav = hasSiblings ? `
|
||||
<div class="branch-nav">
|
||||
<button class="branch-arrow" onclick="switchSibling('${msg.id}', -1)"
|
||||
${sibPos <= 1 ? 'disabled' : ''} title="Previous version">‹</button>
|
||||
<span class="branch-pos">${sibPos}/${sibTotal}</span>
|
||||
<button class="branch-arrow" onclick="switchSibling('${msg.id}', 1)"
|
||||
${sibPos >= sibTotal ? 'disabled' : ''} title="Next version">›</button>
|
||||
</div>` : '';
|
||||
|
||||
// Action buttons
|
||||
const msgId = msg.id ? esc(msg.id) : '';
|
||||
const editBtn = isUser && msgId ? `<button class="msg-action-btn" onclick="editMessage('${msgId}')" title="Edit">Edit</button>` : '';
|
||||
const regenBtn = !isUser && msgId ? `<button class="msg-action-btn" onclick="regenerateMessage('${msgId}')" title="Regenerate">Regen</button>` : '';
|
||||
|
||||
return `
|
||||
<div class="message ${msg.role}${dimmed ? ' msg-dimmed' : ''}" data-msg-id="${msgId}">
|
||||
<div class="msg-inner">
|
||||
<div class="msg-avatar">${avatarHTML(msg.role, isUser ? API.user?.avatar : assistantAvatarURI(msg))}</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-head">
|
||||
<span class="msg-role">${isUser ? 'You' : esc(assistantLabel)}</span>
|
||||
${branchNav}
|
||||
${time ? `<span class="msg-time">${time}</span>` : ''}
|
||||
<div class="msg-actions">
|
||||
${editBtn}
|
||||
${regenBtn}
|
||||
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
|
||||
<div class="msg-text">${formatMessage(msg.content)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
showEmptyState() {
|
||||
document.getElementById('chatMessages').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-logo"><img src="favicon-256.png" class="empty-logo-img" alt=""></div>
|
||||
<h2>Chat Switchboard</h2>
|
||||
<p>Select a model and start chatting</p>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
showEditInline(messageId, currentContent) {
|
||||
const msgEl = document.querySelector(`.message[data-msg-id="${messageId}"]`);
|
||||
if (!msgEl) return;
|
||||
|
||||
const textEl = msgEl.querySelector('.msg-text');
|
||||
if (!textEl) return;
|
||||
|
||||
// Replace message text with an edit form
|
||||
const escaped = currentContent.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
textEl.innerHTML = `
|
||||
<textarea class="msg-edit-input" id="editInput_${messageId}">${escaped}</textarea>
|
||||
<div class="msg-edit-actions">
|
||||
<button class="msg-edit-cancel" onclick="cancelEdit()">Cancel</button>
|
||||
<button class="msg-edit-submit" onclick="submitEdit('${messageId}', document.getElementById('editInput_${messageId}').value)">Submit & Regenerate</button>
|
||||
</div>`;
|
||||
|
||||
// Focus and select all
|
||||
const textarea = document.getElementById(`editInput_${messageId}`);
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
textarea.selectionStart = textarea.value.length;
|
||||
// Auto-resize
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = textarea.scrollHeight + 'px';
|
||||
textarea.addEventListener('input', () => {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = textarea.scrollHeight + 'px';
|
||||
});
|
||||
// Ctrl+Enter to submit
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
submitEdit(messageId, textarea.value);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// ── Streaming ────────────────────────────
|
||||
|
||||
async streamResponse(response, messages, modelName) {
|
||||
const container = document.getElementById('chatMessages');
|
||||
document.getElementById('typingIndicator')?.remove();
|
||||
|
||||
const label = modelName || 'Assistant';
|
||||
const currentModel = App.findModel(App.settings.model);
|
||||
const streamAvatar = currentModel?.presetAvatar || null;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message assistant';
|
||||
div.innerHTML = `
|
||||
<div class="msg-inner">
|
||||
<div class="msg-avatar">${avatarHTML('assistant', streamAvatar)}</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-head"><span class="msg-role">${esc(label)}</span></div>
|
||||
<div class="msg-tools" id="streamTools" style="display:none"></div>
|
||||
<div class="msg-text" id="streamContent"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
container.appendChild(div);
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let content = '';
|
||||
let reasoning = '';
|
||||
let currentEvent = ''; // SSE event type
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
// SSE event type line
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent = line.slice(7).trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') { currentEvent = ''; continue; }
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
if (currentEvent === 'tool_use') {
|
||||
// parsed = array of tool calls
|
||||
this._renderToolUse(parsed);
|
||||
currentEvent = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentEvent === 'tool_result') {
|
||||
// parsed = { tool_call_id, name, content, is_error }
|
||||
this._renderToolResult(parsed);
|
||||
currentEvent = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reasoning content delta (thinking blocks)
|
||||
const reasoningDelta = parsed.choices?.[0]?.delta?.reasoning_content || '';
|
||||
if (reasoningDelta) {
|
||||
reasoning += reasoningDelta;
|
||||
// Render accumulated reasoning + content together
|
||||
const full = (reasoning ? '<think>' + reasoning + '</think>' : '') + content;
|
||||
document.getElementById('streamContent').innerHTML = formatMessage(full);
|
||||
this._scrollToBottom();
|
||||
}
|
||||
|
||||
// Normal content delta
|
||||
const delta = parsed.choices?.[0]?.delta?.content || '';
|
||||
if (delta) {
|
||||
content += delta;
|
||||
const full = (reasoning ? '<think>' + reasoning + '</think>' : '') + content;
|
||||
document.getElementById('streamContent').innerHTML = formatMessage(full);
|
||||
this._scrollToBottom();
|
||||
}
|
||||
currentEvent = '';
|
||||
} catch (e) { /* partial JSON */ }
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
|
||||
_renderToolUse(toolCalls) {
|
||||
const el = document.getElementById('streamTools');
|
||||
if (!el) return;
|
||||
el.style.display = '';
|
||||
for (const tc of toolCalls) {
|
||||
const name = tc.function?.name || tc.name || 'unknown';
|
||||
const toolDiv = document.createElement('div');
|
||||
toolDiv.className = 'tool-activity';
|
||||
toolDiv.id = `tool_${tc.id}`;
|
||||
toolDiv.innerHTML = `
|
||||
<span class="tool-icon">🔧</span>
|
||||
<span class="tool-name">${esc(name)}</span>
|
||||
<span class="tool-status tool-running">running…</span>`;
|
||||
el.appendChild(toolDiv);
|
||||
}
|
||||
this._scrollToBottom();
|
||||
},
|
||||
|
||||
_renderToolResult(result) {
|
||||
const el = document.getElementById(`tool_${result.tool_call_id}`);
|
||||
if (el) {
|
||||
const statusEl = el.querySelector('.tool-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = result.is_error ? 'error' : 'done';
|
||||
statusEl.className = `tool-status ${result.is_error ? 'tool-error' : 'tool-done'}`;
|
||||
}
|
||||
// Parse tool result content for a brief summary
|
||||
try {
|
||||
const parsed = JSON.parse(result.content);
|
||||
let summary = '';
|
||||
if (parsed.title) summary = parsed.title;
|
||||
else if (parsed.count != null) summary = `${parsed.count} results`;
|
||||
if (summary) {
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'tool-hint';
|
||||
hint.textContent = typeof summary === 'string' ? summary : JSON.stringify(summary);
|
||||
el.appendChild(hint);
|
||||
}
|
||||
// Add "View note" link for note tools
|
||||
if (result.name && result.name.startsWith('note_') && parsed.id) {
|
||||
const link = document.createElement('button');
|
||||
link.className = 'tool-note-link';
|
||||
link.textContent = '📝 View note';
|
||||
link.onclick = () => { openNotes(); setTimeout(() => openNoteEditor(parsed.id), 300); };
|
||||
el.appendChild(link);
|
||||
}
|
||||
} catch (e) { /* not JSON or no useful summary */ }
|
||||
}
|
||||
this._scrollToBottom();
|
||||
},
|
||||
|
||||
// ── Model Selector (Custom Dropdown) ────
|
||||
|
||||
_modelValue: '',
|
||||
|
||||
getModelValue() { return UI._modelValue || App.settings.model || ''; },
|
||||
|
||||
setModelValue(val, label) {
|
||||
UI._modelValue = val;
|
||||
if (val) { App.settings.model = val; saveSettings(); }
|
||||
const btn = document.getElementById('modelDropdownLabel');
|
||||
if (btn) btn.textContent = label || val || 'Select a model';
|
||||
// Highlight selected item
|
||||
document.querySelectorAll('#modelDropdownMenu .model-dropdown-item').forEach(el => {
|
||||
el.classList.toggle('selected', el.dataset.value === val);
|
||||
});
|
||||
UI.updateCapabilityBadges();
|
||||
if (typeof updateContextWarning === 'function') updateContextWarning();
|
||||
if (typeof updateInputTokens === 'function') updateInputTokens();
|
||||
},
|
||||
|
||||
updateModelSelector() {
|
||||
const menu = document.getElementById('modelDropdownMenu');
|
||||
if (!menu) return;
|
||||
const current = App.settings.model;
|
||||
menu.innerHTML = '';
|
||||
|
||||
if (App.models.length === 0) {
|
||||
menu.innerHTML = '<div class="model-dropdown-item" style="color:var(--text-3);cursor:default">No models loaded</div>';
|
||||
UI.setModelValue('', 'No models loaded');
|
||||
} else {
|
||||
const globalPresets = App.models.filter(m => m.isPreset && m.presetScope === 'global');
|
||||
const teamPresets = App.models.filter(m => m.isPreset && m.presetScope === 'team');
|
||||
const personalPresets = App.models.filter(m => m.isPreset && m.presetScope === 'personal');
|
||||
const models = App.models.filter(m => !m.isPreset && !m.hidden);
|
||||
|
||||
const addGroup = (label, items) => {
|
||||
if (items.length === 0) return;
|
||||
const hdr = document.createElement('div');
|
||||
hdr.className = 'model-dropdown-group';
|
||||
hdr.textContent = label;
|
||||
menu.appendChild(hdr);
|
||||
items.forEach(m => menu.appendChild(UI._createDropdownItem(m)));
|
||||
};
|
||||
|
||||
addGroup('⚡ Presets', globalPresets);
|
||||
|
||||
// Group team presets by team name
|
||||
const teamGroups = {};
|
||||
teamPresets.forEach(m => {
|
||||
const tn = m.presetTeamName || 'Team';
|
||||
(teamGroups[tn] = teamGroups[tn] || []).push(m);
|
||||
});
|
||||
Object.keys(teamGroups).sort().forEach(tn => {
|
||||
addGroup(`👥 ${tn}`, teamGroups[tn]);
|
||||
});
|
||||
|
||||
addGroup('🔧 My Presets', personalPresets);
|
||||
|
||||
// No team model groups — team providers are for preset building only.
|
||||
// Team members access team models through curated presets.
|
||||
addGroup('Models', models.filter(m => m.source !== 'personal'));
|
||||
addGroup('🔑 My Providers', models.filter(m => m.source === 'personal'));
|
||||
|
||||
// Restore selection — resolution chain: localStorage → admin default → first visible
|
||||
const visibleModels = App.models.filter(m => !m.hidden);
|
||||
const match = visibleModels.find(m => m.id === current || m.baseModelId === current);
|
||||
if (match) {
|
||||
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
|
||||
} else if (App.defaultModel && visibleModels.length > 0) {
|
||||
// Admin-configured default
|
||||
const def = visibleModels.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel);
|
||||
if (def) {
|
||||
UI.setModelValue(def.id, def.name + (def.provider ? ` (${def.provider})` : ''));
|
||||
} else {
|
||||
const first = visibleModels[0];
|
||||
UI.setModelValue(first.id, first.name + (first.provider ? ` (${first.provider})` : ''));
|
||||
}
|
||||
} else if (visibleModels.length > 0) {
|
||||
const first = visibleModels[0];
|
||||
UI.setModelValue(first.id, first.name + (first.provider ? ` (${first.provider})` : ''));
|
||||
} else {
|
||||
UI.setModelValue('', 'No visible models');
|
||||
}
|
||||
}
|
||||
|
||||
// Sync settings modal selector (flat select is fine there)
|
||||
const settingsSel = document.getElementById('settingsModel');
|
||||
if (settingsSel) {
|
||||
settingsSel.innerHTML = '';
|
||||
App.models.filter(m => !m.hidden).forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
|
||||
settingsSel.appendChild(opt);
|
||||
});
|
||||
if (current) settingsSel.value = current;
|
||||
}
|
||||
},
|
||||
|
||||
_createDropdownItem(m) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'model-dropdown-item';
|
||||
div.dataset.value = m.id;
|
||||
const avatar = m.presetAvatar ? `<img src="${m.presetAvatar}" class="dropdown-avatar" alt="">` : '';
|
||||
const teamBadge = m.source === 'team' && m.teamName ? `<span class="badge-team" style="font-size:9px;padding:0 4px">👥 ${esc(m.teamName)}</span>` : '';
|
||||
div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}</span>${teamBadge}${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
|
||||
div.addEventListener('click', () => {
|
||||
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
|
||||
UI._closeModelDropdown();
|
||||
});
|
||||
return div;
|
||||
},
|
||||
|
||||
_closeModelDropdown() {
|
||||
document.getElementById('modelDropdownMenu')?.classList.remove('open');
|
||||
},
|
||||
|
||||
initModelDropdown() {
|
||||
const btn = document.getElementById('modelDropdownBtn');
|
||||
const menu = document.getElementById('modelDropdownMenu');
|
||||
if (!btn || !menu) return;
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
menu.classList.toggle('open');
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#modelDropdown')) menu.classList.remove('open');
|
||||
});
|
||||
},
|
||||
|
||||
// ── Capability Badges ────────────────────
|
||||
|
||||
getSelectedModelCaps() {
|
||||
const modelId = UI.getModelValue();
|
||||
if (!modelId) return {};
|
||||
const model = App.findModel(modelId);
|
||||
// Model in list has resolved caps from backend (catalog → heuristic)
|
||||
return model?.capabilities || {};
|
||||
},
|
||||
|
||||
updateCapabilityBadges() {
|
||||
const el = document.getElementById('modelCaps');
|
||||
if (!el) return;
|
||||
|
||||
const caps = this.getSelectedModelCaps();
|
||||
if (!caps || Object.keys(caps).length === 0) {
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const badges = [];
|
||||
|
||||
// Output tokens
|
||||
if (caps.max_output_tokens > 0) {
|
||||
const k = caps.max_output_tokens >= 1000
|
||||
? (caps.max_output_tokens / 1000).toFixed(0) + 'K'
|
||||
: caps.max_output_tokens;
|
||||
badges.push(`<span class="cap-badge cap-context" title="Max output: ${caps.max_output_tokens.toLocaleString()} tokens">${k} out</span>`);
|
||||
}
|
||||
|
||||
// Context window
|
||||
if (caps.max_context > 0) {
|
||||
const k = (caps.max_context / 1000).toFixed(0) + 'K';
|
||||
badges.push(`<span class="cap-badge cap-context" title="Context window: ${caps.max_context.toLocaleString()} tokens">${k} ctx</span>`);
|
||||
}
|
||||
|
||||
// Capability flags
|
||||
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent" title="Supports tool/function calling">🔧 tools</span>');
|
||||
if (caps.vision) badges.push('<span class="cap-badge cap-accent" title="Supports image/vision input">👁 vision</span>');
|
||||
if (caps.thinking) badges.push('<span class="cap-badge cap-accent" title="Supports extended thinking">💭 thinking</span>');
|
||||
if (caps.reasoning) badges.push('<span class="cap-badge cap-accent" title="Reasoning model (chain-of-thought)">🧠 reasoning</span>');
|
||||
if (caps.code_optimized) badges.push('<span class="cap-badge" title="Optimized for code generation">⟨/⟩ code</span>');
|
||||
if (caps.web_search) badges.push('<span class="cap-badge" title="Supports web search">🔍 search</span>');
|
||||
|
||||
el.innerHTML = badges.join('');
|
||||
},
|
||||
|
||||
// ── User / Connection ────────────────────
|
||||
|
||||
updateUser() {
|
||||
const name = API.user?.display_name || API.user?.username || 'User';
|
||||
const letter = (name[0] || '?').toUpperCase();
|
||||
const avatarEl = document.getElementById('userAvatar');
|
||||
const letterEl = document.getElementById('avatarLetter');
|
||||
// Show avatar image or initial letter
|
||||
let existingImg = avatarEl.querySelector('.user-avatar-img');
|
||||
if (API.user?.avatar) {
|
||||
letterEl.style.display = 'none';
|
||||
if (existingImg) {
|
||||
existingImg.src = API.user.avatar;
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.src = API.user.avatar;
|
||||
img.alt = '';
|
||||
img.className = 'user-avatar-img';
|
||||
img.onerror = function() {
|
||||
this.remove();
|
||||
letterEl.style.display = '';
|
||||
letterEl.textContent = letter;
|
||||
};
|
||||
avatarEl.insertBefore(img, letterEl);
|
||||
}
|
||||
} else {
|
||||
if (existingImg) existingImg.remove();
|
||||
letterEl.style.display = '';
|
||||
letterEl.textContent = letter;
|
||||
}
|
||||
document.getElementById('userName').textContent = name;
|
||||
},
|
||||
|
||||
showAdminButton(show) {
|
||||
document.getElementById('menuAdmin').style.display = show ? '' : 'none';
|
||||
},
|
||||
|
||||
// ── Generating State ─────────────────────
|
||||
|
||||
setGenerating(on) {
|
||||
document.getElementById('stopBtn').classList.toggle('visible', on);
|
||||
document.getElementById('sendBtn').disabled = on;
|
||||
|
||||
// Swap favicon to animated version during generation
|
||||
const faviconLink = document.querySelector('link[rel="icon"][type="image/svg+xml"]');
|
||||
if (faviconLink) {
|
||||
if (on) {
|
||||
// Pulsing favicon: dots fade in/out rapidly
|
||||
faviconLink._origHref = faviconLink._origHref || faviconLink.href;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<rect x="4" y="4" width="56" height="56" rx="10" fill="%23252220" stroke="%234a4540" stroke-width="1.5"/>
|
||||
<circle cx="22" cy="23" r="8" fill="%23111"/><circle cx="42" cy="23" r="8" fill="%23111"/>
|
||||
<circle cx="42" cy="43" r="8" fill="%23111"/><circle cx="22" cy="43" r="8" fill="%23111"/>
|
||||
<circle cx="22" cy="23" r="5.5" fill="%232D7DD2"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0s"/></circle>
|
||||
<circle cx="42" cy="23" r="5.5" fill="%23E8852E"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.3s"/></circle>
|
||||
<circle cx="42" cy="43" r="5.5" fill="%239B59B6"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.6s"/></circle>
|
||||
<circle cx="22" cy="43" r="5.5" fill="%232EAA4E"><animate attributeName="opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite" begin="0.9s"/></circle>
|
||||
</svg>`;
|
||||
faviconLink.href = 'data:image/svg+xml,' + svg;
|
||||
} else if (faviconLink._origHref) {
|
||||
faviconLink.href = faviconLink._origHref;
|
||||
}
|
||||
}
|
||||
|
||||
if (on) {
|
||||
const container = document.getElementById('chatMessages');
|
||||
const currentModel = App.findModel(App.settings.model);
|
||||
const typingAvatar = currentModel?.presetAvatar || null;
|
||||
const typing = document.createElement('div');
|
||||
typing.id = 'typingIndicator';
|
||||
typing.className = 'message assistant';
|
||||
typing.innerHTML = `
|
||||
<div class="msg-inner">
|
||||
<div class="msg-avatar">${avatarHTML('assistant', typingAvatar)}</div>
|
||||
<div class="msg-body"><div class="typing-dots"><span></span><span></span><span></span></div></div>
|
||||
</div>`;
|
||||
container.appendChild(typing);
|
||||
this._scrollToBottom(true);
|
||||
} else {
|
||||
document.getElementById('typingIndicator')?.remove();
|
||||
}
|
||||
},
|
||||
|
||||
showRegenerate(show) {
|
||||
// Bottom-bar regenerate button removed in v0.7.1.
|
||||
// Regen is now per-message via inline buttons.
|
||||
},
|
||||
|
||||
// ── Toast ────────────────────────────────
|
||||
|
||||
toast(message, type = 'success') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const el = document.createElement('div');
|
||||
el.className = `toast ${type}`;
|
||||
el.innerHTML = `<span>${esc(message)}</span><button onclick="this.parentElement.remove()">✕</button>`;
|
||||
container.appendChild(el);
|
||||
setTimeout(() => el.remove(), 5000);
|
||||
},
|
||||
|
||||
// ── Settings Modal ───────────────────────
|
||||
|
||||
openSettings() {
|
||||
document.getElementById('settingsModel').value = App.settings.model;
|
||||
document.getElementById('settingsSystemPrompt').value = App.settings.systemPrompt;
|
||||
document.getElementById('settingsMaxTokens').value = App.settings.maxTokens || '';
|
||||
document.getElementById('settingsTemp').value = App.settings.temperature;
|
||||
document.getElementById('settingsThinking').checked = App.settings.showThinking;
|
||||
document.getElementById('profileChangePwForm').style.display = 'none';
|
||||
|
||||
// Show model's max output in the hint
|
||||
const caps = this.getSelectedModelCaps();
|
||||
const hint = document.getElementById('settingsMaxHint');
|
||||
if (hint) {
|
||||
if (caps.max_output_tokens > 0) {
|
||||
const k = (caps.max_output_tokens / 1000).toFixed(0) + 'K';
|
||||
hint.textContent = `(model max: ${k})`;
|
||||
} else {
|
||||
hint.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
UI.loadProfileIntoSettings();
|
||||
UI.loadMyTeams();
|
||||
UI.checkUserProvidersAllowed();
|
||||
UI.checkUserPresetsAllowed();
|
||||
UI.switchSettingsTab('general');
|
||||
openModal('settingsModal');
|
||||
},
|
||||
closeSettings() { closeModal('settingsModal'); },
|
||||
|
||||
switchSettingsTab(tab) {
|
||||
document.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.stab === tab));
|
||||
document.querySelectorAll('.settings-tab-content').forEach(c => c.style.display = 'none');
|
||||
const panel = document.getElementById(`settings${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
|
||||
if (panel) panel.style.display = '';
|
||||
|
||||
if (tab === 'providers') {
|
||||
UI.loadProviderList();
|
||||
UI.checkUserProvidersAllowed();
|
||||
}
|
||||
if (tab === 'models') UI.loadUserModels();
|
||||
if (tab === 'personas') { UI.loadUserPresets(); UI.checkUserPresetsAllowed(); }
|
||||
if (tab === 'usage') UI.loadMyUsage();
|
||||
if (tab === 'roles') UI.loadUserRoles();
|
||||
if (tab === 'appearance') UI.loadAppearanceSettings();
|
||||
},
|
||||
|
||||
// ── Export ────────────────────────────────
|
||||
|
||||
exportChat(format) {
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (!chat) return UI.toast('No chat to export', 'warning');
|
||||
|
||||
let content, ext, mime;
|
||||
const safeName = chat.title.replace(/[^a-z0-9]/gi, '_');
|
||||
|
||||
if (format === 'json') { content = JSON.stringify(chat, null, 2); ext = 'json'; mime = 'application/json'; }
|
||||
else if (format === 'md') {
|
||||
content = `# ${chat.title}\n\n` + chat.messages.filter(m => m.role !== 'system')
|
||||
.map(m => `## ${m.role === 'user' ? 'You' : (m.modelName || m.model || 'Assistant')}\n\n${m.content}`).join('\n\n---\n\n');
|
||||
ext = 'md'; mime = 'text/markdown';
|
||||
} else {
|
||||
content = chat.messages.filter(m => m.role !== 'system').map(m => `[${m.role === 'user' ? 'You' : (m.modelName || m.model || 'Assistant')}]\n${m.content}`).join('\n\n');
|
||||
ext = 'txt'; mime = 'text/plain';
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||||
a.download = `${safeName}.${ext}`; a.click(); URL.revokeObjectURL(a.href);
|
||||
},
|
||||
|
||||
// ── Message Actions ──────────────────────
|
||||
|
||||
copyMessage(index) {
|
||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||
if (chat?.messages[index]) {
|
||||
navigator.clipboard.writeText(chat.messages[index].content)
|
||||
.then(() => UI.toast('Copied', 'success'))
|
||||
.catch(() => UI.toast('Copy failed', 'error'));
|
||||
}
|
||||
},
|
||||
|
||||
// ── Scroll ───────────────────────────────
|
||||
|
||||
_isNearBottom() {
|
||||
const el = document.getElementById('chatMessages');
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
},
|
||||
|
||||
_scrollToBottom(force) {
|
||||
if (force || this._isNearBottom()) {
|
||||
const el = document.getElementById('chatMessages');
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}
|
||||
};
|
||||
353
src/js/ui-format.js
Normal file
353
src/js/ui-format.js
Normal file
@@ -0,0 +1,353 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – UI Formatting & Helpers
|
||||
// ==========================================
|
||||
// Pure utility layer: esc(), markdown rendering, code blocks,
|
||||
// side panel, time formatting. Loaded before all other UI files.
|
||||
|
||||
// ── HTML Escaping ───────────────────────────
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// ── Message Formatting ──────────────────────
|
||||
|
||||
function formatMessage(content) {
|
||||
if (!content) return '';
|
||||
|
||||
const thinkingBlocks = [];
|
||||
let text = content;
|
||||
|
||||
text = text.replace(/<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi, (_, inner) => {
|
||||
const id = 'think-' + Math.random().toString(36).slice(2, 9);
|
||||
thinkingBlocks.push({ id, content: inner.trim() });
|
||||
// Newlines ensure the placeholder is its own markdown block so adjacent
|
||||
// code fences (e.g. "</think>```html") start on a fresh line for marked.
|
||||
return `\n\nTHINK_PLACEHOLDER_${id}\n\n`;
|
||||
});
|
||||
|
||||
let html;
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
html = _formatMarked(text);
|
||||
} else {
|
||||
html = _formatBasic(text);
|
||||
}
|
||||
|
||||
for (const b of thinkingBlocks) {
|
||||
const inner = esc(b.content).replace(/\n/g, '<br>');
|
||||
const openAttr = App.settings.showThinking ? ' open' : '';
|
||||
const thinkHTML = `<details class="thinking-block"${openAttr}><summary>💭 Thinking</summary><div class="thinking-content">${inner}</div></details>`;
|
||||
html = html.replace(new RegExp(`<p>\\s*THINK_PLACEHOLDER_${b.id}\\s*</p>`, 'g'), thinkHTML);
|
||||
html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function _formatMarked(text) {
|
||||
// Close any unclosed code fences to prevent raw HTML injection during
|
||||
// streaming (closing ``` hasn't arrived yet) or when the model forgets
|
||||
// to close a fence. An odd number of ``` markers means one is unclosed.
|
||||
const fences = text.match(/^```/gm);
|
||||
if (fences && fences.length % 2 !== 0) {
|
||||
text += '\n```';
|
||||
}
|
||||
|
||||
const rendered = marked.parse(text, { breaks: true, gfm: true });
|
||||
let html = DOMPurify.sanitize(rendered, {
|
||||
// Strict allowlist: only elements that marked.js actually produces.
|
||||
// This prevents ANY raw HTML (canvas, style, script, iframe, form, etc.)
|
||||
// from rendering live even if a code fence fails to parse.
|
||||
ALLOWED_TAGS: [
|
||||
// Block elements
|
||||
'p', 'br', 'hr', 'pre', 'code', 'blockquote',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
// Lists
|
||||
'ul', 'ol', 'li',
|
||||
// Tables
|
||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
|
||||
// Inline formatting
|
||||
'strong', 'em', 'b', 'i', 'u', 's', 'del', 'sub', 'sup',
|
||||
'a', 'img', 'span', 'div',
|
||||
// Think blocks (injected post-sanitize, but placeholders may be in <p>)
|
||||
'details', 'summary',
|
||||
],
|
||||
ALLOWED_ATTR: ['id', 'class', 'href', 'target', 'rel', 'src', 'alt', 'title',
|
||||
'colspan', 'rowspan', 'align'],
|
||||
});
|
||||
|
||||
// Process code blocks: add copy button, collapse toggle, HTML preview
|
||||
html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g, (_, attrs, code) => {
|
||||
const codeId = 'code-' + Math.random().toString(36).slice(2, 9);
|
||||
const langMatch = attrs.match(/class="language-(\w+)"/);
|
||||
const lang = langMatch ? langMatch[1] : '';
|
||||
|
||||
// Count lines for collapse decision
|
||||
const lineCount = (code.match(/\n/g) || []).length + 1;
|
||||
const COLLAPSE_THRESHOLD = 15;
|
||||
const shouldCollapse = lineCount > COLLAPSE_THRESHOLD;
|
||||
|
||||
// Build toolbar buttons
|
||||
const collapseBtn = lineCount > 5
|
||||
? `<button class="code-collapse-btn" onclick="toggleCodeCollapse('${codeId}')" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>`
|
||||
: '';
|
||||
|
||||
let toolbar = collapseBtn;
|
||||
toolbar += `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${codeId}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
|
||||
toolbar += `<button class="copy-code-btn" onclick="downloadCode('${codeId}','${lang}')">Download</button>`;
|
||||
|
||||
// HTML preview button (only for HTML-like content)
|
||||
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
|
||||
toolbar += `<button class="copy-code-btn preview-code-btn" onclick="toggleHTMLPreview('${codeId}')">Preview</button>`;
|
||||
}
|
||||
|
||||
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
|
||||
const collapsedClass = shouldCollapse ? ' code-collapsed' : '';
|
||||
return `<pre class="code-block${collapsedClass}" id="block-${codeId}">${langLabel}<code id="${codeId}"${attrs}>${code}</code><div class="code-toolbar">${toolbar}</div></pre>`;
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function _formatBasic(text) {
|
||||
let html = esc(text);
|
||||
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
||||
const id = 'code-' + Math.random().toString(36).slice(2, 9);
|
||||
const lineCount = (code.match(/\n/g) || []).length + 1;
|
||||
const COLLAPSE_THRESHOLD = 15;
|
||||
const shouldCollapse = lineCount > COLLAPSE_THRESHOLD;
|
||||
|
||||
const collapseBtn = lineCount > 5
|
||||
? `<button class="code-collapse-btn" onclick="toggleCodeCollapse('${id}')" title="${shouldCollapse ? 'Expand' : 'Collapse'}">${shouldCollapse ? '▸' : '▾'} ${lineCount} lines</button>`
|
||||
: '';
|
||||
|
||||
let toolbar = collapseBtn;
|
||||
toolbar += `<button class="copy-code-btn" onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent).then(()=>UI.toast('Copied','success'))">Copy</button>`;
|
||||
toolbar += `<button class="copy-code-btn" onclick="downloadCode('${id}','${lang}')">Download</button>`;
|
||||
|
||||
if (lang === 'html' || lang === 'htm' || (!lang && _looksLikeHTML(code))) {
|
||||
toolbar += `<button class="copy-code-btn preview-code-btn" onclick="toggleHTMLPreview('${id}')">Preview</button>`;
|
||||
}
|
||||
|
||||
const langLabel = lang ? `<span class="code-lang">${lang}</span>` : '';
|
||||
const collapsedClass = shouldCollapse ? ' code-collapsed' : '';
|
||||
return `<pre class="code-block${collapsedClass}" id="block-${id}">${langLabel}<code id="${id}" class="language-${lang}">${code.trim()}</code><div class="code-toolbar">${toolbar}</div></pre>`;
|
||||
});
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
return html;
|
||||
}
|
||||
|
||||
// Heuristic: does this code block look like HTML?
|
||||
function _looksLikeHTML(code) {
|
||||
const decoded = code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
return /<!DOCTYPE|<html|<head|<body|<div|<span|<style|<script/i.test(decoded);
|
||||
}
|
||||
|
||||
// ── Code Block Actions ──────────────────────
|
||||
|
||||
function toggleCodeCollapse(codeId) {
|
||||
const pre = document.getElementById('block-' + codeId);
|
||||
if (!pre) return;
|
||||
const isCollapsed = pre.classList.toggle('code-collapsed');
|
||||
const btn = pre.querySelector('.code-collapse-btn');
|
||||
if (btn) {
|
||||
const lines = btn.textContent.replace(/[▸▾]\s*/, '');
|
||||
btn.textContent = (isCollapsed ? '▸ ' : '▾ ') + lines;
|
||||
btn.title = isCollapsed ? 'Expand' : 'Collapse';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleHTMLPreview(codeId) {
|
||||
const codeEl = document.getElementById(codeId);
|
||||
if (!codeEl) return;
|
||||
|
||||
const rawHTML = codeEl.textContent;
|
||||
const frame = document.getElementById('previewFrame');
|
||||
const empty = document.getElementById('previewEmpty');
|
||||
|
||||
frame.srcdoc = rawHTML;
|
||||
frame.style.display = '';
|
||||
if (empty) empty.style.display = 'none';
|
||||
|
||||
// Show clear button
|
||||
const clearBtn = document.getElementById('sidePanelClearBtn');
|
||||
if (clearBtn) clearBtn.style.display = '';
|
||||
|
||||
openSidePanel('preview');
|
||||
}
|
||||
|
||||
function clearPreview() {
|
||||
const frame = document.getElementById('previewFrame');
|
||||
const empty = document.getElementById('previewEmpty');
|
||||
const clearBtn = document.getElementById('sidePanelClearBtn');
|
||||
if (frame) { frame.srcdoc = ''; frame.style.display = 'none'; }
|
||||
if (empty) empty.style.display = '';
|
||||
if (clearBtn) clearBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
function downloadCode(codeId, lang) {
|
||||
const el = document.getElementById(codeId);
|
||||
if (!el) return;
|
||||
const extMap = {
|
||||
javascript: 'js', typescript: 'ts', python: 'py', ruby: 'rb',
|
||||
golang: 'go', go: 'go', rust: 'rs', java: 'java', cpp: 'cpp',
|
||||
c: 'c', csharp: 'cs', css: 'css', html: 'html', htm: 'html',
|
||||
json: 'json', yaml: 'yaml', yml: 'yml', xml: 'xml', sql: 'sql',
|
||||
bash: 'sh', shell: 'sh', sh: 'sh', markdown: 'md', md: 'md',
|
||||
toml: 'toml', dockerfile: 'Dockerfile', makefile: 'Makefile',
|
||||
perl: 'pl', lua: 'lua', swift: 'swift', kotlin: 'kt', php: 'php',
|
||||
};
|
||||
const ext = extMap[lang?.toLowerCase()] || lang || 'txt';
|
||||
const filename = `code.${ext}`;
|
||||
const blob = new Blob([el.textContent], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename;
|
||||
document.body.appendChild(a); a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
UI.toast('Downloaded ' + filename, 'success');
|
||||
}
|
||||
|
||||
// ── Side Panel ──────────────────────────────
|
||||
|
||||
function openSidePanel(tab) {
|
||||
const panel = document.getElementById('sidePanel');
|
||||
panel.classList.add('open');
|
||||
if (tab) switchSidePanelTab(tab);
|
||||
}
|
||||
|
||||
function closeSidePanel() {
|
||||
const panel = document.getElementById('sidePanel');
|
||||
panel.classList.remove('open', 'fullscreen');
|
||||
panel.style.width = ''; panel.style.minWidth = '';
|
||||
}
|
||||
|
||||
function switchSidePanelTab(tab) {
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.side-panel-tab').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tab);
|
||||
});
|
||||
// Show/hide pages
|
||||
document.getElementById('sidePanelPreview').style.display = tab === 'preview' ? '' : 'none';
|
||||
document.getElementById('sidePanelNotes').style.display = tab === 'notes' ? '' : 'none';
|
||||
}
|
||||
|
||||
function toggleSidePanelFullscreen() {
|
||||
const panel = document.getElementById('sidePanel');
|
||||
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>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Side Panel Resize ───────────────────────
|
||||
|
||||
function _initSidePanelResize() {
|
||||
let startX, startW;
|
||||
const panel = document.getElementById('sidePanel');
|
||||
const handle = document.getElementById('sidePanelResize');
|
||||
if (!handle || !panel) return;
|
||||
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
if (panel.classList.contains('fullscreen')) return;
|
||||
e.preventDefault();
|
||||
startX = e.clientX;
|
||||
startW = panel.getBoundingClientRect().width;
|
||||
panel.style.transition = 'none'; // disable animation during drag
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMove = (e) => {
|
||||
const delta = startX - e.clientX; // dragging left = wider
|
||||
const newW = Math.max(280, Math.min(window.innerWidth * 0.7, startW + delta));
|
||||
panel.style.width = newW + 'px';
|
||||
panel.style.minWidth = newW + 'px';
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
panel.style.transition = ''; // re-enable animation
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
// Render tool calls from stored message data (history view)
|
||||
function _renderToolCallsHTML(toolCalls) {
|
||||
if (!toolCalls || !Array.isArray(toolCalls) || toolCalls.length === 0) return '';
|
||||
|
||||
const items = toolCalls.map(tc => {
|
||||
const name = tc.function?.name || tc.name || 'unknown';
|
||||
const args = tc.function?.arguments || tc.arguments || tc.input || '';
|
||||
const result = tc.result || '';
|
||||
const isError = tc.is_error || false;
|
||||
const tcId = tc.id || '';
|
||||
|
||||
const statusCls = isError ? 'tool-error' : 'tool-done';
|
||||
const statusText = isError ? 'error' : 'done';
|
||||
|
||||
// Check for note tool — add view link
|
||||
let noteLink = '';
|
||||
if (name.startsWith('note_') && result) {
|
||||
try {
|
||||
const parsed = typeof result === 'string' ? JSON.parse(result) : result;
|
||||
if (parsed.id) {
|
||||
noteLink = `<button class="tool-note-link" onclick="openNotes(); setTimeout(() => openNoteEditor('${esc(parsed.id)}'), 300)">📝 View</button>`;
|
||||
}
|
||||
} catch (e) { /* not JSON */ }
|
||||
}
|
||||
|
||||
// Build collapsible detail
|
||||
let detailHTML = '';
|
||||
if (args || result) {
|
||||
const argsStr = typeof args === 'string' ? args : JSON.stringify(args, null, 2);
|
||||
const resultStr = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
||||
detailHTML = `<div class="tool-detail">`;
|
||||
if (argsStr) detailHTML += `<div class="tool-detail-section"><span class="tool-detail-label">Input</span><pre class="tool-detail-pre">${esc(argsStr)}</pre></div>`;
|
||||
if (resultStr) detailHTML += `<div class="tool-detail-section"><span class="tool-detail-label">Output</span><pre class="tool-detail-pre">${esc(resultStr)}</pre></div>`;
|
||||
detailHTML += `</div>`;
|
||||
}
|
||||
|
||||
return `<details class="tool-call-block">
|
||||
<summary class="tool-call-summary">
|
||||
<span class="tool-icon">🔧</span>
|
||||
<span class="tool-name">${esc(name)}</span>
|
||||
<span class="tool-status ${statusCls}">${statusText}</span>
|
||||
${noteLink}
|
||||
</summary>
|
||||
${detailHTML}
|
||||
</details>`;
|
||||
});
|
||||
|
||||
return `<div class="msg-tools">${items.join('')}</div>`;
|
||||
}
|
||||
|
||||
function _relativeTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = (now - d) / 1000;
|
||||
|
||||
if (diff < 60) return 'now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
640
src/js/ui-settings.js
Normal file
640
src/js/ui-settings.js
Normal file
@@ -0,0 +1,640 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – UI Settings & Teams
|
||||
// ==========================================
|
||||
// Extends UI with settings modal tabs, team management,
|
||||
// providers, usage, model roles, and user preferences.
|
||||
|
||||
Object.assign(UI, {
|
||||
// ── Appearance Settings ─────────────────
|
||||
|
||||
loadAppearanceSettings() {
|
||||
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||
const scale = prefs.scale || 100;
|
||||
const msgFont = prefs.msgFont || 14;
|
||||
|
||||
const scaleEl = document.getElementById('settingsScale');
|
||||
const msgFontEl = document.getElementById('settingsMsgFont');
|
||||
if (scaleEl) { scaleEl.value = scale; document.getElementById('scaleValue').textContent = scale + '%'; }
|
||||
if (msgFontEl) { msgFontEl.value = msgFont; document.getElementById('msgFontValue').textContent = msgFont + 'px'; }
|
||||
},
|
||||
|
||||
initAppearance() {
|
||||
// Load saved prefs on startup
|
||||
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||
UI.applyAppearance(prefs.scale || 100, prefs.msgFont || 14);
|
||||
|
||||
// Live preview on slider change
|
||||
const scaleEl = document.getElementById('settingsScale');
|
||||
const msgFontEl = document.getElementById('settingsMsgFont');
|
||||
if (scaleEl) scaleEl.addEventListener('input', () => {
|
||||
const v = parseInt(scaleEl.value);
|
||||
document.getElementById('scaleValue').textContent = v + '%';
|
||||
UI.applyAppearance(v, parseInt(msgFontEl?.value || 14));
|
||||
});
|
||||
if (msgFontEl) msgFontEl.addEventListener('input', () => {
|
||||
const v = parseInt(msgFontEl.value);
|
||||
document.getElementById('msgFontValue').textContent = v + 'px';
|
||||
UI.applyAppearance(parseInt(scaleEl?.value || 100), v);
|
||||
});
|
||||
},
|
||||
|
||||
applyAppearance(scale, msgFont) {
|
||||
const z = scale === 100 ? '' : scale / 100;
|
||||
// Zoom content areas + modals (but NOT .app or banners)
|
||||
document.querySelectorAll('.sidebar, .chat-area, .modal-overlay').forEach(el => el.style.zoom = z);
|
||||
const splash = document.getElementById('splashGate');
|
||||
if (splash) splash.style.zoom = z;
|
||||
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
|
||||
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
|
||||
// Recheck tab overflow after layout settles with new zoom
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow);
|
||||
});
|
||||
},
|
||||
|
||||
async checkUserProvidersAllowed() {
|
||||
const notice = document.getElementById('userProvidersDisabled');
|
||||
const addBtn = document.getElementById('providerShowAddBtn');
|
||||
const tabBtn = document.getElementById('settingsProvidersTabBtn');
|
||||
const usageTabBtn = document.getElementById('settingsUsageTabBtn');
|
||||
const rolesTabBtn = document.getElementById('settingsRolesTabBtn');
|
||||
if (!notice) return;
|
||||
const allowed = App.policies?.allow_user_byok === 'true';
|
||||
notice.style.display = allowed ? 'none' : '';
|
||||
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
||||
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
|
||||
if (usageTabBtn) usageTabBtn.style.display = allowed ? '' : 'none';
|
||||
if (rolesTabBtn) rolesTabBtn.style.display = allowed ? '' : 'none';
|
||||
},
|
||||
|
||||
checkUserPresetsAllowed() {
|
||||
const addBtn = document.getElementById('userAddPresetBtn');
|
||||
const addForm = document.getElementById('userAddPresetForm');
|
||||
const tabBtn = document.getElementById('settingsPersonasTabBtn');
|
||||
const allowed = App.policies?.allow_user_personas === 'true';
|
||||
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
||||
if (addForm && !allowed) addForm.style.display = 'none';
|
||||
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
|
||||
},
|
||||
|
||||
async loadProfileIntoSettings() {
|
||||
try {
|
||||
const p = await API.getProfile();
|
||||
document.getElementById('profileDisplayName').value = p.display_name || '';
|
||||
document.getElementById('profileEmail').value = p.email || '';
|
||||
updateAvatarPreview(p.avatar || null);
|
||||
} catch (e) { /* optional */ }
|
||||
},
|
||||
|
||||
async loadMyTeams() {
|
||||
const section = document.getElementById('settingsTeamsSection');
|
||||
const el = document.getElementById('settingsTeamsList');
|
||||
const menuBtn = document.getElementById('menuTeamAdmin');
|
||||
if (!section || !el) return;
|
||||
try {
|
||||
const resp = await API.listMyTeams();
|
||||
const teams = resp.data || [];
|
||||
UI._myTeams = teams;
|
||||
|
||||
// Show/hide the Team Management flyout item for team admins
|
||||
const isTeamAdmin = teams.some(t => t.my_role === 'admin');
|
||||
if (menuBtn) menuBtn.style.display = isTeamAdmin ? '' : 'none';
|
||||
|
||||
if (teams.length === 0) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
section.style.display = '';
|
||||
el.innerHTML = teams.map(t => `
|
||||
<div class="team-card"${t.my_role === 'admin' ? ` style="cursor:pointer" onclick="UI.openTeamManage('${t.id}', '${esc(t.name)}')"` : ''}>
|
||||
<div class="team-card-info">
|
||||
<strong>${esc(t.name)}</strong>
|
||||
<span class="badge-${t.my_role === 'admin' ? 'admin' : 'user'}">${t.my_role}</span>
|
||||
${t.my_role === 'admin' ? '<span style="margin-left:auto;color:var(--text-3);font-size:12px">Manage →</span>' : ''}
|
||||
</div>
|
||||
${t.description ? `<div class="text-muted" style="font-size:12px">${esc(t.description)}</div>` : ''}
|
||||
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) { section.style.display = 'none'; if (menuBtn) menuBtn.style.display = 'none'; }
|
||||
},
|
||||
|
||||
_myTeams: [],
|
||||
_managingTeamId: null,
|
||||
|
||||
loadTeamsTab() {
|
||||
// No longer used — picker is in team admin modal
|
||||
},
|
||||
|
||||
async openTeamManage(teamId, teamName) {
|
||||
UI._managingTeamId = teamId;
|
||||
UI._teamAuditPage = 1;
|
||||
|
||||
// If the modal isn't open yet (called from settings "Manage →"), open it
|
||||
if (!document.getElementById('teamAdminModal').classList.contains('active')) {
|
||||
openModal('teamAdminModal');
|
||||
}
|
||||
|
||||
// Show tabbed content, hide picker
|
||||
const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin');
|
||||
const titleEl = document.getElementById('teamAdminTitle');
|
||||
if (adminTeams.length > 1) {
|
||||
titleEl.innerHTML = `<span class="team-admin-back" onclick="UI.showTeamPicker()" title="Back to teams">←</span> ${esc(teamName)}`;
|
||||
} else {
|
||||
titleEl.textContent = teamName;
|
||||
}
|
||||
document.getElementById('teamAdminPicker').style.display = 'none';
|
||||
document.getElementById('teamAdminContent').style.display = '';
|
||||
|
||||
// Reset forms
|
||||
document.getElementById('settingsTeamAddMember').style.display = 'none';
|
||||
document.getElementById('settingsTeamAddPreset').style.display = 'none';
|
||||
document.getElementById('settingsTeamAddProvider').style.display = 'none';
|
||||
document.getElementById('settingsTeamEditProvider').style.display = 'none';
|
||||
const af = document.getElementById('teamAuditFilterAction');
|
||||
if (af) { af.innerHTML = '<option value="">All actions</option>'; }
|
||||
|
||||
// Show Members tab first, load its data
|
||||
UI.switchTeamTab('members');
|
||||
},
|
||||
|
||||
showTeamPicker() {
|
||||
document.getElementById('teamAdminTitle').textContent = 'Team Management';
|
||||
document.getElementById('teamAdminContent').style.display = 'none';
|
||||
document.getElementById('teamAdminPicker').style.display = '';
|
||||
},
|
||||
|
||||
async loadTeamManageMembers(teamId) {
|
||||
const el = document.getElementById('settingsTeamMembers');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const resp = await API.teamListMembers(teamId);
|
||||
const members = resp.data || [];
|
||||
el.innerHTML = members.map(m => `
|
||||
<div class="admin-user-row" style="padding:6px 0">
|
||||
<div class="admin-user-info">
|
||||
<strong style="font-size:13px">${esc(m.display_name || m.email)}</strong>
|
||||
<span class="badge-${m.role === 'admin' ? 'admin' : 'user'}" style="font-size:10px">${m.role}</span>
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
<select onchange="settingsUpdateTeamMember('${teamId}','${m.id}',this.value)" class="inline-select">
|
||||
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
|
||||
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Admin</option>
|
||||
</select>
|
||||
<button class="btn-delete" onclick="settingsRemoveTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty-hint">No members</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadTeamManageProviders(teamId) {
|
||||
const el = document.getElementById('settingsTeamProviders');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
const addBtn = document.getElementById('settingsTeamAddProviderBtn');
|
||||
try {
|
||||
const resp = await API.teamListProviders(teamId);
|
||||
const provs = resp.providers || [];
|
||||
const allowed = resp.allow_team_providers !== false;
|
||||
|
||||
// Show/hide add button and entire section based on policy
|
||||
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
||||
|
||||
if (!allowed && provs.length === 0) {
|
||||
el.innerHTML = '<div class="empty-hint">Team provider management is disabled by your administrator</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = provs.map(p => {
|
||||
const statusCls = p.is_active ? 'badge-admin' : 'badge-user';
|
||||
const statusLabel = p.is_active ? 'active' : 'inactive';
|
||||
const privateBadge = p.is_private ? ' <span class="badge-user" style="font-size:9px">🔒 private</span>' : '';
|
||||
return `<div class="admin-preset-row" style="padding:6px 0">
|
||||
<div class="preset-info">
|
||||
<strong>${esc(p.name)}</strong>
|
||||
<div class="preset-meta" style="font-size:11px">${esc(p.provider)} · ${p.has_key ? '🔑' : 'no key'} <span class="${statusCls}" style="font-size:10px">${statusLabel}</span>${privateBadge}</div>
|
||||
</div>
|
||||
<div class="preset-actions">
|
||||
<button class="btn-small" onclick="settingsEditTeamProvider('${teamId}','${p.id}','${esc(p.name)}','${esc(p.endpoint || '')}','${esc(p.model_default || '')}',${!!p.is_private})" title="Edit">✎</button>
|
||||
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="settingsToggleTeamProvider('${teamId}','${p.id}',${p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
|
||||
<button class="btn-delete" onclick="settingsDeleteTeamProvider('${teamId}','${p.id}','${esc(p.name)}')" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No team providers — add one to give your team access to additional models</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadTeamManagePresets(teamId) {
|
||||
const el = document.getElementById('settingsTeamPresets');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const resp = await API.teamListPresets(teamId);
|
||||
const presets = resp.presets || [];
|
||||
el.innerHTML = presets.map(p => {
|
||||
const icon = p.icon || '';
|
||||
return `<div class="admin-preset-row" style="padding:6px 0">
|
||||
<div class="preset-info">
|
||||
<strong>${icon ? icon + ' ' : ''}${esc(p.name)}</strong>
|
||||
<div class="preset-meta" style="font-size:11px">${esc(p.base_model_id)}</div>
|
||||
</div>
|
||||
<button class="btn-delete" onclick="settingsDeleteTeamPreset('${teamId}','${p.id}','${esc(p.name)}')" title="Delete">✕</button>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No team presets — create one to give your team preconfigured models</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadMyUsage() {
|
||||
const totalsEl = document.getElementById('myUsageTotals');
|
||||
const resultsEl = document.getElementById('myUsageResults');
|
||||
if (!totalsEl) return;
|
||||
|
||||
const period = document.getElementById('myUsagePeriod')?.value || '30d';
|
||||
const groupBy = document.getElementById('myUsageGroupBy')?.value || 'model';
|
||||
|
||||
totalsEl.innerHTML = '<div class="loading" style="font-size:12px">Loading...</div>';
|
||||
resultsEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
const data = await API.getMyUsage({ period, group_by: groupBy });
|
||||
const t = data.totals || {};
|
||||
|
||||
if (!t.requests) {
|
||||
totalsEl.innerHTML = '<div class="empty-hint">No usage recorded in this period</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
totalsEl.innerHTML = `
|
||||
<div class="stats-grid" style="grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px">
|
||||
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.requests || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Requests</div></div>
|
||||
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Input</div></div>
|
||||
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Output</div></div>
|
||||
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label" style="font-size:10px">Est. Cost</div></div>
|
||||
</div>`;
|
||||
|
||||
const results = data.results || [];
|
||||
if (results.length > 0) {
|
||||
resultsEl.innerHTML = `
|
||||
<table class="admin-table" style="font-size:12px">
|
||||
<thead><tr>
|
||||
<th>${groupBy === 'day' ? 'Date' : 'Model'}</th>
|
||||
<th style="text-align:right">Reqs</th>
|
||||
<th style="text-align:right">Input</th>
|
||||
<th style="text-align:right">Output</th>
|
||||
<th style="text-align:right">Cost</th>
|
||||
</tr></thead>
|
||||
<tbody>${results.map(r => `<tr>
|
||||
<td>${esc(r.label || r.group_key)}</td>
|
||||
<td style="text-align:right">${(r.requests || 0).toLocaleString()}</td>
|
||||
<td style="text-align:right">${(r.input_tokens || 0).toLocaleString()}</td>
|
||||
<td style="text-align:right">${(r.output_tokens || 0).toLocaleString()}</td>
|
||||
<td style="text-align:right">$${(r.total_cost || 0).toFixed(4)}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
} catch (e) { totalsEl.innerHTML = `<div class="error-hint" style="font-size:12px">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadTeamUsage() {
|
||||
const teamId = UI._managingTeamId;
|
||||
if (!teamId) return;
|
||||
|
||||
const totalsEl = document.getElementById('settingsTeamUsageTotals');
|
||||
const resultsEl = document.getElementById('settingsTeamUsageResults');
|
||||
if (!totalsEl) return;
|
||||
|
||||
const period = document.getElementById('teamUsagePeriod')?.value || '30d';
|
||||
const groupBy = document.getElementById('teamUsageGroupBy')?.value || 'model';
|
||||
|
||||
totalsEl.innerHTML = '<div class="loading" style="font-size:12px">Loading...</div>';
|
||||
resultsEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
const data = await API.teamGetUsage(teamId, { period, group_by: groupBy });
|
||||
const t = data.totals || {};
|
||||
|
||||
if (!t.requests) {
|
||||
totalsEl.innerHTML = '<div class="empty-hint">No usage recorded for team providers in this period</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
totalsEl.innerHTML = `
|
||||
<div class="stats-grid" style="grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px">
|
||||
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.requests || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Requests</div></div>
|
||||
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.input_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Input</div></div>
|
||||
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">${(t.output_tokens || 0).toLocaleString()}</div><div class="stat-label" style="font-size:10px">Output</div></div>
|
||||
<div class="stat-card" style="padding:8px"><div class="stat-value" style="font-size:16px">$${(t.total_cost || 0).toFixed(4)}</div><div class="stat-label" style="font-size:10px">Est. Cost</div></div>
|
||||
</div>`;
|
||||
|
||||
const results = data.results || [];
|
||||
if (results.length > 0) {
|
||||
resultsEl.innerHTML = `
|
||||
<table class="admin-table" style="font-size:12px">
|
||||
<thead><tr>
|
||||
<th>${groupBy === 'day' ? 'Date' : groupBy === 'user' ? 'User' : 'Model'}</th>
|
||||
<th style="text-align:right">Reqs</th>
|
||||
<th style="text-align:right">Input</th>
|
||||
<th style="text-align:right">Output</th>
|
||||
<th style="text-align:right">Cost</th>
|
||||
</tr></thead>
|
||||
<tbody>${results.map(r => `<tr>
|
||||
<td>${esc(r.label || r.group_key)}</td>
|
||||
<td style="text-align:right">${(r.requests || 0).toLocaleString()}</td>
|
||||
<td style="text-align:right">${(r.input_tokens || 0).toLocaleString()}</td>
|
||||
<td style="text-align:right">${(r.output_tokens || 0).toLocaleString()}</td>
|
||||
<td style="text-align:right">$${(r.total_cost || 0).toFixed(4)}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
} catch (e) { totalsEl.innerHTML = `<div class="error-hint" style="font-size:12px">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
_teamAuditPage: 1,
|
||||
_teamAuditPerPage: 20,
|
||||
|
||||
// ── User Model Roles (BYOK overrides) ──
|
||||
|
||||
async loadUserRoles() {
|
||||
const el = document.getElementById('userRolesConfig');
|
||||
const notice = document.getElementById('userRolesDisabled');
|
||||
const tabBtn = document.getElementById('settingsRolesTabBtn');
|
||||
if (!el) return;
|
||||
|
||||
// Only show if BYOK is enabled
|
||||
const byokAllowed = App.policies?.allow_user_byok === 'true';
|
||||
if (tabBtn) tabBtn.style.display = byokAllowed ? '' : 'none';
|
||||
if (!byokAllowed) return;
|
||||
|
||||
try {
|
||||
// Get user's personal providers and their models
|
||||
const configs = await API.listConfigs();
|
||||
const configList = configs.configs || configs.data || [];
|
||||
const personalProviders = configList.filter(c => c.scope === 'personal');
|
||||
|
||||
if (personalProviders.length === 0) {
|
||||
el.style.display = 'none';
|
||||
if (notice) notice.style.display = '';
|
||||
return;
|
||||
}
|
||||
el.style.display = '';
|
||||
if (notice) notice.style.display = 'none';
|
||||
|
||||
// Load all models for personal providers
|
||||
const allModels = App.models || [];
|
||||
const personalModels = allModels.filter(m =>
|
||||
personalProviders.some(p => p.id === m.configId)
|
||||
);
|
||||
|
||||
// Load current user settings to get existing role overrides
|
||||
const settings = await API.getSettings();
|
||||
const userRoles = settings.model_roles || {};
|
||||
|
||||
const roleNames = ['utility', 'embedding'];
|
||||
el.innerHTML = roleNames.map(role => {
|
||||
const cfg = userRoles[role] || { primary: null };
|
||||
return `
|
||||
<div class="settings-section" style="margin-bottom:12px">
|
||||
<h4 style="font-size:13px;margin:0 0 6px;text-transform:capitalize">${role}
|
||||
<span class="form-hint">${role === 'utility' ? '(summarization, title gen)' : '(future: KB search, note search)'}</span>
|
||||
</h4>
|
||||
<div class="form-row" style="gap:8px;align-items:center">
|
||||
<label style="min-width:60px;font-size:12px">Provider</label>
|
||||
<select id="userRole-${role}-provider" onchange="userRoleProviderChanged('${role}')" style="min-width:140px;font-size:12px">
|
||||
<option value="">— use org default —</option>
|
||||
${personalProviders.map(p => `<option value="${p.id}" ${cfg.primary?.provider_config_id === p.id ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
|
||||
</select>
|
||||
<select id="userRole-${role}-model" style="min-width:200px;font-size:12px">
|
||||
<option value="">— select model —</option>
|
||||
${cfg.primary ? personalModels.filter(m => m.configId === cfg.primary.provider_config_id).map(m => `<option value="${m.baseModelId}" ${m.baseModelId === cfg.primary.model_id ? 'selected' : ''}>${esc(m.name || m.baseModelId)}</option>`).join('') : ''}
|
||||
</select>
|
||||
<button class="btn-small" onclick="saveUserRole('${role}')" style="font-size:11px">Save</button>
|
||||
${cfg.primary ? `<button class="btn-small btn-danger" onclick="clearUserRole('${role}')" style="font-size:11px" title="Remove override, use org default">Clear</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
async loadTeamAuditLog(page) {
|
||||
const teamId = UI._managingTeamId;
|
||||
if (!teamId) return;
|
||||
if (page) UI._teamAuditPage = page;
|
||||
|
||||
const el = document.getElementById('settingsTeamAudit');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
|
||||
// Populate action filter on first load
|
||||
const actionSel = document.getElementById('teamAuditFilterAction');
|
||||
if (actionSel && actionSel.options.length <= 1) {
|
||||
try {
|
||||
const resp = await API.teamListAuditActions(teamId);
|
||||
(resp.actions || []).forEach(a => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a;
|
||||
opt.textContent = a;
|
||||
actionSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { /* optional */ }
|
||||
}
|
||||
|
||||
const params = {
|
||||
page: UI._teamAuditPage,
|
||||
per_page: UI._teamAuditPerPage,
|
||||
action: document.getElementById('teamAuditFilterAction')?.value || '',
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await API.teamListAudit(teamId, params);
|
||||
const entries = resp.data || [];
|
||||
const total = resp.total || 0;
|
||||
const totalPages = Math.ceil(total / UI._teamAuditPerPage);
|
||||
|
||||
if (entries.length === 0) {
|
||||
el.innerHTML = '<div class="empty-hint">No activity recorded for this team</div>';
|
||||
} else {
|
||||
el.innerHTML = entries.map(e => {
|
||||
const ts = new Date(e.created_at);
|
||||
const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
|
||||
const actor = e.actor_name || 'system';
|
||||
const meta = UI._formatAuditMeta(e.metadata);
|
||||
return `<div class="audit-row">
|
||||
<div class="audit-main">
|
||||
<span class="audit-action">${esc(e.action)}</span>
|
||||
<span class="audit-actor">${esc(actor)}</span>
|
||||
<span class="audit-resource">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</span>
|
||||
</div>
|
||||
<div class="audit-meta">
|
||||
${meta ? `<span class="audit-details">${meta}</span>` : ''}
|
||||
<span class="audit-time">${timeStr}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const pgEl = document.getElementById('teamAuditPagination');
|
||||
if (totalPages > 1) {
|
||||
pgEl.style.display = 'flex';
|
||||
document.getElementById('teamAuditPageInfo').textContent = `Page ${UI._teamAuditPage} of ${totalPages} (${total} entries)`;
|
||||
document.getElementById('teamAuditPrevBtn').disabled = UI._teamAuditPage <= 1;
|
||||
document.getElementById('teamAuditNextBtn').disabled = UI._teamAuditPage >= totalPages;
|
||||
} else {
|
||||
pgEl.style.display = 'none';
|
||||
}
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadTeamPresetModelDropdown(teamId) {
|
||||
const sel = document.getElementById('teamPreset_model');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '<option value="">Loading models...</option>';
|
||||
try {
|
||||
const resp = await API.teamListAvailableModels(teamId);
|
||||
const models = resp.models || [];
|
||||
sel.innerHTML = '<option value="">Select base model...</option>';
|
||||
|
||||
// Group by source for clarity
|
||||
const globalModels = models.filter(m => m.source !== 'team');
|
||||
const teamModels = models.filter(m => m.source === 'team');
|
||||
|
||||
const addGroup = (label, items) => {
|
||||
if (!items.length) return;
|
||||
const group = document.createElement('optgroup');
|
||||
group.label = label;
|
||||
items.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.model_id || m.id;
|
||||
const vis = m.visibility === 'team' ? ' [team-only]' : '';
|
||||
opt.textContent = (m.model_id || m.id) + (m.provider_name ? ` (${m.provider_name})` : '') + vis;
|
||||
group.appendChild(opt);
|
||||
});
|
||||
sel.appendChild(group);
|
||||
};
|
||||
|
||||
addGroup('Global Models', globalModels);
|
||||
addGroup('Team Provider Models', teamModels);
|
||||
} catch (e) {
|
||||
// Fallback to App.models if team endpoint fails
|
||||
sel.innerHTML = '<option value="">Select base model...</option>';
|
||||
App.models.filter(m => !m.isPreset).forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.baseModelId || m.id;
|
||||
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// ── Providers ────────────────────────────
|
||||
|
||||
async loadProviderList() {
|
||||
const el = document.getElementById('providerList');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const data = await API.listConfigs();
|
||||
const list = Array.isArray(data) ? data : (data.configs || data.data || []);
|
||||
if (list.length === 0) {
|
||||
el.innerHTML = '<div class="empty-hint">No providers configured.</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = list.map(c => `
|
||||
<div class="provider-row">
|
||||
<div>
|
||||
<span class="provider-name">${esc(c.name)}</span>
|
||||
<span class="provider-meta">${esc(c.provider)} · ${esc(c.model_default || 'no default')}</span>
|
||||
</div>
|
||||
<div class="provider-actions">
|
||||
${c.has_key ? '🔑' : '⚠️'}
|
||||
<button class="btn-small" onclick="refreshProviderModels('${c.id}','${esc(c.name)}')">Refresh Models</button>
|
||||
<button class="btn-small" onclick="editProvider('${c.id}')">Edit</button>
|
||||
<button class="btn-small btn-danger" onclick="deleteProvider('${c.id}','${esc(c.name)}')">Remove</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
showProviderForm() { document.getElementById('providerAddForm').style.display = ''; },
|
||||
hideProviderForm() {
|
||||
document.getElementById('providerAddForm').style.display = 'none';
|
||||
UI._editingProviderId = null;
|
||||
document.getElementById('providerApiKey').placeholder = 'API key';
|
||||
},
|
||||
|
||||
// ── User Model List ─────────────────────
|
||||
|
||||
async loadUserModels() {
|
||||
const el = document.getElementById('userModelList');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="loading">Loading models...</div>';
|
||||
try {
|
||||
// Load preferences if not already loaded
|
||||
if (!App.hiddenModels) {
|
||||
try {
|
||||
const prefData = await API.getModelPreferences();
|
||||
App.hiddenModels = new Set(
|
||||
(prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id)
|
||||
);
|
||||
} catch (e) { App.hiddenModels = new Set(); }
|
||||
}
|
||||
|
||||
const data = await API.listEnabledModels();
|
||||
const models = (data.models || []).filter(m => !m.is_preset);
|
||||
if (!models.length) {
|
||||
el.innerHTML = '<div class="empty-hint">No models available</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = models.map(m => {
|
||||
const mid = m.model_id || m.id;
|
||||
const caps = m.capabilities || {};
|
||||
const badges = [];
|
||||
if (caps.max_output_tokens > 0) badges.push(`<span class="cap-badge cap-context">${(caps.max_output_tokens/1000).toFixed(0)}K</span>`);
|
||||
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
|
||||
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
|
||||
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
|
||||
const src = m.source === 'personal' ? '<span class="badge-user" style="font-size:10px">personal</span>'
|
||||
: m.source === 'team' ? `<span class="badge-team" style="font-size:10px">👥 ${esc(m.team_name || 'team')}</span>` : '';
|
||||
const hidden = App.hiddenModels.has(mid);
|
||||
const toggleCls = hidden ? '' : 'enabled';
|
||||
const toggleLabel = hidden ? 'Hidden' : '✓ Visible';
|
||||
return `<div class="model-list-item ${hidden ? 'model-hidden' : ''}">
|
||||
<span class="model-name">${esc(mid)}</span>
|
||||
<span class="model-caps-inline">${badges.join('')}</span>
|
||||
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
|
||||
${src}
|
||||
<button class="admin-model-toggle ${toggleCls}" onclick="toggleUserModelVisibility('${esc(mid)}', ${hidden})" title="${hidden ? 'Show in selector' : 'Hide from selector'}">${toggleLabel}</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadUserPresets() {
|
||||
const el = document.getElementById('userPresetList');
|
||||
if (!el) return;
|
||||
try {
|
||||
const data = await API.listUserPresets();
|
||||
// Only show personal presets owned by user
|
||||
const presets = (data.presets || []).filter(p => p.scope === 'personal');
|
||||
if (!presets.length) {
|
||||
el.innerHTML = '<div class="empty-hint">No personal presets yet</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = presets.map(p => {
|
||||
const avatarEl = p.avatar ? `<img src="${p.avatar}" class="preset-row-avatar" alt="">` : '';
|
||||
return `<div class="admin-preset-row" style="padding:6px 0">
|
||||
<div class="preset-info">
|
||||
<strong>${avatarEl}${esc(p.name)}</strong>
|
||||
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
|
||||
</div>
|
||||
<div class="preset-actions">
|
||||
<button class="btn-delete" onclick="deleteUserPreset('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
});
|
||||
10
src/sw.js
10
src/sw.js
@@ -15,7 +15,15 @@ const SHELL_FILES = [
|
||||
'./js/debug.js',
|
||||
'./js/events.js',
|
||||
'./js/api.js',
|
||||
'./js/ui.js',
|
||||
'./js/ui-format.js',
|
||||
'./js/ui-core.js',
|
||||
'./js/ui-settings.js',
|
||||
'./js/ui-admin.js',
|
||||
'./js/tokens.js',
|
||||
'./js/notes.js',
|
||||
'./js/chat.js',
|
||||
'./js/settings-handlers.js',
|
||||
'./js/admin-handlers.js',
|
||||
'./js/app.js',
|
||||
'./vendor/marked.min.js',
|
||||
'./vendor/purify.min.js',
|
||||
|
||||
Reference in New Issue
Block a user