Changeset 0.13.1 (#65)
This commit is contained in:
@@ -1948,6 +1948,55 @@ select option { background: var(--bg-surface); color: var(--text); }
|
||||
.popup-menu-hint { margin-left: auto; font-size: 11px; color: var(--text-3); }
|
||||
.popup-menu-divider { height: 1px; background: var(--border); margin: 4px 8px; }
|
||||
|
||||
/* ── Tools Toggle ────────────────────────── */
|
||||
|
||||
.tools-toggle-wrap { position: relative; }
|
||||
.tools-toggle-btn { padding: 6px 4px 12px 4px; }
|
||||
.tools-toggle-btn.has-disabled { color: var(--text-3); }
|
||||
.tools-toggle-btn.has-disabled::after {
|
||||
content: ''; position: absolute; top: 4px; right: 4px;
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--warning);
|
||||
}
|
||||
.tools-popup { bottom: 100%; left: 0; margin-bottom: 8px; min-width: 210px; }
|
||||
.tools-popup-category, .tools-popup-tool {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px; border-radius: var(--radius);
|
||||
cursor: pointer; font-size: 13px; color: var(--text-2);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.tools-popup-category:hover, .tools-popup-tool:hover { background: var(--bg-hover); }
|
||||
.tools-popup-tool { padding-left: 36px; font-size: 12px; } /* indented children */
|
||||
.tools-popup-check {
|
||||
width: 16px; height: 16px; border-radius: 3px;
|
||||
border: 1.5px solid var(--border); flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background var(--transition), border-color var(--transition);
|
||||
}
|
||||
/* Fully enabled */
|
||||
.tools-popup-category.enabled .tools-popup-check,
|
||||
.tools-popup-tool.enabled .tools-popup-check {
|
||||
background: var(--accent); border-color: var(--accent);
|
||||
}
|
||||
.tools-popup-category.enabled .tools-popup-check::after,
|
||||
.tools-popup-tool.enabled .tools-popup-check::after {
|
||||
content: '✓'; color: var(--text-on-color); font-size: 11px; font-weight: 700;
|
||||
}
|
||||
/* Partial (indeterminate) */
|
||||
.tools-popup-category.partial .tools-popup-check {
|
||||
background: var(--accent-dim); border-color: var(--accent);
|
||||
}
|
||||
.tools-popup-category.partial .tools-popup-check::after {
|
||||
content: '─'; color: var(--accent); font-size: 12px; font-weight: 700;
|
||||
}
|
||||
.tools-popup-label { flex: 1; }
|
||||
.tools-popup-expand {
|
||||
font-size: 14px; color: var(--text-3); cursor: pointer;
|
||||
transition: transform var(--transition); padding: 0 2px;
|
||||
user-select: none;
|
||||
}
|
||||
.tools-popup-expand.open { transform: rotate(90deg); }
|
||||
|
||||
/* ── Responsive ──────────────────────────── */
|
||||
|
||||
.mobile-menu-btn {
|
||||
|
||||
@@ -153,6 +153,12 @@
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
|
||||
</button>
|
||||
<input type="file" id="fileInput" multiple style="display:none">
|
||||
<div class="tools-toggle-wrap" style="display:none">
|
||||
<button class="action-btn tools-toggle-btn" id="toolsToggleBtn" title="Tools">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||
</button>
|
||||
<div class="popup-menu tools-popup" id="toolsPopup"></div>
|
||||
</div>
|
||||
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
|
||||
<div class="input-actions">
|
||||
<button class="action-btn stop-btn" id="stopBtn" title="Stop">
|
||||
@@ -720,6 +726,30 @@
|
||||
<div class="banner-preview" id="bannerPreview">PREVIEW</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-section">
|
||||
<h3>Web Search</h3>
|
||||
<div class="form-group">
|
||||
<label>Search Provider</label>
|
||||
<select id="adminSearchProvider">
|
||||
<option value="duckduckgo">DuckDuckGo (no API key)</option>
|
||||
<option value="searxng">SearXNG (self-hosted)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="searxngConfigFields" style="display:none">
|
||||
<div class="form-group"><label>SearXNG Endpoint</label><input type="text" id="adminSearchEndpoint" placeholder="https://search.internal.example.com"></div>
|
||||
<div class="form-group"><label>API Key (optional)</label><input type="text" id="adminSearchApiKey" placeholder="Leave blank if not required"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max Results per Search</label>
|
||||
<select id="adminSearchMaxResults">
|
||||
<option value="3">3</option>
|
||||
<option value="5" selected>5</option>
|
||||
<option value="8">8</option>
|
||||
<option value="10">10</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="section-hint">Web search and URL fetch tools allow AI to look up current information. DuckDuckGo works out of the box. SearXNG is ideal for airgapped deployments.</p>
|
||||
</section>
|
||||
<section class="admin-section">
|
||||
<h4>🔐 Encryption</h4>
|
||||
<div id="adminVaultStatus"><span class="empty-hint">Loading…</span></div>
|
||||
@@ -869,6 +899,7 @@
|
||||
<script src="js/tokens.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/notes.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/attachments.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/tools-toggle.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>
|
||||
|
||||
@@ -168,11 +168,12 @@ const API = {
|
||||
return this._post(`/api/v1/channels/${channelId}/messages/${messageId}/edit`, { content });
|
||||
},
|
||||
|
||||
async streamRegenerate(channelId, messageId, signal, model, presetId, apiConfigId) {
|
||||
async streamRegenerate(channelId, messageId, signal, model, presetId, apiConfigId, disabledTools) {
|
||||
const body = {};
|
||||
if (model) body.model = model;
|
||||
if (presetId) body.preset_id = presetId;
|
||||
if (apiConfigId) body.provider_config_id = apiConfigId;
|
||||
if (disabledTools?.length) body.disabled_tools = disabledTools;
|
||||
|
||||
let resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
|
||||
method: 'POST',
|
||||
@@ -226,7 +227,7 @@ const API = {
|
||||
|
||||
// ── Completions ──────────────────────────
|
||||
|
||||
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId, attachmentIds) {
|
||||
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId, attachmentIds, disabledTools) {
|
||||
const body = { channel_id: channelId, content, stream: true };
|
||||
if (presetId) {
|
||||
body.preset_id = presetId;
|
||||
@@ -239,6 +240,7 @@ const API = {
|
||||
// Only send max_tokens if user explicitly set it (non-zero = override)
|
||||
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
|
||||
if (attachmentIds?.length) body.attachment_ids = attachmentIds;
|
||||
if (disabledTools?.length) body.disabled_tools = disabledTools;
|
||||
|
||||
let resp = await fetch(BASE + '/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
@@ -278,6 +280,10 @@ const API = {
|
||||
return resp;
|
||||
},
|
||||
|
||||
// ── Tools ────────────────────────────────
|
||||
|
||||
getTools() { return this._get('/api/v1/tools'); },
|
||||
|
||||
// ── Models ───────────────────────────────
|
||||
|
||||
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
|
||||
|
||||
@@ -187,6 +187,7 @@ async function startApp() {
|
||||
|
||||
await initBanners();
|
||||
initAttachments();
|
||||
if (typeof ToolsToggle !== 'undefined') ToolsToggle.init();
|
||||
UI.renderChatList();
|
||||
UI.updateModelSelector();
|
||||
UI.updateUser();
|
||||
|
||||
@@ -294,7 +294,8 @@ async function sendMessage() {
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId, attachmentIds);
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId, attachmentIds,
|
||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []);
|
||||
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
||||
|
||||
// Reload active path from server to get proper IDs and sibling metadata
|
||||
@@ -391,7 +392,8 @@ async function regenerateMessage(messageId) {
|
||||
try {
|
||||
const resp = await API.streamRegenerate(
|
||||
App.currentChatId, messageId, App.abortController.signal,
|
||||
model, presetId, modelInfo?.configId
|
||||
model, presetId, modelInfo?.configId,
|
||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
|
||||
);
|
||||
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
|
||||
|
||||
@@ -475,7 +477,8 @@ async function submitEdit(messageId, newContent) {
|
||||
// message that streamCompletion would create.
|
||||
const resp = await API.streamRegenerate(
|
||||
App.currentChatId, editResp.id, App.abortController.signal,
|
||||
model, presetId, modelInfo?.configId
|
||||
model, presetId, modelInfo?.configId,
|
||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
|
||||
);
|
||||
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
|
||||
|
||||
|
||||
@@ -195,6 +195,15 @@ async function handleSaveAdminSettings() {
|
||||
};
|
||||
await API.adminUpdateSetting('banner', { value: banner });
|
||||
|
||||
// Web Search config → global_settings (JSON value)
|
||||
const searchConfig = {
|
||||
provider: document.getElementById('adminSearchProvider').value,
|
||||
endpoint: document.getElementById('adminSearchEndpoint').value.trim(),
|
||||
api_key: document.getElementById('adminSearchApiKey').value.trim(),
|
||||
max_results: parseInt(document.getElementById('adminSearchMaxResults').value) || 5,
|
||||
};
|
||||
await API.adminUpdateSetting('search_config', { value: searchConfig });
|
||||
|
||||
UI.toast('Settings saved', 'success');
|
||||
// Live-apply: refresh policies and dependent UI
|
||||
await initBanners();
|
||||
@@ -582,6 +591,11 @@ function _initSettingsListeners() {
|
||||
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 — search provider controls
|
||||
document.getElementById('adminSearchProvider')?.addEventListener('change', (e) => {
|
||||
document.getElementById('searxngConfigFields').style.display = e.target.value === 'searxng' ? '' : 'none';
|
||||
});
|
||||
|
||||
// Admin — audit log
|
||||
document.getElementById('auditRefreshBtn')?.addEventListener('click', () => UI.loadAuditLog(1));
|
||||
document.getElementById('auditFilterAction')?.addEventListener('change', () => UI.loadAuditLog(1));
|
||||
|
||||
222
src/js/tools-toggle.js
Normal file
222
src/js/tools-toggle.js
Normal file
@@ -0,0 +1,222 @@
|
||||
// ── tools-toggle.js ─────────────────────────
|
||||
// Tools toggle popup: category-based enable/disable of server-side tools.
|
||||
// Categories expand to show individual tool toggles.
|
||||
// State persisted in localStorage, sent as disabled_tools[] in completion requests.
|
||||
|
||||
'use strict';
|
||||
|
||||
const ToolsToggle = (() => {
|
||||
const STORAGE_KEY = 'cs-disabled-tools';
|
||||
|
||||
// Category display config
|
||||
const CATEGORY_META = {
|
||||
search: { label: 'Web Search', icon: '🔍' },
|
||||
notes: { label: 'Notes', icon: '📝' },
|
||||
utilities: { label: 'Utilities', icon: '🧮' },
|
||||
browser: { label: 'Extensions', icon: '🧩' },
|
||||
};
|
||||
|
||||
// Cached tool manifest from GET /api/v1/tools
|
||||
let _tools = []; // [{name, display_name, description, category}]
|
||||
let _categories = {}; // {category: [{name, displayName}]}
|
||||
let _disabled = new Set();
|
||||
let _expanded = new Set(); // which categories are expanded
|
||||
|
||||
// ── Public API ──────────────────────────
|
||||
|
||||
/** Initialize: load disabled set from storage, fetch tool manifest. */
|
||||
async function init() {
|
||||
_loadDisabled();
|
||||
try {
|
||||
const resp = await API.getTools();
|
||||
_tools = resp.data || [];
|
||||
_buildCategories();
|
||||
_render();
|
||||
_show();
|
||||
} catch (e) {
|
||||
console.warn('Tools toggle: failed to load tools', e);
|
||||
}
|
||||
_wireEvents();
|
||||
}
|
||||
|
||||
/** Returns array of disabled tool names for the completion request. */
|
||||
function getDisabledTools() {
|
||||
return [..._disabled];
|
||||
}
|
||||
|
||||
/** Refresh tool list (e.g. after browser extension connects). */
|
||||
async function refresh() {
|
||||
try {
|
||||
const resp = await API.getTools();
|
||||
_tools = resp.data || [];
|
||||
_buildCategories();
|
||||
_render();
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
// ── Internal ────────────────────────────
|
||||
|
||||
function _loadDisabled() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
_disabled = raw ? new Set(JSON.parse(raw)) : new Set();
|
||||
} catch { _disabled = new Set(); }
|
||||
}
|
||||
|
||||
function _saveDisabled() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([..._disabled]));
|
||||
_updateBtnState();
|
||||
}
|
||||
|
||||
function _buildCategories() {
|
||||
_categories = {};
|
||||
for (const t of _tools) {
|
||||
const cat = t.category || 'other';
|
||||
if (!_categories[cat]) _categories[cat] = [];
|
||||
_categories[cat].push({
|
||||
name: t.name,
|
||||
displayName: t.display_name || t.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns 'all' | 'none' | 'partial' for a category's enabled state. */
|
||||
function _categoryState(cat) {
|
||||
const tools = _categories[cat] || [];
|
||||
if (tools.length === 0) return 'none';
|
||||
const enabledCount = tools.filter(t => !_disabled.has(t.name)).length;
|
||||
if (enabledCount === tools.length) return 'all';
|
||||
if (enabledCount === 0) return 'none';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
function _toggleCategory(cat) {
|
||||
const tools = _categories[cat] || [];
|
||||
const state = _categoryState(cat);
|
||||
if (state === 'all') {
|
||||
// All on → all off
|
||||
tools.forEach(t => _disabled.add(t.name));
|
||||
} else {
|
||||
// Partial or none → all on
|
||||
tools.forEach(t => _disabled.delete(t.name));
|
||||
}
|
||||
_saveDisabled();
|
||||
_render();
|
||||
}
|
||||
|
||||
function _toggleTool(name) {
|
||||
if (_disabled.has(name)) {
|
||||
_disabled.delete(name);
|
||||
} else {
|
||||
_disabled.add(name);
|
||||
}
|
||||
_saveDisabled();
|
||||
_render();
|
||||
}
|
||||
|
||||
function _toggleExpand(cat) {
|
||||
if (_expanded.has(cat)) {
|
||||
_expanded.delete(cat);
|
||||
} else {
|
||||
_expanded.add(cat);
|
||||
}
|
||||
_render();
|
||||
}
|
||||
|
||||
function _show() {
|
||||
const wrap = document.querySelector('.tools-toggle-wrap');
|
||||
if (wrap && _tools.length > 0) {
|
||||
wrap.style.display = '';
|
||||
_updateBtnState();
|
||||
}
|
||||
}
|
||||
|
||||
function _updateBtnState() {
|
||||
const btn = document.getElementById('toolsToggleBtn');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('has-disabled', _disabled.size > 0);
|
||||
const count = _disabled.size;
|
||||
btn.title = count > 0 ? `Tools (${count} disabled)` : 'Tools';
|
||||
}
|
||||
|
||||
function _render() {
|
||||
const popup = document.getElementById('toolsPopup');
|
||||
if (!popup) return;
|
||||
|
||||
const cats = Object.keys(_categories);
|
||||
const order = Object.keys(CATEGORY_META);
|
||||
cats.sort((a, b) => {
|
||||
const ai = order.indexOf(a), bi = order.indexOf(b);
|
||||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
||||
});
|
||||
|
||||
let html = '';
|
||||
for (const cat of cats) {
|
||||
const meta = CATEGORY_META[cat] || { label: cat, icon: '🔧' };
|
||||
const state = _categoryState(cat);
|
||||
const tools = _categories[cat];
|
||||
const expanded = _expanded.has(cat);
|
||||
const stateClass = state === 'all' ? 'enabled' : state === 'partial' ? 'partial' : '';
|
||||
const chevron = tools.length > 1
|
||||
? `<span class="tools-popup-expand ${expanded ? 'open' : ''}" data-expand="${cat}">›</span>`
|
||||
: '';
|
||||
|
||||
html += `<div class="tools-popup-category ${stateClass}" data-category="${cat}">
|
||||
<span class="tools-popup-check"></span>
|
||||
<span class="tools-popup-label">${meta.icon} ${meta.label}</span>
|
||||
${chevron}
|
||||
</div>`;
|
||||
|
||||
// Expanded children
|
||||
if (expanded && tools.length > 1) {
|
||||
for (const t of tools) {
|
||||
const on = !_disabled.has(t.name);
|
||||
html += `<div class="tools-popup-tool ${on ? 'enabled' : ''}" data-tool="${t.name}">
|
||||
<span class="tools-popup-check"></span>
|
||||
<span class="tools-popup-label">${t.displayName}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
popup.innerHTML = html;
|
||||
}
|
||||
|
||||
function _wireEvents() {
|
||||
const btn = document.getElementById('toolsToggleBtn');
|
||||
const popup = document.getElementById('toolsPopup');
|
||||
if (!btn || !popup) return;
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
popup.classList.toggle('open');
|
||||
});
|
||||
|
||||
popup.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Expand/collapse chevron
|
||||
const expander = e.target.closest('.tools-popup-expand');
|
||||
if (expander) {
|
||||
_toggleExpand(expander.dataset.expand);
|
||||
return;
|
||||
}
|
||||
|
||||
// Individual tool toggle
|
||||
const toolRow = e.target.closest('.tools-popup-tool');
|
||||
if (toolRow) {
|
||||
_toggleTool(toolRow.dataset.tool);
|
||||
return;
|
||||
}
|
||||
|
||||
// Category toggle
|
||||
const catRow = e.target.closest('.tools-popup-category');
|
||||
if (catRow) _toggleCategory(catRow.dataset.category);
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', () => popup.classList.remove('open'));
|
||||
}
|
||||
|
||||
return { init, getDisabledTools, refresh };
|
||||
})();
|
||||
@@ -780,6 +780,16 @@ Object.assign(UI, {
|
||||
|
||||
// Vault / Encryption status
|
||||
const vaultEl = document.getElementById('adminVaultStatus');
|
||||
|
||||
// Web Search config (global_settings)
|
||||
const searchCfg = getSetting('search_config', {}) || {};
|
||||
document.getElementById('adminSearchProvider').value = searchCfg.provider || 'duckduckgo';
|
||||
document.getElementById('adminSearchEndpoint').value = searchCfg.endpoint || '';
|
||||
document.getElementById('adminSearchApiKey').value = searchCfg.api_key || '';
|
||||
document.getElementById('adminSearchMaxResults').value = String(searchCfg.max_results || 5);
|
||||
document.getElementById('searxngConfigFields').style.display =
|
||||
(searchCfg.provider === 'searxng') ? '' : 'none';
|
||||
|
||||
if (vaultEl) {
|
||||
try {
|
||||
const vault = await API.adminGetVaultStatus();
|
||||
|
||||
Reference in New Issue
Block a user