Add manifest-based addon manager
This commit is contained in:
@@ -1405,6 +1405,10 @@
|
||||
</button>
|
||||
<div class="settings-sidebar-divider admin-only"></div>
|
||||
<div class="settings-sidebar-label admin-only">Admin</div>
|
||||
<button class="settings-nav-item admin-only" data-settings-tab="addons">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3 6 6 .9-4.5 4.4 1.1 6.2L12 16.6 6.4 19.5l1.1-6.2L3 8.9 9 8z"/></svg>
|
||||
<span>Addons</span>
|
||||
</button>
|
||||
<button class="settings-nav-item admin-only" data-settings-tab="tools">
|
||||
<svg width="15" height="15" 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>
|
||||
<span>Agent Tools</span>
|
||||
@@ -2262,6 +2266,35 @@
|
||||
</div>
|
||||
|
||||
<!-- ═══ TOOLS TAB ═══ -->
|
||||
<!-- ADDONS TAB -->
|
||||
<div data-settings-panel="addons" class="hidden">
|
||||
<div class="admin-card">
|
||||
<h2 style="display:flex;align-items:center;gap:8px;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6"><path d="M12 2l3 6 6 .9-4.5 4.4 1.1 6.2L12 16.6 6.4 19.5l1.1-6.2L3 8.9 9 8z"/></svg>
|
||||
Addons
|
||||
<span style="flex:1"></span>
|
||||
<button class="admin-btn-sm" id="addons-refresh-btn" title="Rescan addons">Refresh</button>
|
||||
</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:12px">Manifest-based extensions loaded from <code>data/addons/<id>/addon.json</code>. Addons are declarative by default; Odysseus does not execute arbitrary addon code automatically.</div>
|
||||
<div id="addons-list" class="addons-list"><div class="admin-empty">Loading addons...</div></div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<h2 style="display:flex;align-items:center;gap:8px;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M12 18v-6"/><path d="M9 15h6"/></svg>
|
||||
Addon Builder
|
||||
</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:10px">Create or edit an addon manifest. This is the stable contract future addon code can build on.</div>
|
||||
<div class="addon-builder-row">
|
||||
<input id="addon-template-id" class="settings-select" type="text" value="my-addon" aria-label="Addon id" />
|
||||
<button class="admin-btn-add" id="addon-template-btn">Load Template</button>
|
||||
<button class="admin-btn-add" id="addon-save-btn">Save Addon</button>
|
||||
<button class="admin-btn-sm" id="addon-clear-btn">Clear</button>
|
||||
</div>
|
||||
<textarea id="addon-manifest-editor" class="addon-manifest-editor" spellcheck="false" placeholder="{ "id": "my-addon", "name": "My Addon" }"></textarea>
|
||||
<div id="addons-msg" class="addon-msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-settings-panel="tools" class="hidden">
|
||||
<div class="admin-card" style="margin-bottom:12px;">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><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>Agent</h2>
|
||||
|
||||
198
static/js/addons.js
Normal file
198
static/js/addons.js
Normal file
@@ -0,0 +1,198 @@
|
||||
// static/js/addons.js - Addon manager for manifest-based Odysseus addons
|
||||
|
||||
import uiModule from './ui.js';
|
||||
|
||||
let initialized = false;
|
||||
let currentManifest = null;
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function esc(value) {
|
||||
return uiModule && typeof uiModule.esc === 'function'
|
||||
? uiModule.esc(String(value ?? ''))
|
||||
: String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const res = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||
...options,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function contributionSummary(addon) {
|
||||
const items = Array.isArray(addon.contributions) ? addon.contributions : [];
|
||||
if (!items.length) return '<span class="addon-muted">No contributions</span>';
|
||||
const counts = new Map();
|
||||
items.forEach(item => counts.set(item.type || 'unknown', (counts.get(item.type || 'unknown') || 0) + 1));
|
||||
return Array.from(counts.entries())
|
||||
.map(([type, count]) => `<span class="addon-chip">${esc(type)}${count > 1 ? ` x${count}` : ''}</span>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderAddons(addons) {
|
||||
const list = el('addons-list');
|
||||
if (!list) return;
|
||||
if (!addons.length) {
|
||||
list.innerHTML = '<div class="admin-empty">No addons installed yet.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = addons.map(addon => `
|
||||
<div class="addon-card" data-addon-id="${esc(addon.id)}">
|
||||
<div class="addon-card-main">
|
||||
<div class="addon-card-title">
|
||||
<span class="addon-status ${addon.enabled ? 'enabled' : ''}"></span>
|
||||
<strong>${esc(addon.name || addon.id)}</strong>
|
||||
<span class="addon-version">${esc(addon.version || '0.1.0')}</span>
|
||||
</div>
|
||||
<div class="addon-desc">${esc(addon.description || 'No description')}</div>
|
||||
<div class="addon-meta">
|
||||
<span>${esc(addon.id)}</span>
|
||||
${addon.author ? `<span>${esc(addon.author)}</span>` : ''}
|
||||
${addon.error ? `<span class="addon-error">${esc(addon.error)}</span>` : ''}
|
||||
</div>
|
||||
<div class="addon-contribs">${contributionSummary(addon)}</div>
|
||||
</div>
|
||||
<div class="addon-card-actions">
|
||||
<button class="admin-btn-sm addon-edit-btn" data-addon-id="${esc(addon.id)}">Edit</button>
|
||||
<button class="admin-btn-sm addon-toggle-btn" data-addon-id="${esc(addon.id)}" data-enabled="${addon.enabled ? '0' : '1'}">${addon.enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button class="admin-btn-sm danger addon-delete-btn" data-addon-id="${esc(addon.id)}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function setMessage(message, kind = '') {
|
||||
const node = el('addons-msg');
|
||||
if (!node) return;
|
||||
node.textContent = message || '';
|
||||
node.className = kind ? `addon-msg ${kind}` : 'addon-msg';
|
||||
}
|
||||
|
||||
function setEditorManifest(manifest) {
|
||||
currentManifest = manifest || null;
|
||||
const editor = el('addon-manifest-editor');
|
||||
if (!editor) return;
|
||||
editor.value = JSON.stringify(manifest || {}, null, 2);
|
||||
}
|
||||
|
||||
async function loadAddons() {
|
||||
const list = el('addons-list');
|
||||
if (list) list.innerHTML = '<div class="admin-empty">Loading addons...</div>';
|
||||
try {
|
||||
const data = await api('/api/addons');
|
||||
renderAddons(Array.isArray(data.addons) ? data.addons : []);
|
||||
setMessage('');
|
||||
} catch (err) {
|
||||
if (list) list.innerHTML = `<div class="admin-empty addon-error">${esc(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTemplate() {
|
||||
const idInput = el('addon-template-id');
|
||||
const addonId = (idInput && idInput.value.trim()) || 'my-addon';
|
||||
const data = await api(`/api/addons/template?addon_id=${encodeURIComponent(addonId)}`);
|
||||
setEditorManifest(data.manifest);
|
||||
setMessage('Template loaded. Edit the manifest and save it.', 'ok');
|
||||
}
|
||||
|
||||
async function saveManifest() {
|
||||
const editor = el('addon-manifest-editor');
|
||||
if (!editor) return;
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(editor.value || '{}');
|
||||
} catch (err) {
|
||||
setMessage(`Invalid JSON: ${err.message}`, 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await api('/api/addons', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ manifest }),
|
||||
});
|
||||
setEditorManifest(data.addon);
|
||||
setMessage('Addon saved.', 'ok');
|
||||
await loadAddons();
|
||||
} catch (err) {
|
||||
setMessage(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function editAddon(addonId) {
|
||||
try {
|
||||
const data = await api(`/api/addons/${encodeURIComponent(addonId)}`);
|
||||
const addon = { ...(data.addon || {}) };
|
||||
delete addon.path;
|
||||
setEditorManifest(addon);
|
||||
setMessage(`Editing ${addon.id}.`, 'ok');
|
||||
} catch (err) {
|
||||
setMessage(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAddon(addonId, enabled) {
|
||||
try {
|
||||
await api(`/api/addons/${encodeURIComponent(addonId)}/enabled`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
setMessage(enabled ? 'Addon enabled.' : 'Addon disabled.', 'ok');
|
||||
await loadAddons();
|
||||
} catch (err) {
|
||||
setMessage(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAddon(addonId) {
|
||||
if (!confirm(`Delete addon "${addonId}"?`)) return;
|
||||
try {
|
||||
await api(`/api/addons/${encodeURIComponent(addonId)}`, { method: 'DELETE' });
|
||||
if (currentManifest && currentManifest.id === addonId) setEditorManifest(null);
|
||||
setMessage('Addon deleted.', 'ok');
|
||||
await loadAddons();
|
||||
} catch (err) {
|
||||
setMessage(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
const refresh = el('addons-refresh-btn');
|
||||
if (refresh) refresh.addEventListener('click', loadAddons);
|
||||
const template = el('addon-template-btn');
|
||||
if (template) template.addEventListener('click', () => loadTemplate().catch(err => setMessage(err.message, 'error')));
|
||||
const save = el('addon-save-btn');
|
||||
if (save) save.addEventListener('click', saveManifest);
|
||||
const clear = el('addon-clear-btn');
|
||||
if (clear) clear.addEventListener('click', () => {
|
||||
setEditorManifest(null);
|
||||
setMessage('');
|
||||
});
|
||||
const list = el('addons-list');
|
||||
if (list) {
|
||||
list.addEventListener('click', (event) => {
|
||||
const edit = event.target.closest('.addon-edit-btn');
|
||||
const toggle = event.target.closest('.addon-toggle-btn');
|
||||
const del = event.target.closest('.addon-delete-btn');
|
||||
if (edit) editAddon(edit.dataset.addonId);
|
||||
if (toggle) toggleAddon(toggle.dataset.addonId, toggle.dataset.enabled === '1');
|
||||
if (del) deleteAddon(del.dataset.addonId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function init() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
export function open() {
|
||||
init();
|
||||
loadAddons();
|
||||
}
|
||||
|
||||
export default { init, open };
|
||||
@@ -9,6 +9,7 @@ import { sortModelIds } from './modelSort.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { isAltGrEvent } from './platform.js';
|
||||
import { bindMenuDismiss } from './escMenuStack.js';
|
||||
import addonsModule from './addons.js';
|
||||
|
||||
let initialized = false;
|
||||
let modalEl = null;
|
||||
@@ -41,6 +42,7 @@ function initTabs() {
|
||||
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
|
||||
syncAppearanceOpacity(tab === 'appearance');
|
||||
if (tab === 'ai') refreshAiModelEndpoints();
|
||||
if (tab === 'addons') addonsModule.open();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2322,6 +2324,7 @@ function initAll() {
|
||||
initClose();
|
||||
initOpenPromptModalLink();
|
||||
initOpacityToggle();
|
||||
addonsModule.init();
|
||||
initialized = true;
|
||||
initDefaultChat();
|
||||
initTeacherModel();
|
||||
@@ -5731,6 +5734,7 @@ export function open(tab) {
|
||||
document.body.classList.toggle('settings-appearance-open', activeTab === 'appearance');
|
||||
syncAppearanceOpacity(activeTab === 'appearance');
|
||||
if (activeTab === 'ai') refreshAiModelEndpoints();
|
||||
if (activeTab === 'addons') addonsModule.open();
|
||||
if (ADMIN_TABS.has(activeTab) && window.adminModule && !window.adminModule._initialized) {
|
||||
window.adminModule._initData();
|
||||
}
|
||||
|
||||
120
static/style.css
120
static/style.css
@@ -40439,6 +40439,126 @@ body.theme-frosted .modal {
|
||||
color: var(--fg, #9cdef2);
|
||||
}
|
||||
|
||||
/* Addon manager */
|
||||
.addons-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.addon-card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--panel) 86%, var(--bg));
|
||||
}
|
||||
.addon-card-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.addon-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.addon-card-title strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.addon-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--fg) 25%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.addon-status.enabled {
|
||||
background: var(--accent, #50fa7b);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #50fa7b) 14%, transparent);
|
||||
}
|
||||
.addon-version,
|
||||
.addon-muted,
|
||||
.addon-meta {
|
||||
color: color-mix(in srgb, var(--fg) 55%, transparent);
|
||||
font-size: 11px;
|
||||
}
|
||||
.addon-desc {
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
color: color-mix(in srgb, var(--fg) 78%, transparent);
|
||||
}
|
||||
.addon-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-family: var(--mono, monospace);
|
||||
}
|
||||
.addon-error,
|
||||
.addon-msg.error {
|
||||
color: var(--red, #ff5555);
|
||||
}
|
||||
.addon-contribs {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.addon-chip {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
color: color-mix(in srgb, var(--fg) 74%, transparent);
|
||||
}
|
||||
.addon-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.addon-builder-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.addon-builder-row input {
|
||||
min-width: 180px;
|
||||
max-width: 260px;
|
||||
}
|
||||
.addon-manifest-editor {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--mono, Consolas, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.addon-msg {
|
||||
min-height: 18px;
|
||||
margin-top: 7px;
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--fg) 58%, transparent);
|
||||
}
|
||||
.addon-msg.ok {
|
||||
color: var(--accent, #50fa7b);
|
||||
}
|
||||
|
||||
/* The model-comparison grid hard-codes 2-4 equal columns with no phone
|
||||
breakpoint that stacks them, so at 390px two models render ~178px columns and
|
||||
four render ~88px columns. Each column is a full scrolling chat (code blocks,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||
// - API / non-GET: never cached.
|
||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||
const CACHE_NAME = 'odysseus-v344';
|
||||
const CACHE_NAME = 'odysseus-v345';
|
||||
|
||||
// Core shell precached on install so repeat opens are instant without any
|
||||
// network wait. Keep this list in sync with the <script type="module"> tags
|
||||
@@ -44,6 +44,7 @@ const PRECACHE = [
|
||||
'/static/js/theme.js',
|
||||
'/static/js/censor.js',
|
||||
'/static/js/settings.js',
|
||||
'/static/js/addons.js',
|
||||
'/static/js/admin.js',
|
||||
'/static/js/init.js',
|
||||
'/static/js/slashCommands.js',
|
||||
|
||||
Reference in New Issue
Block a user