added scheduler

This commit is contained in:
ION606
2025-09-26 14:28:04 -04:00
parent 5f535a61c1
commit 133ef3f48b
19 changed files with 3975 additions and 555 deletions
+4
View File
@@ -132,3 +132,7 @@ dist
__pycache__/
.venv/
bun.lock
tmp/
temp.*
+4
View File
@@ -131,11 +131,15 @@ services:
- TEMPLATES_FILE=/app/templates.json
- DOCKER_SOCKET=/var/run/docker.sock
- TZ=America/New_York
networks:
- internal
volumes:
- schedule_data:/app/data
- ./templates.json:/app/templates.json:ro,Z
- /var/run/docker.sock:/var/run/docker.sock
- ./tmp:/tmp
volumes:
open-webui:
pgdata:
+1
View File
@@ -4,3 +4,4 @@ bun.lock
bun.lockb
.DS_Store
*.log
templates.json
+8 -3
View File
@@ -3,12 +3,17 @@ WORKDIR /app
# prod deps
COPY package.json ./package.json
RUN bun install --ci --production
COPY server.mjs ./server.mjs
COPY public ./public
COPY . .
RUN mkdir -p /app/data && chown -R bun:bun /app/data
USER bun
EXPOSE 12253
ENV NODE_ENV=production
CMD ["bun", "run", "server.mjs"]
CMD ["bun", "run", "server.ts"]
+324
View File
@@ -0,0 +1,324 @@
import crypto from 'node:crypto';
import { base } from './resolve-user';
import fs from 'fs';
export type schedInp = {
name: string
when: {
cron?: string
start?: string
}
oneShot?: boolean
template?: { name: string }
parameters: object
prompt: string
userId: string
model: string,
tools: string[]
features?: Record<string, boolean>
chatId?: string
files?: { fname: string, fkey?: string, content?: string }[]
}
export type ollamaInp = {
name: string
displayName: string
userId: string
schedule: string
startAt?: string
oneShot: boolean
template: any //idk
parameters: object
prompt: string
model: string
tools: string[]
features: Record<string, boolean>
cookie: string
chatId?: string
files?: { fname: string, fkey: string }[]
}
async function makeRequest(cookie: string, path: string, throwOnErr = false) {
if (!cookie) return [];
const r = await fetch(`${base}/${path}`, {
method: "GET",
headers: {
'Authorization': `Bearer ${cookie}`,
"content-type": "application/json",
"accept": "application/json",
}
});
if (throwOnErr && !r.ok) throw r;
return await r.text().then(data => {
try { return JSON.parse(data); }
catch (_) { return data; }
}).catch(async err => {
console.error(err);
return [];
});
}
// NO FOREWARD SLASH NO IDK WHY IG /v1/ DOES IT FUCK ME MAN
export const getModels = (cookie: string) => makeRequest(cookie, 'api/models').then((r) => (r.data || []));
export const getTools = (cookie: string) => makeRequest(cookie, 'api/v1/tools/');
export const authCall = (cookie: string) => makeRequest(cookie, 'api/v1/auths/', true);
const buildPrompt = async (def: ollamaInp) => {
let content = `Please complete this request given the following information:\n`;
content += `request: ${def.prompt}\nExtra Context:\n\`\`\`json\n${JSON.stringify(def.parameters || {})}\n\`\`\``;
if (def.files) {
const files = await Promise.all(def.files.map(async f => {
const fpath = `/app/data/files/${f.fkey}`,
r = await fetch(`https://${base}/api/v1/files`, {
headers: {
'Content-Type': 'multipart/form-data',
'filename': f.fname
},
method: 'POST',
body: await fs.readFileSync(fpath)
}).catch(err => {
console.error(err);
return { data: err, ok: false };
});
fs.rm(fpath, { recursive: false }, (err: any) => {
if (err) console.error(err);
});
return {
ok: r.ok,
data: r.ok ? await (r as Response).json() : (r as any).data
};
}));
if (files.find(v => !v.ok)) {
throw new Error(`File upload failed for file arr: ${JSON.stringify(files)}`)
}
return {
content, files: files.map(({ data: f }) => ({
id: f.id,
type: 'file',
url: `/api/v1/files/${f.id}`,
file: f,
name: f.filename,
status: 'uploaded', // trust, trust
size: f.meta.size,
error: '',
itemId: crypto.randomUUID()
}))
};
}
return { content };
};
const getSystemSettings = async (cookie: string) => {
const r = await makeRequest(cookie, 'api/v1/users/user/settings/'),
{ notifications, system } = r?.length || {};
return { notifications, system };
};
const showErr = async (r: any) => {
console.error('=========================================');
console.error(r);
console.error(await r.text());
console.error('=========================================');
}
// duplicate? Maybe?
const toFeatureFlags = (f: Record<string, boolean> = {}) => ({
image_generation: !!f.image_generation,
code_interpreter: !!f.code_interpreter,
web_search: !!f.web_search,
memory: !!f.memory
});
const splitThink = (raw: string, startPos: number = 0) => {
const open = raw.indexOf('<think>', startPos),
close = raw.indexOf('</think>', startPos);
if (close >= 0) {
const start = open >= 0 ? open + '<think>'.length : 0;
return {
thinking: raw.slice(start, close).trim(),
answer: raw.slice(close + '</think>'.length).trim()
};
}
return { thinking: '', answer: raw.trim(), endPos: close };
};
async function newChat(def: ollamaInp, sysMsg?: string) {
const userId = crypto.randomUUID(),
assistantId = crypto.randomUUID(), // will be reused in completions
now = Date.now();
const userMsg = {
id: userId,
parentId: null,
childrenIds: [assistantId],
role: 'user',
...(await buildPrompt(def)),
timestamp: now,
models: [def.model]
};
const assistantPlaceholder = {
id: assistantId,
parentId: userId,
childrenIds: [],
role: 'assistant',
content: '',
model: def.model,
modelName: def.model,
modelIdx: 0,
timestamp: now
};
const reqObj = {
user_id: def.userId,
title: 'New Chat',
chat: {
id: '',
title: 'New Chat',
models: [def.model],
system: sysMsg || '',
params: {}, // model params???
history: {
messages: {
[userMsg.id]: userMsg,
[assistantPlaceholder.id]: assistantPlaceholder
},
currentId: assistantId // needed for openwebUI stuff
},
messages: [userMsg, assistantPlaceholder],
tags: []
},
share_id: null,
archived: false,
pinned: false,
meta: {},
folder_id: null
};
const r = await fetch(`${base}/api/v1/chats/new`, {
method: 'POST',
headers: {
Authorization: `Bearer ${def.cookie}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(reqObj)
});
if (!r.ok) {
await showErr(r);
throw new Error('Failed to create new chat!');
}
const created = await r.json();
return { chatId: created.id as string, assistantId };
}
export async function callNewChat(def: ollamaInp) {
const { cookie } = def;
if (!cookie) throw new Error('Cookie not found!');
const sysSettings = await getSystemSettings(cookie),
{ name: username } = await makeRequest(cookie, 'api/v1/auths/'),
models = await getModels(cookie),
model = models.find((m: any) => m.id === def.model);
if (!model) throw new Error(`Model ${def.model} not found!`);
const { chatId, assistantId } = def.chatId
? { chatId: def.chatId, assistantId: crypto.randomUUID() } // TODO: if passing an existing chat, insert a placeholder there first (same shape as above)
: await newChat(def, sysSettings.system);
// mock socket ID
const sessionId = crypto.randomUUID(),
completeReq = {
chat_id: chatId,
id: assistantId,
stream: false,
model: def.model,
messages: [
{ role: 'system', content: sysSettings.system || '' },
{ role: 'user', ...(await buildPrompt(def)) }
],
features: toFeatureFlags(def.features),
variables: {
'{{USER_NAME}}': username,
'{{USER_LANGUAGE}}': 'en-US'
},
session_id: sessionId,
background_tasks: { title_generation: true, follow_up_generation: true }
};
// run completion
let r = await fetch(`${base}/api/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${cookie}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(completeReq)
});
if (!r.ok) return showErr(r);
// bc stream:false, read the whole json here bc ui still wants a finalizer call
const txt = await r.text();
let content = txt;
try { const j = JSON.parse(txt); content = j?.choices?.[0]?.message?.content ?? txt; } catch { }
const thinkRes = splitThink(content),
answerArr = [(thinkRes.thinking ? `<details id="__DETAIL_${0}__"/>\n` : '') + thinkRes.answer];
let counter = thinkRes.thinking ? 1 : 0,
{ thinking, endPos } = thinkRes;
while (thinking && counter < 10) {
const { thinking: newThink, answer: newAnswer, endPos: newEndPos } = splitThink(content, endPos);
thinking = newThink;
if (thinking) {
answerArr.push(`<details id="__DETAIL_${counter}__"/>\n` + newAnswer);
endPos = newEndPos;
}
else break;
counter++;
}
const answer = answerArr.join('\n')
// fetch current chat, replace the assistant placeholder content with `answer`
const chat = await (await fetch(`${base}/api/v1/chats/${chatId}`, {
headers: { Authorization: `Bearer ${def.cookie}` }
})).json();
chat.chat.history.messages[assistantId].content = answer;
const idx = chat.chat.messages.findIndex((m: any) => m.id === assistantId);
if (idx >= 0) chat.chat.messages[idx].content = answer;
console.log(JSON.stringify(chat));
r = await fetch(`${base}/api/v1/chats/${chatId}`, {
method: 'POST',
headers: { Authorization: `Bearer ${def.cookie}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ chat: chat.chat })
});
// now finalize
r = await fetch(`${base}/api/chat/completed`, {
method: 'POST',
headers: { Authorization: `Bearer ${def.cookie}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: chatId, id: assistantId, session_id: sessionId, model: def.model })
});
console.log('completed:', await r.text());
}
+97
View File
@@ -0,0 +1,97 @@
import http from 'http'
import { readBodyJson } from '../server';
import { getModels, getTools } from './ollamaCalls';
export const base = 'http://open-webui:8080';
type Me = {
id: string;
email: string;
name: string;
role: string;
profile_image_url: string;
token: string;
token_type: string;
expires_at: string | null;
permissions: {
workspace: {
models: boolean;
knowledge: boolean;
prompts: boolean;
tools: boolean;
};
features: {
direct_tool_servers: boolean;
web_search: boolean;
image_generation: boolean;
code_interpreter: boolean;
notes: boolean;
};
};
};
export default async function loginUser(req: http.IncomingMessage, res: http.ServerResponse) {
const { email, password } = await readBodyJson(req);
if (!email || !password) {
res.writeHead(400).end(JSON.stringify({ error: "email or password not sent" }));
return;
}
// login with username/password
const loginRes = await fetch(`${base}/api/v1/auths/signin`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!loginRes.ok) {
console.error("error logging in", await loginRes.text());
res.writeHead(401).end();
return
}
const upstreamCookie = loginRes.headers.get("set-cookie"),
user = await loginRes.json();
// forward Set-Cookie to the browser so it stores the cookie
const outHeaders: Record<string, string | string[]> = {
"content-type": "application/json",
};
if (upstreamCookie) {
outHeaders["Set-Cookie"] = upstreamCookie;
}
res.writeHead(200, outHeaders).end(JSON.stringify({ user }));
}
export async function getUser(req: http.IncomingMessage, res: http.ServerResponse) {
if (!req.headers.cookie) {
return res.writeHead(401).end("Not logged in");
}
const cookies = Object.fromEntries(req.headers.cookie.split(';').map(c => c.split('=').map(o => o.trim())));
if (!('token' in cookies)) {
return res.writeHead(401).end("Not logged in");
}
const uRes = await fetch(`${base}/api/v1/auths/`, {
method: "GET",
headers: {
"content-type": "application/json",
'Authorization': `Bearer ${cookies['token']}`
},
});
if (!uRes.ok) {
console.error("Error getting user", await uRes.text());
return res.writeHead(401).end();
}
const uObj = await uRes.json();
uObj.models = await getModels(cookies['token']);
uObj.tools = await getTools(cookies['token']);
res.writeHead(200).end(JSON.stringify(uObj));
}
+3
View File
@@ -8,8 +8,11 @@
"dev": "bun run --hot server.mjs"
},
"dependencies": {
"@js-temporal/polyfill": "^0.5.1",
"@kubernetes/client-node": "^0.22.1",
"@types/cookie-parser": "^1.4.9",
"@types/node": "^24.3.3",
"cookie-parser": "^1.4.7",
"dockerode": "^4.0.8",
"node-cron": "^4.2.1"
}
+212
View File
@@ -0,0 +1,212 @@
:root {
--fc-surface: #0f2219;
--fc-surface-2: #103220;
--fc-text: #e8f9f0;
--fc-muted: #7dc9a5;
--fc-accent: #2dd282;
--fc-accent-soft: rgba(45, 210, 130, 0.18);
--fc-border: rgba(125, 201, 165, 0.45);
--fc-border-strong: rgba(45, 210, 130, 0.65);
--fc-overlay: rgba(4, 18, 12, 0.72);
}
.feature-section {
margin-top: 12px;
display: grid;
gap: 6px;
}
.feature-section-header {
display: flex;
flex-direction: column;
gap: 2px;
}
.feature-section-header h3 {
margin: 0;
font-size: 1rem;
}
.feature-pill-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 6px 0;
min-height: 40px;
}
.feature-pill {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 999px;
border: 1px solid var(--fc-border);
background: var(--fc-accent-soft);
color: inherit;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.07);
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.feature-pill--selected {
background: color-mix(in srgb, var(--fc-accent), transparent 68%);
border-color: var(--fc-border-strong);
box-shadow: 0 2px 6px rgba(21, 94, 50, 0.22);
}
.feature-pill-toggle {
appearance: none;
border: none;
background: transparent;
color: inherit;
font: inherit;
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
flex: 1 1 auto;
text-align: left;
}
.feature-pill-toggle:focus-visible,
.feature-pill-info:focus-visible {
outline: 2px solid color-mix(in srgb, var(--fc-accent), white 25%);
outline-offset: 2px;
}
.feature-pill-info {
appearance: none;
border: none;
background: transparent;
color: var(--fc-muted);
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-right: 4px;
}
.feature-pill-info:hover {
color: var(--fc-text);
background: rgba(45, 210, 130, 0.16);
}
.feature-pill-info span {
display: inline-block;
font-size: 0.9rem;
font-weight: 700;
}
/* Modal styles */
.fc-overlay {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: clamp(12px, 2vw, 24px);
background: var(--fc-overlay);
z-index: 9999;
}
.fc-dialog {
width: min(520px, 100%);
max-height: 90dvh;
overflow: auto;
border-radius: 12px;
background: linear-gradient(180deg, var(--fc-surface), var(--fc-surface-2));
color: var(--fc-text);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
border: 1px solid rgba(125, 201, 165, 0.35);
}
.fc-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid rgba(125, 201, 165, 0.25);
position: sticky;
top: 0;
backdrop-filter: blur(6px);
background:
linear-gradient(to bottom, color-mix(in srgb, var(--fc-surface), transparent 30%), color-mix(in srgb, var(--fc-surface-2), transparent 35%)),
conic-gradient(from 0.35turn at 12% -30%, color-mix(in srgb, var(--fc-accent), transparent 80%), transparent 40%);
z-index: 1;
}
.fc-titlewrap {
display: flex;
align-items: center;
gap: 10px;
}
.fc-title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
}
.fc-tag {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #06361d;
background: color-mix(in srgb, var(--fc-accent), white 38%);
border: 1px solid color-mix(in srgb, var(--fc-accent), black 10%);
}
.fc-close {
appearance: none;
border: 1px solid transparent;
background: transparent;
color: var(--fc-muted);
font-size: 1.6rem;
line-height: 1;
border-radius: 8px;
padding: 4px 8px;
cursor: pointer;
}
.fc-close:hover {
color: var(--fc-text);
border-color: rgba(125, 201, 165, 0.4);
background: rgba(45, 210, 130, 0.16);
}
.fc-body {
padding: 20px;
display: grid;
gap: 18px;
}
.fc-desc {
margin: 0;
color: var(--fc-text);
font-size: 0.95rem;
line-height: 1.4;
}
.fc-details {
display: grid;
grid-template-columns: max-content 1fr;
gap: 6px 12px;
margin: 0;
}
.fc-details dt {
font-weight: 600;
color: var(--fc-muted);
}
.fc-details dd {
margin: 0;
font-weight: 500;
}
+258
View File
@@ -0,0 +1,258 @@
const FEATURE_DEFAULTS = {
image_generation: false,
code_interpreter: false,
web_search: false,
memory: true,
};
const FEATURE_METADATA = {
image_generation: {
label: 'Image Generation',
description: 'Request image outputs from supported models. When enabled the assistant may produce images alongside text responses.',
},
code_interpreter: {
label: 'Code Interpreter',
description: 'Grant access to the sandboxed runtime for running Python snippets and data transformations during the task.',
},
web_search: {
label: 'Web Search',
description: 'Allow the assistant to perform outbound web searches to enrich the response with current information.',
},
memory: {
label: 'Memory',
description: 'Persist relevant conversation context for future automations so runs can recall prior outcomes.',
},
};
const FEATURE_SECTION_TAG = 'Feature';
function renderFeatureList(featureState) {
const container = document.querySelector('#features-select');
const hiddenInput = document.querySelector('#features-select-input');
if (!container || !hiddenInput) return;
const baseState = normalizeFeatureState(featureState),
featureKeys = Object.keys(baseState);
container.innerHTML = '';
container.setAttribute('data-has-features', String(featureKeys.length > 0));
const selection = { ...baseState },
syncHidden = () => {
hiddenInput.value = JSON.stringify(selection);
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
};
featureKeys.forEach((id) => {
const meta = getFeatureMeta(id);
const pill = document.createElement('div');
pill.className = 'feature-pill';
pill.dataset.featureId = id;
pill.setAttribute('role', 'option');
pill.setAttribute('tabindex', '-1');
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = 'feature-pill-toggle';
toggleBtn.textContent = meta.label;
toggleBtn.setAttribute('aria-pressed', String(Boolean(selection[id])));
toggleBtn.addEventListener('click', () => {
selection[id] = !selection[id];
const isSelected = Boolean(selection[id]);
pill.classList.toggle('feature-pill--selected', isSelected);
pill.setAttribute('aria-selected', String(isSelected));
toggleBtn.setAttribute('aria-pressed', String(isSelected));
syncHidden();
});
const infoBtn = document.createElement('button');
infoBtn.type = 'button';
infoBtn.className = 'feature-pill-info';
infoBtn.setAttribute('aria-label', `Show info for ${meta.label}`);
infoBtn.innerHTML = '<span aria-hidden="true">i</span>';
infoBtn.addEventListener('click', (event) => {
event.stopPropagation();
generateFeatureCard({ id, ...meta, selected: Boolean(selection[id]) }, { trigger: infoBtn });
});
const isSelected = Boolean(selection[id]);
pill.classList.toggle('feature-pill--selected', isSelected);
pill.setAttribute('aria-selected', String(isSelected));
pill.appendChild(toggleBtn);
pill.appendChild(infoBtn);
container.appendChild(pill);
});
syncHidden();
}
function normalizeFeatureState(featureState) {
const normalized = { ...FEATURE_DEFAULTS };
if (featureState && typeof featureState === 'object' && !Array.isArray(featureState)) {
for (const [key, value] of Object.entries(featureState)) {
if (Object.prototype.hasOwnProperty.call(FEATURE_DEFAULTS, key) || Object.prototype.hasOwnProperty.call(FEATURE_METADATA, key)) {
normalized[key] = Boolean(value);
}
}
}
return normalized;
}
function getFeatureMeta(id) {
if (Object.prototype.hasOwnProperty.call(FEATURE_METADATA, id)) {
return FEATURE_METADATA[id];
}
const label = id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return {
label,
description: 'Toggle this capability for the scheduled run.',
};
}
function generateFeatureCard(feature, options = {}) {
const mount = options.mountSelector ? document.querySelector(options.mountSelector) : document.body;
if (!mount) throw new Error('mount element not found');
const overlay = document.createElement('div');
overlay.className = 'fc-overlay';
overlay.setAttribute('data-testid', 'feature-card-overlay');
const dialog = document.createElement('div');
dialog.className = 'fc-dialog';
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
const titleId = `fc-title-${Math.random().toString(36).slice(2)}`;
const descId = `fc-desc-${Math.random().toString(36).slice(2)}`;
dialog.setAttribute('aria-labelledby', titleId);
dialog.setAttribute('aria-describedby', descId);
const header = document.createElement('header');
header.className = 'fc-header';
const titleWrap = document.createElement('div');
titleWrap.className = 'fc-titlewrap';
const h1 = document.createElement('h1');
h1.id = titleId;
h1.className = 'fc-title';
h1.textContent = feature.label;
const tag = document.createElement('span');
tag.className = 'fc-tag';
tag.textContent = FEATURE_SECTION_TAG;
tag.setAttribute('data-testid', 'feature-tag');
titleWrap.appendChild(h1);
titleWrap.appendChild(tag);
const closeBtn = document.createElement('button');
closeBtn.className = 'fc-close';
closeBtn.type = 'button';
closeBtn.setAttribute('aria-label', 'close');
closeBtn.textContent = '×';
header.appendChild(titleWrap);
header.appendChild(closeBtn);
const body = document.createElement('div');
body.className = 'fc-body';
const desc = document.createElement('p');
desc.id = descId;
desc.className = 'fc-desc';
desc.textContent = feature.description;
const details = document.createElement('dl');
details.className = 'fc-details';
const pairs = [
['Identifier', feature.id],
['Current state', feature.selected ? 'Enabled' : 'Disabled'],
];
pairs.forEach(([label, value]) => {
const dt = document.createElement('dt');
dt.textContent = label;
const dd = document.createElement('dd');
dd.textContent = value;
details.appendChild(dt);
details.appendChild(dd);
});
body.appendChild(desc);
body.appendChild(details);
dialog.appendChild(header);
dialog.appendChild(body);
overlay.appendChild(dialog);
mount.appendChild(overlay);
const opener = options.trigger instanceof HTMLElement ? options.trigger : document.activeElement;
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
const getFocusable = () => /** @type {HTMLElement[]} */(Array.from(dialog.querySelectorAll(focusableSelectors)));
const focusFirst = () => {
const items = getFocusable();
(items[0] || closeBtn).focus();
};
const onKeydown = (event) => {
if (event.key === 'Tab') {
const items = getFocusable();
if (items.length === 0) {
event.preventDefault();
closeBtn.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = /** @type {HTMLElement} */(document.activeElement);
if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
} else if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
}
}
if (event.key === 'Escape') {
event.preventDefault();
close();
}
};
const onOverlayClick = (event) => {
if (event.target === overlay) close();
};
overlay.addEventListener('click', onOverlayClick);
document.addEventListener('keydown', onKeydown);
closeBtn.addEventListener('click', () => close());
const prevOverflow = document.documentElement.style.overflow;
document.documentElement.style.overflow = 'hidden';
setTimeout(focusFirst, 0);
function close() {
overlay.removeEventListener('click', onOverlayClick);
document.removeEventListener('keydown', onKeydown);
document.documentElement.style.overflow = prevOverflow;
overlay.remove();
if (opener && typeof opener.focus === 'function') opener.focus();
}
return { close, root: overlay };
}
+177 -91
View File
@@ -5,7 +5,13 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Schedules • Task & Workflow Manager</title>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="modelList.css" />
<link rel="stylesheet" href="toolList.css" />
<link rel="stylesheet" href="featureList.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jsoneditor/10.4.1/jsoneditor.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsoneditor/10.4.1/jsoneditor.min.js"></script>
</head>
<body>
@@ -30,6 +36,64 @@
<main class="content-grid">
<!-- left column: auth + run now -->
<aside class="stack col-left">
<!-- user summary card (shows when logged in) -->
<section class="card user-card" id="userCard" aria-hidden="true">
<header class="card-header">
<h2 style="margin-top: 0px;">Account</h2>
</header>
<div class="user-compact">
<img id="userAvatar" class="avatar" src="" alt="" aria-hidden="true" />
<div class="user-info">
<div id="userName" class="user-name muted">not logged in</div>
<div id="userEmail" class="muted user-email"></div>
<div id="userRole" class="muted user-role"></div>
</div>
</div>
<!-- permissions summary -->
<div id="userPermissions" class="permissions" aria-live="polite" hidden>
<h3 class="small">Permissions</h3>
<ul class="perms-list">
<li>
<span class="perm-key">Workspace</span>
<ul class="nested-perms">
<li>
<span class="perm-val" data-perm="workspace.tools"></span>
<span class="perm-key">Tools</span>
</li>
</ul>
</li>
<li>
<span class="perm-key">Chat</span>
<ul class="nested-perms">
<li>
<span class="perm-val" data-perm="chat.file_upload"></span>
<span class="perm-key">File Upload</span>
</li>
</ul>
</li>
<li>
<span class="perm-key">Features</span>
<ul class="nested-perms">
<li>
<span class="perm-val" data-perm="features.web_search"></span>
<span class="perm-key">Web Search</span>
</li>
<li>
<span class="perm-val" data-perm="features.code_interpreter"></span>
<span class="perm-key">Code Interpreter</span>
</li>
</ul>
</li>
</ul>
</div>
<div class="actions">
<button id="userLogoutBtn" aria-variant="danger">Logout</button>
</div>
</section>
<!-- login card (ids preserved) -->
<section class="card" id="auth">
<header class="card-header">
@@ -38,65 +102,28 @@
class="inline">x-user-id</code> header on api requests.</p>
</header>
<form id="loginForm" class="form-stack">
<div class="row">
<div>
<label for="userId">user id (uuid)</label>
<input id="userId" name="userId" type="text" required placeholder="e.g. 5a8d1d7e-..." />
</div>
<div>
<label for="displayName">display name (optional)</label>
<input id="displayName" name="displayName" type="text" placeholder="your name" />
</div>
</div>
<!-- Login panel -->
<section class="auth-card">
<h2>Login</h2>
<p id="authStatus" role="status">not logged in</p>
<div class="actions">
<button type="submit">save & set header</button>
<button type="button" id="logoutBtn" aria-variant="ghost">logout</button>
<form id="loginForm" autocomplete="off">
<label>
Username
<input id="username" name="username" required />
</label>
<label>
Password
<input id="password" name="password" type="password" required />
</label>
<div class="row actions">
<button type="submit">Login</button>
<button type="button" id="logoutBtn">Logout</button>
</div>
<p id="authStatus" class="muted" aria-live="polite"></p>
</form>
</section>
<!-- run now -->
<section class="card">
<header class="card-header">
<h2>run now</h2>
<p class="muted">trigger a workflow adhoc with parameters</p>
</header>
<form id="runNowForm" class="form-stack">
<div class="row">
<div>
<label for="rnName">name (label only)</label>
<input id="rnName" type="text" placeholder="ad-hoc-run" />
</div>
<div>
<label for="rnTemplateName">workflow template</label>
<input id="rnTemplateName" type="text" placeholder="report-template" required />
</div>
</div>
<div class="row">
<div>
<label for="rnEntrypoint">entrypoint (optional)</label>
<input id="rnEntrypoint" type="text" placeholder="main" />
</div>
<div class="checkbox">
<label>
<input id="rnClusterScope" type="checkbox" /> template is cluster-scoped
</label>
</div>
</div>
<label for="rnParams">parameters (json object)</label>
<textarea id="rnParams" placeholder='{"report_kind":"summary"}'></textarea>
<div class="actions">
<button type="submit">run now</button>
</div>
<p id="runNowStatus" class="muted" aria-live="polite"></p>
</form>
</section>
</aside>
@@ -118,9 +145,9 @@
<tr>
<th scope="col">name</th>
<th scope="col">schedules</th>
<th scope="col">tz</th>
<th scope="col">start</th>
<th scope="col">template</th>
<th scope="col">entrypoint</th>
<th scope="col">prompt</th>
<th scope="col">one-shot</th>
<th scope="col">actions</th>
</tr>
@@ -142,22 +169,6 @@
<label for="name">name</label>
<input id="name" name="name" type="text" required placeholder="daily-report" />
</div>
<div>
<label for="tz">timezone</label>
<input id="tz" name="tz" type="text" value="America/New_York" />
</div>
</div>
<div class="row">
<div>
<label for="iso">run at (iso datetime, or leave empty if using cron)</label>
<input id="iso" name="iso" type="datetime-local" />
</div>
<div>
<label for="cron">cron (min hour day month *)</label>
<input id="cron" name="cron" type="text" placeholder="30 9 * * *" />
</div>
</div>
<div class="row">
<div>
@@ -165,27 +176,103 @@
<input id="templateName" name="templateName" type="text" required
placeholder="report-template" />
</div>
<div>
<label for="entrypoint">entrypoint (optional)</label>
<input id="entrypoint" name="entrypoint" type="text" placeholder="main" />
</div>
</div>
<div class="row">
<div class="checkbox">
<label>
<input id="clusterScope" type="checkbox" /> template is cluster-scoped
</label>
<div>
<label for="startAt">start at (optional)</label>
<input id="startAt" name="startAt" type="datetime-local" />
</div>
<div class="checkbox">
<label>
<input id="oneShot" type="checkbox" /> stop after first success (one-shot)
</label>
<div>
<label for="cron">cron (min hour day month day-of-week) *</label>
<input id="cron" name="cron" type="text" placeholder="30 9 * * *" />
<div id="cronError"></div>
</div>
<div style="justify-content: center; height: 100%;">
<div class="warning-box">
<!-- exclamation icon -->
<svg class="warning-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm0 18c-4.411 0-8-3.589-8-8s3.589-8 8-8 8 3.589 8 8-3.589 8-8 8zm1-13h-2v6h2V7zm0 8h-2v2h2v-2z" />
</svg>
<span>Cron is in EST (America/New_York)</span>
</div>
</div>
<div>
<label for="params">parameters (json object)</label>
<textarea id="params" name="params" placeholder='{"report_kind":"summary"}'></textarea>
<div id="params" name="params" placeholder=''></div>
</div>
<div>
<div class="label">Run Settings</div>
<ul class="row">
<li class="checkbox">
<label>
<input id="clusterScope" type="checkbox" /> template is cluster-scoped
</label>
</li>
<li class="checkbox">
<label>
<input id="oneShot" type="checkbox" /> stop after first success (one-shot)
</label>
</li>
</ul>
<details class="templates">
<summary class="muted">available workflow templates</summary>
<ul id="templatesUl" class="muted"></ul>
</details>
</div>
</div>
<div class="row">
<div>
<label for="model-select">Model</label>
<select id="model-select" name="model">
<option value="">choose a model</option>
</select>
<button id="selected-model-card" type="button" class="selected-model-card" hidden></button>
</div>
<div>
<label id="tools-select-label">Tools</label>
<div id="tools-select" class="tool-pill-group" role="listbox"
aria-labelledby="tools-select-label" aria-multiselectable="true"></div>
<input type="hidden" id="tools-select-input" name="tools" value="" />
<section class="feature-section" aria-labelledby="features-select-label">
<header class="feature-section-header">
<h3 id="features-select-label">Features</h3>
<p class="muted">Toggle optional capabilities for the run.</p>
</header>
<div id="features-select" class="feature-pill-group" role="listbox"
aria-labelledby="features-select-label" aria-multiselectable="true"></div>
<input type="hidden" id="features-select-input" name="features" value="{}" />
</section>
</div>
</div>
<section class="form-subsection">
<h3>Prompt</h3>
<p class="muted" style="margin-top: 0;">Supports Markdown formatting.</p>
<textarea id="prompt" name="prompt" placeholder="Write your prompt in Markdown..."
rows="10"></textarea>
</section>
<section class="form-subsection">
<h3>Attachments</h3>
<p class="muted" style="margin-top: 0;">Optional files are uploaded with the schedule and shared with the run.</p>
<label class="file-input">
<span class="file-input-label">Select file(s)</span>
<input type="file" id="scheduleFiles" name="scheduleFiles" multiple />
</label>
<ul id="scheduleFilesList" class="file-list"></ul>
</section>
<div>
<hr style="width: 100%;">
<div class="actions between wrap">
<div class="actions">
@@ -195,18 +282,17 @@
</div>
<span id="createStatus" class="muted" aria-live="polite"></span>
</div>
<details class="templates">
<summary class="muted">available workflow templates</summary>
<ul id="templatesUl" class="muted"></ul>
</details>
</div>
</form>
</section>
</section>
</main>
<!-- scripts -->
<script type="module" src="script.js"></script>
<script src="modelList.js"></script>
<script src="toolList.js"></script>
<script src="featureList.js"></script>
<script src="script.js" defer></script>
</body>
</html>
+340
View File
@@ -0,0 +1,340 @@
.mc-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: clamp(12px, 2vw, 24px);
z-index: 9999;
}
.mc-dialog {
width: min(880px, 100%);
max-height: 90dvh;
overflow: auto;
border-radius: 12px;
background: #0b0b0c;
color: #e8e8ea;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
outline: none;
}
.mc-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
position: sticky;
top: 0;
backdrop-filter: blur(6px);
background: linear-gradient(to bottom, rgba(11, 11, 12, 0.9), rgba(11, 11, 12, 0.7));
z-index: 1;
}
.mc-titlewrap {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.mc-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.1);
background: #1a1a1d;
}
.mc-avatar--placeholder {
background: linear-gradient(135deg, #2a2a2e, #1e1e22);
}
.mc-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mc-subtitle {
font-size: 0.85rem;
color: #b8b8bf;
}
.mc-close {
appearance: none;
border: none;
background: transparent;
color: #bfbfc7;
font-size: 1.6rem;
line-height: 1;
border-radius: 8px;
padding: 4px 8px;
cursor: pointer;
}
.mc-close:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.06);
}
.mc-body {
padding: 20px;
display: grid;
gap: 18px;
}
.mc-desc {
margin: 0;
color: #d8d8de;
}
.mc-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.mc-badge {
font-size: 0.78rem;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #e8e8ea;
}
.mc-section {
display: grid;
gap: 10px;
}
.mc-section-title {
font-size: 0.95rem;
font-weight: 600;
color: #f0f0f3;
margin: 0;
}
.mc-capabilities {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px 14px;
padding: 0;
margin: 0;
}
.mc-cap {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.mc-cap-label {
color: #d8d8de;
font-size: 0.92rem;
}
.mc-cap-state {
font-size: 0.82rem;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
}
.mc-cap-state.is-yes {
background: rgba(16, 185, 129, 0.22);
border-color: rgba(16, 185, 129, 0.35);
}
.mc-cap-state.is-no {
background: rgba(239, 68, 68, 0.22);
border-color: rgba(239, 68, 68, 0.35);
}
.mc-table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.mc-table th,
.mc-table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
vertical-align: top;
}
.mc-table th {
width: 30%;
color: #bfc0c7;
font-weight: 500;
}
.mc-links {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 6px;
}
.mc-links a {
color: #a6c8ff;
text-decoration: none;
word-break: break-all;
}
.mc-links a:hover {
text-decoration: underline;
}
/* disclosure (advanced details) */
.mc-disclosure {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
overflow: hidden;
}
.mc-disclosure+.mc-disclosure {
margin-top: 8px;
}
.mc-disclosure-summary {
cursor: pointer;
list-style: none;
user-select: none;
padding: 12px 14px;
font-weight: 600;
color: #f0f0f3;
}
.mc-disclosure[open] .mc-disclosure-summary {
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.mc-disclosure-body {
padding: 12px 14px;
display: grid;
gap: 16px;
}
/* nested disclosure for raw json */
.mc-disclosure--nested {
border: 1px dashed rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.02);
}
.mc-disclosure--nested .mc-disclosure-summary {
font-weight: 500;
color: #d8d8de;
}
.mc-pre {
margin: 0;
padding: 12px;
overflow: auto;
max-height: 40dvh;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 0.82rem;
line-height: 1.4;
border-radius: 8px;
background: #0f0f12;
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* responsive adjustments */
@media (max-width: 520px) {
.mc-header {
padding: 14px 16px;
}
.mc-body {
padding: 16px;
}
.mc-title {
font-size: 1rem;
}
}
.selected-model-card {
margin-top: 0.5rem;
width: 100%;
display: grid;
gap: 0.25rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
border: 1px solid color-mix(in srgb, var(--border), transparent 30%);
background: linear-gradient(180deg,
color-mix(in srgb, var(--accent) 12%, transparent),
color-mix(in srgb, var(--card) 92%, transparent));
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease, background 0.2s ease;
outline: none;
position: relative;
}
.selected-model-card:hover {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
box-shadow: 0 6px 20px rgba(15, 27, 51, 0.35);
transform: translateY(-1px);
}
.selected-model-card:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 65%, white 20%);
outline-offset: 2px;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
}
.selected-model-title {
font-weight: 600;
font-size: 0.95rem;
letter-spacing: -0.01em;
}
.selected-model-meta {
font-size: 0.8rem;
color: var(--muted);
}
.selected-model-desc {
font-size: 0.78rem;
color: color-mix(in srgb, var(--muted) 85%, var(--text));
opacity: 0.92;
}
.selected-model-hint {
margin-top: 0.1rem;
font-size: 0.75rem;
color: color-mix(in srgb, var(--accent) 70%, var(--text));
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.selected-model-hint::after {
content: '↗';
font-size: 0.75rem;
}
+441
View File
@@ -0,0 +1,441 @@
function generateModelCard(model, options = {}) {
const mount = options.mountSelector
? /** @type {HTMLElement} */(document.querySelector(options.mountSelector))
: document.body;
if (!mount) throw new Error('mount element not found');
// overlay
const overlay = document.createElement('div');
overlay.className = 'mc-overlay';
overlay.setAttribute('data-testid', 'model-card-overlay');
// dialog shell
const dialog = document.createElement('div');
dialog.className = 'mc-dialog';
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
const titleId = `mc-title-${Math.random().toString(36).slice(2)}`,
descId = `mc-desc-${Math.random().toString(36).slice(2)}`;
dialog.setAttribute('aria-labelledby', titleId);
dialog.setAttribute('aria-describedby', descId);
// header
const header = document.createElement('header');
header.className = 'mc-header';
const titleWrap = document.createElement('div');
titleWrap.className = 'mc-titlewrap';
const avatar = document.createElement('img');
avatar.className = 'mc-avatar';
if (model?.info?.meta?.profile_image_url) {
avatar.src = model.info.meta.profile_image_url;
avatar.alt = '';
} else {
avatar.src = 'data:image/gif;base64,R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==';
avatar.alt = '';
avatar.classList.add('mc-avatar--placeholder');
}
const h1 = document.createElement('h1');
h1.id = titleId;
h1.className = 'mc-title';
h1.textContent = model?.name ?? model?.id ?? 'model';
const owner = document.createElement('div');
owner.className = 'mc-subtitle';
owner.textContent = model?.owned_by ? `by ${model.owned_by}` : '';
titleWrap.appendChild(avatar);
titleWrap.appendChild(h1);
if (owner.textContent) titleWrap.appendChild(owner);
const closeBtn = document.createElement('button');
closeBtn.className = 'mc-close';
closeBtn.type = 'button';
closeBtn.setAttribute('aria-label', 'close');
closeBtn.textContent = '×';
header.appendChild(titleWrap);
header.appendChild(closeBtn);
// body
const body = document.createElement('div');
body.className = 'mc-body';
// description (primary)
const desc = document.createElement('p');
desc.id = descId;
desc.className = 'mc-desc';
desc.textContent = model?.info?.meta?.description ?? 'no description provided.';
// badges (primary)
const badges = document.createElement('div');
badges.className = 'mc-badges';
const addBadge = (label, value) => {
if (value === undefined || value === null || value === '') return;
const b = document.createElement('span');
b.className = 'mc-badge';
b.textContent = `${label}: ${value}`;
badges.appendChild(b);
};
addBadge('family', model?.ollama?.details?.family ?? model?.ollama?.details?.families?.[0]);
addBadge('params', model?.ollama?.details?.parameter_size);
addBadge('quant', model?.ollama?.details?.quantization_level);
addBadge('format', model?.ollama?.details?.format);
addBadge('connection', model?.connection_type ?? model?.ollama?.connection_type);
addBadge('created', formatEpoch(model?.created));
addBadge('modified', formatIso(model?.ollama?.modified_at));
// capabilities (primary)
const caps = model?.info?.meta?.capabilities ?? {};
const capsWrap = document.createElement('div');
capsWrap.className = 'mc-section';
const capsTitle = document.createElement('h2');
capsTitle.className = 'mc-section-title';
capsTitle.textContent = 'capabilities';
const capsList = document.createElement('ul');
capsList.className = 'mc-capabilities';
Object.entries(caps).forEach(([k, v]) => {
const li = document.createElement('li');
li.className = 'mc-cap';
const label = document.createElement('span');
label.className = 'mc-cap-label';
label.textContent = k.replaceAll('_', ' ');
const state = document.createElement('span');
state.className = `mc-cap-state ${v ? 'is-yes' : 'is-no'}`;
state.textContent = v ? 'yes' : 'no';
li.appendChild(label);
li.appendChild(state);
capsList.appendChild(li);
});
capsWrap.appendChild(capsTitle);
capsWrap.appendChild(capsList);
// advanced details (moved to disclosure)
const adv = document.createElement('details');
adv.className = 'mc-disclosure';
adv.setAttribute('data-testid', 'model-card-advanced');
const advSum = document.createElement('summary');
advSum.className = 'mc-disclosure-summary';
advSum.textContent = 'advanced details';
adv.appendChild(advSum);
const advBody = document.createElement('div');
advBody.className = 'mc-disclosure-body';
// details table (now inside disclosure)
const table = document.createElement('table');
table.className = 'mc-table';
const tbody = document.createElement('tbody'),
rows = [
['id', model?.id],
['object', model?.object],
['digest', model?.ollama?.digest],
['size', formatBytes(model?.ollama?.size)],
['parameter size', model?.ollama?.details?.parameter_size],
['quantization', model?.ollama?.details?.quantization_level],
['base model id', safeText(model?.info?.base_model_id) ?? '—'],
['function calling', safeText(model?.info?.params?.function_calling)],
['active', String(model?.info?.is_active ?? model?.info?.isActive ?? '—')],
];
rows.forEach(([k, v]) => {
if (v === undefined || v === null) return;
const tr = document.createElement('tr');
const th = document.createElement('th');
th.textContent = k;
const td = document.createElement('td');
td.textContent = String(v);
tr.appendChild(th);
tr.appendChild(td);
tbody.appendChild(tr);
});
table.appendChild(tbody);
// urls (inside disclosure; only if valid strings)
const urlSection = document.createElement('div');
urlSection.className = 'mc-section';
if (Array.isArray(model?.ollama?.urls) && model.ollama.urls.some(u => u && typeof u === 'string')) {
const urlsTitle = document.createElement('h2');
urlsTitle.className = 'mc-section-title';
urlsTitle.textContent = 'urls';
const list = document.createElement('ul');
list.className = 'mc-links';
model.ollama.urls.forEach(u => {
if (!u || typeof u !== 'string') return;
const li = document.createElement('li');
const a = document.createElement('a');
a.rel = 'noopener noreferrer';
a.target = '_blank';
a.href = u;
a.textContent = u;
li.appendChild(a);
list.appendChild(li);
});
urlSection.appendChild(urlsTitle);
urlSection.appendChild(list);
}
// raw json (inside disclosure)
const rawWrap = document.createElement('details');
rawWrap.className = 'mc-disclosure mc-disclosure--nested';
const rawSum = document.createElement('summary');
rawSum.className = 'mc-disclosure-summary';
rawSum.textContent = 'raw model json';
rawWrap.appendChild(rawSum);
const pre = document.createElement('pre');
pre.className = 'mc-pre';
pre.textContent = prettyJson(model);
rawWrap.appendChild(pre);
// assemble disclosure body
advBody.appendChild(table);
if (urlSection.childNodes.length) advBody.appendChild(urlSection);
advBody.appendChild(rawWrap);
adv.appendChild(advBody);
// assemble modal content
body.appendChild(desc);
body.appendChild(badges);
body.appendChild(capsWrap);
body.appendChild(adv); // advanced section at the very end
dialog.appendChild(header);
dialog.appendChild(body);
overlay.appendChild(dialog);
mount.appendChild(overlay);
// focus management (trap + restore)
const opener = options.trigger instanceof HTMLElement ? options.trigger : /** @type {HTMLElement|null} */(document.activeElement);
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
const findFocusable = () => /** @type {HTMLElement[]} */(Array.from(dialog.querySelectorAll(focusableSelectors))),
focusFirst = () => {
const items = findFocusable();
const target = items[0] || closeBtn;
target.focus();
};
const onKeydown = (e) => {
if (e.key === 'Tab') {
const items = findFocusable();
if (items.length === 0) {
e.preventDefault();
closeBtn.focus();
return;
}
const first = items[0],
last = items[items.length - 1],
active = /** @type {HTMLElement} */(document.activeElement);
if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
} else if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
}
}
if (e.key === 'Escape') {
e.preventDefault();
close();
}
};
const onOverlayClick = (e) => {
if (e.target === overlay) close();
};
overlay.addEventListener('click', onOverlayClick);
document.addEventListener('keydown', onKeydown);
closeBtn.addEventListener('click', () => close());
// prevent background scroll while open
const prevOverflow = document.documentElement.style.overflow;
document.documentElement.style.overflow = 'hidden';
// open
setTimeout(focusFirst, 0);
function close() {
overlay.removeEventListener('click', onOverlayClick);
document.removeEventListener('keydown', onKeydown);
document.documentElement.style.overflow = prevOverflow;
overlay.remove();
if (opener && typeof opener.focus === 'function') opener.focus();
}
return { close, root: overlay };
}
// helpers
function formatBytes(n) {
if (typeof n !== 'number') return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0, v = n;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(1)} ${units[i]}`;
}
const formatEpoch = (epochSeconds) => {
if (!Number.isFinite(epochSeconds)) return '—';
try { return new Date(epochSeconds * 1000).toLocaleString(); } catch { return '—'; }
}
function formatIso(iso) {
if (!iso || typeof iso !== 'string') return '—';
const d = new Date(iso);
return isNaN(d.getTime()) ? iso : d.toLocaleString();
}
const safeText = (v) => { return v === undefined || v === null ? null : String(v); },
prettyJson = (obj) => {
try { return JSON.stringify(obj, null, 2); } catch { return String(obj); }
}
const makeOptionLabel = (m, models) => {
const base = m?.name ?? m?.id ?? 'model',
duplicates = models.filter(x => (x?.name ?? x?.id) === (m?.name ?? m?.id));
if (duplicates.length > 1) {
const hint = m?.ollama?.details?.parameter_size || m?.ollama?.details?.quantization_level;
return hint ? `${base}${hint}` : `${base}${String(m?.id).slice(0, 8)}`;
}
return base;
};
// render options
function renderModelList(models) {
const sel = document.querySelector('#model-select'),
card = document.querySelector('#selected-model-card');
if (!sel) {
console.warn('failed to find model dropdown');
return;
}
const list = Array.isArray(models) ? models : [];
sel.__modelsData = list;
if (card) card.__modelsData = list;
const prevValue = sel.value;
for (let i = sel.options.length - 1; i >= 1; i--) sel.remove(i);
list.forEach(m => {
const opt = document.createElement('option');
opt.value = String(m?.id ?? '');
opt.textContent = makeOptionLabel(m, list);
sel.appendChild(opt);
});
const selectedExists = list.some(m => String(m?.id ?? '') === prevValue);
sel.value = selectedExists ? prevValue : '';
const updateCard = (id) => {
if (!card) return;
const registry = card.__modelsData || [],
model = registry.find(m => String(m?.id ?? '') === id);
if (!id || !model) {
card.hidden = true;
card.dataset.modelId = '';
card.replaceChildren();
card.setAttribute('aria-label', 'No model selected');
return;
}
card.hidden = false;
card.dataset.modelId = id;
card.setAttribute('aria-label', `Selected model ${makeOptionLabel(model, registry)}`);
card.replaceChildren();
const title = document.createElement('span');
title.className = 'selected-model-title';
title.textContent = model?.name ?? model?.id ?? 'model';
const metaParts = [];
if (model?.owned_by) metaParts.push(model.owned_by);
const paramSize = model?.ollama?.details?.parameter_size;
if (paramSize) metaParts.push(paramSize);
const quant = model?.ollama?.details?.quantization_level;
if (quant) metaParts.push(quant);
const metaText = metaParts.join(' • '),
descRaw = (model?.info?.meta?.description ?? '').trim(),
descText = descRaw.length > 90 ? `${descRaw.slice(0, 87).trim()}` : descRaw;
const hint = document.createElement('span');
hint.className = 'selected-model-hint';
hint.textContent = 'Open model details';
card.appendChild(title);
if (metaText) {
const meta = document.createElement('span');
meta.className = 'selected-model-meta';
meta.textContent = metaText;
card.appendChild(meta);
}
if (descText) {
const desc = document.createElement('span');
desc.className = 'selected-model-desc';
desc.textContent = descText;
card.appendChild(desc);
}
card.appendChild(hint);
};
if (!sel.__modelChangeHandler) {
sel.__modelChangeHandler = (e) => {
const target = /** @type {HTMLSelectElement} */(e.currentTarget),
id = target.value;
if (id) updateCard(id);
// const registry = sel.__modelsData || [],
// model = registry.find(m => String(m?.id ?? '') === id);
// if (!model) return;
// generateModelCard(model, { trigger: sel });
};
sel.addEventListener('change', sel.__modelChangeHandler);
}
if (card && !card.dataset.bound) {
card.addEventListener('click', () => {
const id = card.dataset.modelId;
if (!id) return;
const registry = card.__modelsData || [];
const model = registry.find(m => String(m?.id ?? '') === id);
if (model) generateModelCard(model, { trigger: card });
});
card.dataset.bound = 'true';
}
updateCard(sel.value);
}
+439 -119
View File
@@ -1,8 +1,19 @@
const storedMe = (() => {
try {
const raw = localStorage.getItem("me") ?? localStorage.getItem("user");
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
})();
const state = {
userId: localStorage.getItem("userId") || "",
displayName: localStorage.getItem("displayName") || "",
me: storedMe,
};
/**
* @returns {HTMLElement}
*/
const $ = (sel) => document.querySelector(sel),
setText = (sel, v) => {
const el = $(sel);
@@ -14,74 +25,314 @@ const authStatusEl = $("#authStatus"),
createStatusEl = $("#createStatus"),
runNowStatusEl = $("#runNowStatus"),
schedulesTbody = $("#schedulesTbody"),
templatesUl = $("#templatesUl");
templatesUl = $("#templatesUl"),
oneShotCheckmark = $("#oneShot"),
startAtInput = $("#startAt"),
cronInput = $("#cron"),
cronError = $("#cronError"),
filesInput = $("#scheduleFiles"),
filesList = $("#scheduleFilesList");
// update login ui from state
function paintAuth() {
$("#userId").value = state.userId || "";
$("#displayName").value = state.displayName || "";
if (state.userId) {
authStatusEl.textContent = `logged in as ${state.displayName ? state.displayName + " · " : ""
}${state.userId}`;
const cron5Regex = new RegExp(
'^' +
'(\\*|([0-5]?\\d)(\\/\\d+)?|\\d+(,\\d+)*|\\d+(-\\d+))\\s+' +
'(\\*|([01]?\\d|2[0-3])(\\/\\d+)?|\\d+(,\\d+)*|\\d+(-\\d+))\\s+' +
'(\\*|([1-9]|[12]\\d|3[01])(\\/\\d+)?|\\d+(,\\d+)*|\\d+(-\\d+))\\s+' +
'(\\*|([1-9]|1[0-2])(\\/\\d+)?|\\d+(,\\d+)*|\\d+(-\\d+))\\s+' +
'(\\*|[0-7](\\/\\d+)?|\\d+(,\\d+)*|\\d+(-\\d+))' +
'$'
);
const validateCron = (e) => {
if (!cronInput || !cronError) return true;
const val = cronInput.value.trim();
if (cron5Regex.test(val)) {
cronError.style.display = 'none';
cronError.textContent = '';
return true;
}
e?.preventDefault();
cronError.textContent = 'please enter a valid cron expression';
cronError.style.display = 'block';
return false;
};
cronInput?.addEventListener('input', () => {
if (!cronError) return;
const val = cronInput.value.trim();
if (cron5Regex.test(val)) {
cronError.style.display = 'none';
cronError.textContent = '';
} else {
authStatusEl.textContent = "not logged in";
cronError.style.display = 'block';
}
});
const formatBytes = (bytes) => {
const size = Number(bytes);
if (!Number.isFinite(size) || size < 0) return '';
if (size === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const idx = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
const scaled = size / Math.pow(1024, idx);
return `${scaled.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
};
const readFileAsDataUrl = (file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error(`failed to read file ${file?.name || ''}`));
reader.readAsDataURL(file);
});
const renderSelectedFiles = () => {
if (!filesList) return;
const files = filesInput?.files ? Array.from(filesInput.files) : [];
if (!files.length) {
filesList.innerHTML = '<li class="muted empty">no files selected</li>';
return;
}
filesList.innerHTML = '';
files.forEach((file) => {
const li = document.createElement('li');
const size = formatBytes(file.size);
li.textContent = size ? `${file.name} (${size})` : file.name;
filesList.appendChild(li);
});
};
filesInput?.addEventListener('change', renderSelectedFiles);
renderSelectedFiles();
// JSON
const paramsEl = $('#params'),
options = {
mode: 'code',
modes: ['code', 'form', 'text'], // allowed modes
onModeChange: function (newMode, oldMode) {
console.log('Mode switched from', oldMode, 'to', newMode)
}
},
jInstance = new JSONEditor(paramsEl, options)
// set json
const initialJson = {
"report_kind": "summary",
"Numbers": [1, 2, 3]
}
jInstance.set(initialJson);
window.jInstance = jInstance;
const togCron = ({ target: { checked } }) => {
const cron = cronInput?.parentElement,
start = startAtInput?.parentElement,
warn = $(".warning-box")?.parentElement;
if (!cron || !start || !warn) return;
if (checked) {
cron.style.display = 'none';
warn.style.display = 'none';
start.style.display = 'block';
if (startAtInput) startAtInput.required = true;
if (cronInput) cronInput.required = false;
} else {
cron.style.display = 'block';
warn.style.display = 'flex';
start.style.display = 'block';
if (startAtInput) startAtInput.required = false;
if (cronInput) cronInput.required = true;
}
}
oneShotCheckmark.addEventListener('change', togCron);
togCron({ target: { checked: oneShotCheckmark.checked } });
// update login ui from state
async function paintAuth() {
const meRes = await fetch('/api/me', {
headers: {
'credentials': 'include',
'Content-Type': 'application/json'
}
});
if (meRes.ok) {
const me = await meRes.json().catch(console.error);
if (me) {
localStorage.setItem("me", JSON.stringify(me));
state.me = me;
}
}
if (state.me && state.me.id) {
const who = state.me.name || state.me.email || state.me.id;
authStatusEl.textContent = `logged in as ${who}`;
document.querySelector('#userLogoutBtn').style.display = 'block';
// populate user-card
const userCard = $("#userCard"),
avatar = $("#userAvatar"),
nameEl = $("#userName"),
emailEl = $("#userEmail"),
roleEl = $("#userRole"),
permsEl = $("#userPermissions");
if (state.me.models) renderModelList(state.me.models);
if (state.me.tools) renderToolList(state.me.tools);
const featurePerms = state.me.permissions?.features;
renderFeatureList(featurePerms);
if (userCard) userCard.setAttribute("aria-hidden", "false");
if (avatar) {
avatar.src = state.me.profile_image_url || "";
avatar.alt = state.me.name || state.me.email || "user avatar";
}
if (nameEl) nameEl.textContent = state.me.name || state.me.email || state.me.id;
if (emailEl) emailEl.textContent = state.me.email || "";
if (roleEl) roleEl.textContent = state.me.role ? `role: ${state.me.role}` : "";
if (permsEl) {
permsEl.hidden = false;
renderSelectedPermissions(state.me, permsEl);
}
document.querySelector('#auth').ariaHidden = 'true';
} else {
authStatusEl.textContent = "not logged in";
document.querySelector('#userLogoutBtn').style.display = 'none';
// clear credential inputs if present
const u = $("#username"),
p = $("#password");
if (u) u.value = "";
if (p) p.value = "";
// hide user-card
const userCard = $("#userCard"),
avatar = $("#userAvatar"),
nameEl = $("#userName"),
emailEl = $("#userEmail"),
roleEl = $("#userRole"),
permsEl = $("#userPermissions");
if (userCard) userCard.setAttribute("aria-hidden", "true");
if (avatar) { avatar.src = ""; avatar.alt = ""; }
if (nameEl) nameEl.textContent = "not logged in";
if (emailEl) emailEl.textContent = "";
if (roleEl) roleEl.textContent = "";
if (permsEl) {
permsEl.hidden = true;
// clear values
permsEl.querySelectorAll(".perm-val").forEach(el => el.textContent = "");
}
renderFeatureList();
}
}
// helper to safely resolve nested permission path like "features.web_search"
function getPerm(me, path) {
if (!me || !me.permissions || !path) return false;
return path.split('.').reduce((acc, k) => (acc && typeof acc === 'object') ? acc[k] : undefined, me.permissions) || false;
}
function renderSelectedPermissions(me, container) {
// mapping of displayed items -> permission path
const items = [
{ path: 'workspace.tools', label: 'Workspace (tools)' },
{ path: 'chat.file_upload', label: 'Chat (file_upload)' },
{ path: 'features.web_search', label: 'web_search' },
{ path: 'features.code_interpreter', label: 'code_interpreter' }
],
isadmin = me.role === "admin";
// update all .perm-val placeholders by data-perm attribute when present
container.querySelectorAll('.perm-val').forEach(el => {
const key = el.getAttribute('data-perm');
if (key) {
const v = isadmin || getPerm(me, key);
el.textContent = v ? '✅' : '❌';
el.classList.toggle('perm-yes', Boolean(v));
el.classList.toggle('perm-no', !v);
el.title = `${key}: ${v ? 'allowed' : 'denied'}`;
}
});
// fallback placeholders
items.forEach(it => {
const sel = `[data-perm="${it.path}"]`;
if (!container.querySelector(sel)) {
const li = document.createElement('li');
li.innerHTML = `<span class="perm-val ${getPerm(me, it.path) ? 'perm-yes' : 'perm-no'}">${isadmin || getPerm(me, it.path) ? '✅' : '❌'}</span>`;
li.innerHTML += `<span class="perm-key">${escapeHtml(it.label)}</span>`;
container.querySelector('.perms-list')?.appendChild(li);
}
});
}
// theme toggle stuff!
const prefersLight = document.defaultView?.matchMedia?.('(prefers-color-scheme: light)').matches === true,
saved = localStorage.getItem('theme'),
initial = saved ?? (prefersLight ? 'light' : 'dark');
const prefersLight = document.defaultView?.matchMedia?.("(prefers-color-scheme: light)").matches === true,
saved = localStorage.getItem("theme"),
initial = saved ?? (prefersLight ? "light" : "dark");
const applyTheme = (mode) => {
const b = document.body;
b.classList.remove('theme-dark', 'theme-light');
b.classList.add(mode === 'dark' ? 'theme-dark' : 'theme-light');
b.classList.remove("theme-dark", "theme-light");
b.classList.add(mode === "dark" ? "theme-dark" : "theme-light");
const icon = document.querySelector('#themeToggleIcon'),
label = document.querySelector('#themeToggleLabel');
const icon = document.querySelector("#themeToggleIcon"),
label = document.querySelector("#themeToggleLabel");
if (icon && label) {
const isDark = mode === 'dark';
icon.textContent = isDark ? '🌙' : '☀️';
label.textContent = isDark ? 'Dark' : 'Light';
const isDark = mode === "dark";
icon.textContent = isDark ? "🌙" : "☀️";
label.textContent = isDark ? "Dark" : "Light";
}
document.querySelector('#themeToggle')?.setAttribute('aria-pressed', String(mode === 'dark'));
document.querySelector("#themeToggle")?.setAttribute("aria-pressed", String(mode === "dark"));
};
applyTheme(initial);
document.querySelector('#themeToggle')?.addEventListener('click', () => {
const next = document.body.classList.contains('theme-dark') ? 'light' : 'dark';
document.querySelector("#themeToggle")?.addEventListener("click", () => {
const next = document.body.classList.contains("theme-dark") ? "light" : "dark";
applyTheme(next);
try { localStorage.setItem('theme', next); } catch (_) { /* ignore */ }
try {
localStorage.setItem("theme", next);
} catch (_) {
/* ignore */
}
});
if (window.matchMedia) {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener?.("change", (e) => {
// only adapt to system changes when user hasn't explicitly chosen a theme
if (!localStorage.getItem('theme')) applyTheme(e.matches ? 'dark' : 'light');
if (!localStorage.getItem("theme")) applyTheme(e.matches ? "dark" : "light");
});
}
// wrap fetch to always attach x-user-id
// wrap fetch to always attach x-user-id and Authorization when available
async function apiFetch(url, options = {}) {
const headers = new Headers(options.headers || {});
if (!state.userId)
throw new Error(
"no user id set — use the login form first"
);
headers.set("x-user-id", state.userId); // custom header
if (
!headers.has("content-type") &&
options.body &&
!(options.body instanceof FormData)
) {
if (!state.me || !state.me.id) {
throw new Error("no authenticated user — use the login form first");
}
headers.set("x-user-id", state.me.id); // keep existing custom header for server compatibility
if (state.me.token) {
const typ = state.me.token_type || "Bearer";
headers.set("authorization", `${typ} ${state.me.token}`);
}
if (!headers.has("content-type") && options.body && !(options.body instanceof FormData)) {
headers.set("content-type", "application/json");
}
headers.set('credentials', 'include');
const resp = await fetch(url, { ...options, headers });
if (!resp.ok) {
// try to surface json error bodies
@@ -99,67 +350,117 @@ async function apiFetch(url, options = {}) {
function renderSchedules(items = []) {
schedulesTbody.innerHTML = "";
items.forEach((it) => {
const tr = document.createElement("tr");
const tRef = it.templateRef
const tr = document.createElement("tr"),
tRef = it.templateRef
? it.templateRef.clusterScope
? `(cluster) ${it.templateRef.name}`
: it.templateRef.name
: "";
tr.innerHTML = `
<td>${escapeHtml(it.displayName || it.name || "")}</td>
<td>${(it.schedules || []).map(escapeHtml).join("<br/>")}</td>
<td>${escapeHtml(it.timezone || "")}</td>
<td>${escapeHtml(it.startAt || "")}</td>
<td>${escapeHtml(tRef)}</td>
<td>${escapeHtml(it.entrypoint || "")}</td>
<td>${escapeHtml(it.prompt || "")}</td>
<td>${it.oneShot ? "yes" : "no"}</td>
<td class="actions">
<button data-del="${encodeURIComponent(it.name)}">delete</button>
</td>
`;
schedulesTbody.appendChild(tr);
});
}
// tiny escape helper
function escapeHtml(s = "") {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
return String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
// wire up events
$("#loginForm").addEventListener("submit", (e) => {
$("#loginForm").addEventListener("submit", async (e) => {
e.preventDefault();
const userId = $("#userId").value.trim(),
displayName = $("#displayName").value.trim();
if (!userId) {
authStatusEl.textContent = "please enter a user id";
const username = $("#username").value.trim(),
password = $("#password").value;
if (!username || !password) {
authStatusEl.textContent = "please enter username and password";
return;
}
state.userId = userId;
state.displayName = displayName;
localStorage.setItem("userId", state.userId);
localStorage.setItem("displayName", state.displayName);
try {
authStatusEl.textContent = "authenticating...";
const res = await fetch("/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: username, password }),
});
if (!res.ok) {
let msg = `${res.status} ${res.statusText}`;
try {
const d = await res.json();
if (d && d.error) msg = d.error;
} catch { }
throw new Error(msg);
}
const data = await res.json(),
me = data.me || data.user || data;
if (!me || !me.id) {
throw new Error("invalid login response (missing id)");
}
// // persist cookie if server returned one
// if (data.cookie) {
// try {
// document.cookie = `${document.cookie.replace(/owebucookie=.*;/, '')}owebucookie=${data.cookie}`;
// } catch { }
// }
// store canonical "me"
try {
localStorage.setItem("me", JSON.stringify(me));
// keep old key for compatibility
localStorage.setItem("user", JSON.stringify(me));
} catch { }
state.me = me;
paintAuth();
// trigger refresh immediately
$("#refreshBtn")?.click();
} catch (err) {
authStatusEl.textContent = `login error: ${err.message}`;
}
});
$("#logoutBtn").addEventListener("click", () => {
localStorage.removeItem("userId");
localStorage.removeItem("displayName");
state.userId = "";
state.displayName = "";
// central logout routine used by multiple buttons
function doLogout() {
try {
localStorage.removeItem("me");
localStorage.removeItem("user");
} catch { }
state.me = null;
paintAuth();
});
}
// existing logout button (if present) + user card logout
const logoutBtn = $("#logoutBtn"),
userLogoutBtn = $("#userLogoutBtn");
if (logoutBtn) logoutBtn.addEventListener("click", doLogout);
if (userLogoutBtn) userLogoutBtn.addEventListener("click", doLogout);
$("#refreshBtn").addEventListener("click", async () => {
try {
listStatusEl.textContent = "loading...";
const res = await apiFetch("/api/schedules");
const data = await res.json();
const res = await apiFetch("/api/schedules"),
data = await res.json();
renderSchedules(data.items || []);
listStatusEl.textContent = `loaded ${Array.isArray(data.items) ? data.items.length : 0
} schedule(s)`;
listStatusEl.textContent = `loaded ${Array.isArray(data.items) ? data.items.length : 0} schedule(s)`;
} catch (e) {
listStatusEl.textContent = `error: ${e.message}`;
}
@@ -175,7 +476,7 @@ schedulesTbody.addEventListener("click", async (e) => {
try {
target.disabled = true;
const res = await apiFetch(`/schedules/${name}`, {
const res = await apiFetch(`/api/schedules/${name}`, {
method: "DELETE",
});
@@ -198,17 +499,36 @@ $("#createForm").addEventListener("submit", async (e) => {
try {
createStatusEl.textContent = "saving...";
const name = $("#name").value.trim(),
tz = $("#tz").value.trim() || "America/New_York",
iso = $("#iso").value
? new Date($("#iso").value).toISOString()
: "",
startAt = startAtInput?.value || "",
cron = $("#cron").value.trim(),
templateName = $("#templateName").value.trim(),
entrypoint = $("#entrypoint").value.trim(),
prompt = $("#prompt").value.trim(),
clusterScope = $("#clusterScope").checked,
oneShot = $("#oneShot").checked,
paramsRaw = $("#params").value.trim();
paramsRaw = jInstance.getText(),
model = $("#model-select").value.trim(),
tools = Array.from(document
.querySelectorAll("#tools-select [aria-selected='true']"))
.map(o => o.dataset.toolId),
featuresInput = $("#features-select-input");
if (!oneShot && !cron) {
throw new Error("cron expression is required");
}
if (!oneShot && !validateCron(e)) return;
if (oneShot && !startAt) {
throw new Error("start date is required for one-shot schedules");
}
const jErrs = await jInstance.validate();
if (jErrs?.length) {
console.error(jErrs);
throw new Error(`Please fix your JSON errors!`);
}
// leave as extra validation
let parameters = {};
if (paramsRaw) {
try {
@@ -218,64 +538,62 @@ $("#createForm").addEventListener("submit", async (e) => {
}
}
let features = {};
if (featuresInput && featuresInput.value) {
try {
const parsed = JSON.parse(featuresInput.value);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
features = Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, Boolean(v)]));
}
} catch {
throw new Error("features selection must be valid json");
}
}
const attachments = filesInput?.files ? Array.from(filesInput.files) : [];
const filesPayload = await Promise.all(attachments.map(async (file) => ({
fname: file.name,
content: await readFileAsDataUrl(file)
})));
const when = oneShot ? { start: startAt } : { cron };
if (!oneShot && startAt) when.start = startAt;
const payload = {
name,
when: cron ? { cron } : { iso },
tz,
when,
oneShot,
template: { name: templateName, clusterScope },
parameters,
entrypoint: entrypoint || undefined,
prompt,
model,
tools,
features
};
if (filesPayload.length) payload.files = filesPayload;
await apiFetch("/schedules", {
await apiFetch("/api/schedules", {
method: "POST",
body: JSON.stringify(payload),
});
createStatusEl.textContent = "saved ✅";
$("#refreshBtn").click();
// clear the form
$("#createForm").reset();
togCron({ target: { checked: $("#oneShot").checked } });
renderSelectedFiles();
if (cronError) {
cronError.style.display = 'none';
cronError.textContent = '';
}
} catch (err) {
console.error(err);
createStatusEl.textContent = `error: ${err.message}`;
}
});
// run now
$("#runNowForm").addEventListener("submit", async (e) => {
e.preventDefault();
try {
runNowStatusEl.textContent = "starting...";
const name = $("#rnName").value.trim() || "ad-hoc",
templateName = $("#rnTemplateName").value.trim(),
entrypoint = $("#rnEntrypoint").value.trim(),
clusterScope = $("#rnClusterScope").checked,
paramsRaw = $("#rnParams").value.trim();
let parameters = {};
if (paramsRaw) {
try {
parameters = JSON.parse(paramsRaw);
} catch {
throw new Error("parameters must be valid json");
}
}
const payload = {
name,
template: { name: templateName, clusterScope },
entrypoint: entrypoint || undefined,
parameters,
};
await apiFetch("/run-now", {
method: "POST",
body: JSON.stringify(payload),
});
runNowStatusEl.textContent = "started ✅";
} catch (err) {
runNowStatusEl.textContent = `error: ${err.message}`;
}
});
// load workflow templates for convenience
$("#loadTemplatesBtn").addEventListener("click", async () => {
@@ -292,14 +610,16 @@ $("#loadTemplatesBtn").addEventListener("click", async () => {
templatesUl.appendChild(li);
});
} catch (e) {
templatesUl.innerHTML = `<li class="danger">error: ${escapeHtml(
e.message
)}</li>`;
templatesUl.innerHTML = `<li class="danger">error: ${escapeHtml(e.message)}</li>`;
}
});
// boot
paintAuth();
// window.onbeforeunload = () => CookieStore.delete('token');
// auto-refresh if already logged in
if (state.userId) $("#refreshBtn").click();
const reffunc = () => {
// auto-refresh if already logged in
if (state.me && state.me.id) $("#refreshBtn").click();
}
// boot
paintAuth().then(reffunc);
+248 -3
View File
@@ -48,6 +48,12 @@ body.theme-dark {
--border: rgba(255, 255, 255, 0.1);
--accent: #7aa2ff;
--accent-600: #4f7dff;
/* warn box */
--box-bg: #2c2c2c;
--box-text: #f5f5f5;
--box-border: #ff6b6b;
--icon-fill: #ff6b6b;
}
body.theme-light {
@@ -61,6 +67,12 @@ body.theme-light {
--border: #e5e9f2;
--accent: #2563eb;
--accent-600: #1e40af;
/* warn box */
--box-bg: #ffffff;
--box-text: #000000;
--box-border: #d9534f;
--icon-fill: #d9534f;
}
@@ -70,7 +82,7 @@ body.theme-light {
html,
body {
height: 100%;
min-height: 100%;
}
body {
@@ -128,7 +140,7 @@ body {
/* content */
.content-grid {
max-width: var(--maxw);
margin: 1.2rem auto 2rem;
/* margin: 1.2rem auto 2rem; */
padding: 0 clamp(1rem, 3vw, 1.25rem);
display: grid;
grid-template-columns: 360px 1fr;
@@ -150,12 +162,17 @@ body {
.col-right {}
.templates summary {
cursor: pointer;
}
/* cards */
.card {
background: linear-gradient(180deg, rgba(255, 255, 255, .04), rgba(255, 255, 255, .02)), var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 10px 30px rgba(2, 6, 23, 0.28), 0 1px 0 rgba(255, 255, 255, .04) inset;
padding: 10px;
}
.card.compact {
@@ -180,6 +197,36 @@ body {
gap: .9rem;
}
.file-input {
display: grid;
gap: .35rem;
font-size: .95rem;
}
.file-input-label {
font-weight: 600;
}
.file-input input[type="file"] {
font-size: .95rem;
}
.file-list {
margin: .5rem 0 0;
padding-left: 1.2rem;
list-style: disc;
color: var(--muted);
font-size: .9rem;
}
.file-list li + li {
margin-top: .25rem;
}
.file-list .empty::marker {
content: '';
}
.row {
display: grid;
gap: .9rem;
@@ -193,7 +240,16 @@ body {
}
}
label {
ul[class="row"] {
display: block;
line-height: 25px;
list-style: none;
padding: 0;
margin: 10px 0px;
}
label,
.label {
display: block;
font-weight: 600;
font-size: .92rem;
@@ -331,6 +387,9 @@ td:first-child {
display: flex;
gap: .5rem;
align-items: center;
padding: 10px 0px;
width: 100%;
justify-content: space-evenly;
}
.actions.wrap {
@@ -366,6 +425,153 @@ code.inline {
font-size: .9rem;
}
/* auth card */
.auth-card {
border: 1px solid var(--border, #333);
border-radius: 8px;
padding: 1rem;
margin: 1rem 0 2rem;
max-width: 480px;
background: var(--panel, rgba(0, 0, 0, 0.05));
}
.auth-card h2 {
margin: 0 0 .5rem;
font-size: 1.1rem;
}
.auth-card form {
display: grid;
gap: .75rem;
}
.auth-card label {
display: grid;
gap: .25rem;
font-size: .9rem;
}
.auth-card input {
padding: .5rem .6rem;
border-radius: 6px;
border: 1px solid var(--border, #333);
background: var(--bg, #fff);
color: inherit;
}
.auth-card .row {
display: flex;
gap: .5rem;
align-items: center;
}
.auth-card button {
padding: .45rem .8rem;
border-radius: 6px;
border: 1px solid var(--border, #333);
color: inherit;
cursor: pointer;
}
/* user card */
.user-card {
height: fit-content;
}
.user-card .user-compact {
display: flex;
gap: 0.6rem;
align-items: center;
padding: 0.6rem;
}
.user-card .avatar {
width: 48px;
height: 48px;
border-radius: 999px;
object-fit: cover;
background: rgba(0, 0, 0, 0.06);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.user-card .user-info {
min-width: 0;
}
.user-card .user-name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-card .user-email,
.user-card .user-role {
font-size: 0.85rem;
opacity: 0.8;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card[aria-hidden="true"] {
display: none;
}
/* permissions UI */
.permissions {
margin-top: .6rem;
padding: 1rem;
border-top: 1px dashed rgba(0, 0, 0, 0.06);
}
.permissions .small {
margin: 0 0 .35rem;
font-size: .85rem;
color: var(--muted, #666);
}
.perms-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: .25rem;
font-size: .9rem;
margin-left: 5px;
}
.perms-list .perm-key {
display: inline-block;
min-width: 140px;
color: var(--muted, #666);
}
.nested-perms {
list-style: none;
padding: 0 0 0 .5rem;
margin: 0;
display: grid;
gap: .15rem;
}
.perm-val {
font-weight: 700;
margin-left: .5rem;
}
.perm-yes {
color: #0b875b;
}
/* green */
.perm-no {
color: #c23b3b;
}
/* red */
/* high contrast preference gets stronger focus */
@media (prefers-contrast: more) {
@@ -377,3 +583,42 @@ code.inline {
box-shadow: 0 0 0 3px #fff, 0 0 0 5px var(--accent);
}
}
/* Box layout and styling */
.warning-box {
display: inline-flex;
align-items: center;
/* Space between icon and text */
gap: 0.6rem;
margin-top: 10px;
padding: 0.4rem 0.8rem;
font-family: system-ui, sans-serif;
font-size: 0.9rem;
color: var(--box-text);
background: var(--box-bg);
border: 1px solid var(--box-border);
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
max-height: 50px;
}
/* Icon styling */
.warning-icon {
width: 1.2rem;
height: 1.2rem;
flex-shrink: 0;
fill: var(--icon-fill);
}
/* make the whole box a bit more readable on hover */
.warning-box:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
cursor: pointer;
}
.form-subsection {
border-top: 1px dashed var(--border);
padding-top: 1rem;
margin-top: .25rem;
}
+382
View File
@@ -0,0 +1,382 @@
:root {
/* core palette tuned for strong contrast on very dark ui (aiming for wcag aa) */
--tc-bg: #120a09;
/* deep near-black with red tint */
--tc-surface: #1a0e0b;
/* card background */
--tc-surface-2: #21110e;
/* subtle layered bg */
--tc-text: #f3e9e7;
/* primary text */
--tc-text-subtle: #913413;
/* secondary text */
--tc-muted: #cbb2aa;
--tc-accent: #ff6b3d;
/* orange/red accent */
--tc-accent-2: #ff3d3d;
/* red accent for states */
--tc-border: rgba(255, 255, 255, 0.10);
--tc-border-2: rgba(255, 255, 255, 0.16);
--tc-overlay: rgba(0, 0, 0, 0.55);
--tc-link: #ffb083;
/* readable on dark bg */
}
.tc-overlay {
position: fixed;
inset: 0;
background: var(--tc-overlay);
display: grid;
place-items: center;
padding: clamp(12px, 2vw, 24px);
z-index: 9999;
}
.tc-dialog {
width: min(880px, 100%);
max-height: 90dvh;
overflow: auto;
border-radius: 12px;
background: linear-gradient(180deg, var(--tc-surface), var(--tc-surface-2));
color: var(--tc-text);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
outline: none;
border: 1px solid var(--tc-border);
}
.tc-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--tc-border);
position: sticky;
top: 0;
backdrop-filter: blur(6px);
background:
linear-gradient(to bottom, color-mix(in srgb, var(--tc-surface), transparent 20%), color-mix(in srgb, var(--tc-surface-2), transparent 30%)),
conic-gradient(from 0.25turn at 10% -40%, color-mix(in srgb, var(--tc-accent), transparent 85%), transparent 30%);
z-index: 1;
}
.tc-titlewrap {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.tc-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--tc-border-2);
background: #2a1512;
}
.tc-avatar--placeholder {
background: linear-gradient(135deg, #3b1e19, #2a1512);
}
.tc-title {
font-size: 1.1rem;
font-weight: 700;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tc-subtitle {
font-size: 0.85rem;
color: var(--tc-muted);
}
.tc-close {
appearance: none;
border: 1px solid transparent;
background: transparent;
color: var(--tc-muted);
font-size: 1.6rem;
line-height: 1;
border-radius: 8px;
padding: 4px 8px;
cursor: pointer;
}
.tc-close:hover {
color: var(--tc-text);
border-color: var(--tc-border-2);
background: color-mix(in srgb, var(--tc-accent), transparent 86%);
}
.tc-body {
padding: 20px;
display: grid;
gap: 18px;
}
.tc-desc {
margin: 0;
color: var(--tc-text);
}
.tc-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tc-badge {
font-size: 0.78rem;
padding: 6px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--tc-accent), transparent 88%);
border: 1px solid var(--tc-border-2);
color: var(--tc-text);
}
.tc-section {
display: grid;
gap: 10px;
}
.tc-section-title {
font-size: 0.95rem;
font-weight: 700;
color: var(--tc-text);
margin: 0;
}
.tc-manifest {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 8px 14px;
padding: 0;
margin: 0;
}
.tc-manifest-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--tc-border);
}
.tc-manifest-item a {
color: #4da6ff;
text-decoration: none;
}
.tc-manifest-item a:hover {
color: #80c1ff;
text-decoration: underline;
}
.tc-manifest-label {
color: var(--tc-text-subtle);
font-size: 0.92rem;
}
.tc-manifest-value {
font-size: 0.86rem;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--tc-border-2);
background: rgba(255, 255, 255, 0.06);
overflow-x: scroll;
}
.tc-table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--tc-border);
}
.tc-table th,
.tc-table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--tc-border);
vertical-align: top;
}
.tool-pill-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 6px 0;
min-height: 40px;
}
.tool-pill {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid var(--tc-border);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.tool-pill--selected {
background: color-mix(in srgb, var(--tc-accent), transparent 70%);
border-color: color-mix(in srgb, var(--tc-accent), transparent 40%);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.tool-pill-toggle {
appearance: none;
border: none;
background: transparent;
color: inherit;
font: inherit;
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
flex: 1 1 auto;
text-align: left;
}
.tool-pill-toggle:focus-visible,
.tool-pill-info:focus-visible {
outline: 2px solid color-mix(in srgb, var(--tc-accent), white 35%);
outline-offset: 2px;
}
.tool-pill-info {
appearance: none;
border: none;
background: transparent;
color: var(--tc-muted);
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-right: 4px;
}
.tool-pill-info:hover {
color: var(--tc-text);
background: rgba(255, 255, 255, 0.08);
}
.tool-pill-info span {
display: inline-block;
font-size: 0.9rem;
font-weight: 700;
}
.tool-pill-empty {
margin: 0;
}
.tc-table th {
width: 30%;
color: var(--tc-muted);
font-weight: 600;
}
.tc-links {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 6px;
}
.tc-links a {
color: var(--tc-link);
text-decoration: none;
word-break: break-all;
}
.tc-links a:hover {
text-decoration: underline;
}
/* disclosure (advanced details) */
.tc-disclosure {
border: 1px solid var(--tc-border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
overflow: hidden;
}
.tc-disclosure+.tc-disclosure {
margin-top: 8px;
}
.tc-disclosure-summary {
cursor: pointer;
list-style: none;
user-select: none;
padding: 12px 14px;
font-weight: 700;
color: var(--tc-text);
}
.tc-disclosure[open] .tc-disclosure-summary {
border-bottom: 1px solid var(--tc-border);
}
.tc-disclosure-body {
padding: 12px 14px;
display: grid;
gap: 16px;
}
/* nested disclosure for raw json */
.tc-disclosure--nested {
border: 1px dashed color-mix(in srgb, var(--tc-accent-2), transparent 65%);
background: rgba(255, 255, 255, 0.02);
}
.tc-disclosure--nested .tc-disclosure-summary {
font-weight: 600;
color: var(--tc-text-subtle);
}
.tc-pre {
margin: 0;
padding: 12px;
overflow: auto;
max-height: 40dvh;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 0.82rem;
line-height: 1.4;
border-radius: 8px;
background: #0d0706;
border: 1px solid var(--tc-border);
color: var(--tc-text);
}
/* responsive adjustments */
@media (max-width: 520px) {
.tc-header {
padding: 14px 16px;
}
.tc-body {
padding: 16px;
}
.tc-title {
font-size: 1rem;
}
}
+389
View File
@@ -0,0 +1,389 @@
function generateToolCard(tool, options = {}) {
const mount = options.mountSelector
? (document.querySelector(options.mountSelector))
: document.body;
if (!mount) throw new Error('mount element not found');
// overlay
const overlay = document.createElement('div');
overlay.className = 'tc-overlay';
overlay.setAttribute('data-testid', 'tool-card-overlay');
// dialog shell
const dialog = document.createElement('div');
dialog.className = 'tc-dialog';
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true'); // per apg modal dialog pattern
// apg & mdn recommend explicit labelling/description for dialogs
const titleId = `tc-title-${Math.random().toString(36).slice(2)}`,
descId = `tc-desc-${Math.random().toString(36).slice(2)}`;
dialog.setAttribute('aria-labelledby', titleId);
dialog.setAttribute('aria-describedby', descId);
// header
const header = document.createElement('header');
header.className = 'tc-header';
const titleWrap = document.createElement('div');
titleWrap.className = 'tc-titlewrap';
// // avatar (from user.profile_image_url if present)
// const avatar = document.createElement('img');
// avatar.className = 'tc-avatar';
// const profileUrl = tool?.user?.profile_image_url;
// if (typeof profileUrl === 'string' && profileUrl.length > 0) {
// avatar.src = profileUrl;
// avatar.alt = '';
// } else {
// avatar.src = 'data:image/gif;base64,R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==';
// avatar.alt = '';
// avatar.classList.add('tc-avatar--placeholder');
// }
// title + owner
const h1 = document.createElement('h1');
h1.id = titleId;
h1.className = 'tc-title';
h1.textContent = tool?.name ?? tool?.id ?? 'tool';
// const owner = document.createElement('div');
// owner.className = 'tc-subtitle';
// owner.textContent = tool?.user?.name ? `by ${tool.user.name}` : '';
// titleWrap.appendChild(avatar);
titleWrap.appendChild(h1);
// if (owner.textContent) titleWrap.appendChild(owner);
// close button
const closeBtn = document.createElement('button');
closeBtn.className = 'tc-close';
closeBtn.type = 'button';
closeBtn.setAttribute('aria-label', 'close');
closeBtn.textContent = '×';
header.appendChild(titleWrap);
header.appendChild(closeBtn);
// body
const body = document.createElement('div');
body.className = 'tc-body';
// description (primary)
const desc = document.createElement('p');
desc.id = descId;
desc.className = 'tc-desc';
desc.textContent =
tool?.meta?.description
?? tool?.meta?.manifest?.description
?? 'no description provided.';
// badges (primary)
const badges = document.createElement('div');
badges.className = 'tc-badges';
const addBadge = (label, value) => {
if (value === undefined || value === null || value === '') return;
const b = document.createElement('span');
b.className = 'tc-badge';
b.textContent = `${label}: ${value}`;
badges.appendChild(b);
};
addBadge('id', tool?.id);
addBadge('user', tool?.user?.name ?? tool?.user_id);
addBadge('version', tool?.meta?.manifest?.version);
addBadge('license', tool?.meta?.manifest?.license ?? tool?.meta?.manifest?.licence);
addBadge('updated', formatEpoch(tool?.updated_at));
addBadge('created', formatEpoch(tool?.created_at));
// manifest summary (primary)
const manifestWrap = document.createElement('div');
manifestWrap.className = 'tc-section';
const manifestTitle = document.createElement('h2');
manifestTitle.className = 'tc-section-title';
manifestTitle.textContent = 'manifest';
const manifestList = document.createElement('ul');
manifestList.className = 'tc-manifest';
/**
* @param {string} k
* @param {string} v
*/
const kv = (k, v) => {
if (v === undefined || v === null || v === '') return;
const li = document.createElement('li');
li.className = 'tc-manifest-item';
const label = document.createElement('span');
label.className = 'tc-manifest-label';
label.textContent = k;
li.appendChild(label);
const valueEl = document.createElement('span');
valueEl.className = 'tc-manifest-value';
valueEl.textContent = typeof v === 'string' ? v : JSON.stringify(v);
try {
// add https to all of them
const u = 'https://' + v.replace(/https?:\/\//, '');
new URL(u);
if (!(/https:\/\/.*\..*/.test(u))) throw "";
const uEl = document.createElement('a');
uEl.href = u;
uEl.target = '_blank';
uEl.appendChild(valueEl);
li.appendChild(uEl);
} catch {
li.appendChild(valueEl);
}
manifestList.appendChild(li);
};
const mani = tool?.meta?.manifest ?? {};
kv('title', mani.title);
kv('author', mani.author);
kv('author url', mani.author_url ?? mani.author_urls);
kv('email', mani.email);
kv('github', mani.github);
kv('requirements', mani.requirements);
kv('required webui', mani.required_open_webui_version);
kv('funding url', mani.funding_url);
kv('date', mani.date);
manifestWrap.appendChild(manifestTitle);
manifestWrap.appendChild(manifestList);
// advanced details (disclosure)
const adv = document.createElement('details');
adv.className = 'tc-disclosure';
adv.setAttribute('data-testid', 'tool-card-advanced');
const advSum = document.createElement('summary');
advSum.className = 'tc-disclosure-summary';
advSum.textContent = 'advanced details';
adv.appendChild(advSum);
const advBody = document.createElement('div');
advBody.className = 'tc-disclosure-body';
// details table
const table = document.createElement('table');
table.className = 'tc-table';
const tbody = document.createElement('tbody');
const rows = [
['tool id', tool?.id],
['user id', tool?.user_id],
['user role', tool?.user?.role],
['email', tool?.user?.email],
['access control', safeText(Object.keys(tool?.access_control ?? {}).length ? JSON.stringify(tool.access_control) : '—')],
['updated at', formatEpoch(tool?.updated_at)],
['created at', formatEpoch(tool?.created_at)]
];
rows.forEach(([k, v]) => {
if (v === undefined || v === null) return;
const tr = document.createElement('tr');
const th = document.createElement('th'); th.textContent = k;
const td = document.createElement('td'); td.textContent = String(v);
tr.appendChild(th); tr.appendChild(td); tbody.appendChild(tr);
});
table.appendChild(tbody);
// raw json (nested disclosure)
const rawWrap = document.createElement('details');
rawWrap.className = 'tc-disclosure tc-disclosure--nested';
const rawSum = document.createElement('summary');
rawSum.className = 'tc-disclosure-summary';
rawSum.textContent = 'raw tool json';
const pre = document.createElement('pre');
pre.className = 'tc-pre';
pre.textContent = prettyJson(tool);
rawWrap.appendChild(rawSum);
rawWrap.appendChild(pre);
// assemble
advBody.appendChild(table);
advBody.appendChild(rawWrap);
adv.appendChild(advBody);
body.appendChild(desc);
body.appendChild(badges);
body.appendChild(manifestWrap);
body.appendChild(adv);
dialog.appendChild(header);
dialog.appendChild(body);
overlay.appendChild(dialog);
mount.appendChild(overlay);
// focus management per apg recommendations: trap focus inside the dialog, close on esc
const opener = options.trigger instanceof HTMLElement ? options.trigger : /** @type {HTMLElement|null} */(document.activeElement);
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
const findFocusable = () => /** @type {HTMLElement[]} */(Array.from(dialog.querySelectorAll(focusableSelectors))),
focusFirst = () => {
const items = findFocusable();
const target = items[0] || closeBtn;
target.focus();
};
const onKeydown = (e) => {
if (e.key === 'Tab') {
const items = findFocusable();
if (items.length === 0) { e.preventDefault(); closeBtn.focus(); return; }
const first = items[0],
last = items[items.length - 1],
active = /** @type {HTMLElement} */(document.activeElement);
if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
else if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); }
}
if (e.key === 'Escape') { e.preventDefault(); close(); }
};
const onOverlayClick = (e) => { if (e.target === overlay) close(); };
overlay.addEventListener('click', onOverlayClick);
document.addEventListener('keydown', onKeydown);
closeBtn.addEventListener('click', () => close());
// prevent background scroll while open
const prevOverflow = document.documentElement.style.overflow;
document.documentElement.style.overflow = 'hidden';
// open
setTimeout(focusFirst, 0);
function close() {
overlay.removeEventListener('click', onOverlayClick);
document.removeEventListener('keydown', onKeydown);
document.documentElement.style.overflow = prevOverflow;
overlay.remove();
if (opener && typeof opener.focus === 'function') opener.focus();
}
return { close, root: overlay };
}
// helpers
function renderToolList(tools) {
const container = document.querySelector('#tools-select'),
hiddenInput = document.querySelector('#tools-select-input');
if (!container) return console.warn('failed to find tools selection container');
container.innerHTML = '';
container.setAttribute('data-has-tools', String(Boolean(tools?.length)));
const initialSelectionRaw = hiddenInput?.value ? hiddenInput.value.split(',') : [],
initialSelection = new Set(initialSelectionRaw.map(v => v.trim()).filter(Boolean));
if (!Array.isArray(tools) || tools.length === 0) {
const empty = document.createElement('p');
empty.className = 'tool-pill-empty muted';
empty.textContent = 'no tools available';
container.appendChild(empty);
if (hiddenInput) {
hiddenInput.value = '';
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
}
return;
}
const selection = new Set(initialSelection),
validIds = new Set();
const syncHidden = () => {
if (!hiddenInput) return;
const ordered = Array.from(selection).filter(id => validIds.has(id));
hiddenInput.value = ordered.join(',');
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
};
const toggleSelection = (pill, toggleBtn, id) => {
const isSelected = selection.has(id);
if (isSelected) selection.delete(id);
else selection.add(id);
const nowSelected = selection.has(id);
pill.classList.toggle('tool-pill--selected', nowSelected);
pill.setAttribute('aria-selected', String(nowSelected));
toggleBtn.setAttribute('aria-pressed', String(nowSelected));
syncHidden();
};
tools.forEach(tool => {
const id = String(tool?.id ?? '');
if (!id) return;
validIds.add(id);
const label = makeToolLabel(tool, tools);
const pill = document.createElement('div');
pill.className = 'tool-pill';
pill.dataset.toolId = id;
pill.setAttribute('role', 'option');
pill.setAttribute('tabindex', '-1');
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = 'tool-pill-toggle';
toggleBtn.textContent = label;
toggleBtn.setAttribute('aria-pressed', String(selection.has(id)));
toggleBtn.addEventListener('click', () => toggleSelection(pill, toggleBtn, id));
const infoBtn = document.createElement('button');
infoBtn.type = 'button';
infoBtn.className = 'tool-pill-info';
infoBtn.setAttribute('aria-label', `Show info for ${label}`);
infoBtn.innerHTML = '<span aria-hidden="true">i</span>';
infoBtn.addEventListener('click', (event) => {
event.stopPropagation();
generateToolCard(tool, { trigger: infoBtn });
});
const isSelected = selection.has(id);
pill.classList.toggle('tool-pill--selected', isSelected);
pill.setAttribute('aria-selected', String(isSelected));
pill.appendChild(toggleBtn);
pill.appendChild(infoBtn);
container.appendChild(pill);
});
// drop any selections for tools that no longer exist
Array.from(selection).forEach(id => {
if (!validIds.has(id)) selection.delete(id);
});
syncHidden();
}
const makeToolLabel = (t, tools) => {
const base = t?.name ?? t?.id ?? 'tool';
const duplicates = tools.filter(x => (x?.name ?? x?.id) === (t?.name ?? t?.id));
if (duplicates.length > 1) {
const hint = t?.meta?.manifest?.version ?? t?.user?.name ?? t?.id?.slice?.(0, 8);
return `${base}${hint}`;
}
return base;
}
-305
View File
@@ -1,305 +0,0 @@
import http from 'http'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import Docker from 'dockerode'
import cron from 'node-cron'
const LABEL_USER_KEY = 'openwebui.user-id',
ANNO_DISPLAY_NAME = 'openwebui/display-name'
const __filename = fileURLToPath(import.meta.url),
__dirname = path.dirname(__filename),
// folders/files
DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'),
SCHEDULES_FILE = path.join(DATA_DIR, 'schedules.json'),
TEMPLATES_FILE = process.env.TEMPLATES_FILE || path.join(__dirname, 'templates.json'),
// defaults
DEFAULT_TZ = 'America/New_York',
PORT = Number(process.env.PORT) || 12253
// connect to docker (via local socket by default)
const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' })
// in-memory schedule registry
const tasks = new Map() // key -> { task, def }
const readBodyJson = (req) => new Promise((resolve, reject) => {
let d = ''; req.on('data', c => d += c)
req.on('end', () => { try { resolve(JSON.parse(d || '{}')) } catch (e) { reject(e) } })
req.on('error', reject)
}),
ensureDir = (p) => { try { fs.mkdirSync(p, { recursive: true }) } catch { } },
readJsonFile = (p, fallback = null) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return fallback } },
writeJsonFile = (p, obj) => fs.writeFileSync(p, JSON.stringify(obj, null, 2))
// build cron string from an iso timestamp in a timezone (same as your original)
const cronFromISO = (iso, tz = DEFAULT_TZ) => {
const dt = new Date(iso),
parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz, year: 'numeric', month: 'numeric', day: 'numeric',
hour: 'numeric', minute: '2-digit', hour12: false
}).formatToParts(dt).reduce((a, p) => (a[p.type] = p.value, a), {})
const m = Number(parts.month), d = Number(parts.day), h = Number(parts.hour), min = Number(parts.minute)
return `${min} ${h} ${d} ${m} *`
}
// derive a docker-safe, user-scoped name and preserve a human display name
const scopedName = (name, userId) => {
const base = String(name).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40),
suffix = String(userId).toLowerCase().replace(/[^a-z0-9]+/g, '').slice(0, 8) || 'anon'
return `${base}--u-${suffix}`
}
// ensure we have a user id header
const requireUserId = (req) => {
const userId = String(req.headers['x-user-id'] || '').trim()
if (!userId) throw Object.assign(new Error('missing x-user-id header'), { status: 401 })
return userId
}
// load templates (maps "name" -> { image, command?, args?, env? })
const loadTemplates = () => readJsonFile(TEMPLATES_FILE, { items: [] }).items || []
const findTemplate = (name) => loadTemplates().find(t => t.name === name)
// create/start a container for a template (run once)
async function runContainer({ displayName, template, parameters = {}, userId, entrypoint }) {
if (!template?.name) throw Object.assign(new Error('missing template.name'), { status: 400 })
const t = findTemplate(template.name)
if (!t) throw Object.assign(new Error(`unknown template: ${template.name}`), { status: 404 })
// env: pass user + params as env vars (simple & portable)
const env = [
`USER_ID=${userId}`,
`DISPLAY_NAME=${displayName}`,
...Object.entries(parameters).map(([k, v]) => `PARAM_${String(k).toUpperCase()}=${String(v)}`)
]
// image pull if absent then create+start (mirrors docker run flow)
// ref: docker engine api sequence: create -> pull if 404 -> create -> start
// https://docs.docker.com/reference/api/engine/version/v1.24/ (section 4.1)
try {
await docker.getImage(t.image).inspect()
} catch {
await new Promise((resolve, reject) => {
docker.pull(t.image, (err, stream) => {
if (err) return reject(err)
docker.modem.followProgress(stream, (err2) => err2 ? reject(err2) : resolve())
})
})
}
const nameActual = `${scopedName(displayName || t.name, userId)}-${Math.random().toString(36).slice(2, 8)}`
// build container create options
const createOpts = {
Image: t.image,
// optional explicit entrypoint/cmd wiring
Entrypoint: entrypoint ? [entrypoint] : (t.entrypoint ? [].concat(t.entrypoint) : undefined),
Cmd: t.command ? [].concat(t.command, t.args || []) : (t.args ? [].concat(t.args) : undefined),
Env: env,
Labels: {
[LABEL_USER_KEY]: userId,
[ANNO_DISPLAY_NAME]: displayName || t.name
},
HostConfig: {
AutoRemove: true
},
name: nameActual
}
const container = await docker.createContainer(createOpts) // create
await container.start() // start
return { id: container.id, name: nameActual }
}
// persistence of schedules (for restart durability)
ensureDir(DATA_DIR)
const persist = () => {
const defs = [...tasks.values()].map(v => v.def)
writeJsonFile(SCHEDULES_FILE, { items: defs })
}
const restore = () => {
const saved = readJsonFile(SCHEDULES_FILE, { items: [] }).items || []
for (const def of saved) scheduleOrReplace(def)
}
// schedule management (create/update)
function scheduleOrReplace(def) {
// stop existing
const key = def.name
if (tasks.has(key)) { try { tasks.get(key).task.stop() } catch { } tasks.delete(key) }
// forbid overlapping runs per schedule (like argo's Forbid)
let running = false
const task = cron.schedule(def.schedule, async () => {
if (running) return
running = true
try {
await runContainer({
displayName: def.displayName,
template: def.template,
parameters: def.parameters,
userId: def.userId,
entrypoint: def.entrypoint
})
// one-shot: stop after first success
if (def.oneShot) {
try { task.stop() } catch { }
tasks.delete(key)
persist()
}
} catch (e) {
// you could log or collect errors here
} finally {
running = false
}
}, { timezone: def.timezone || DEFAULT_TZ })
tasks.set(key, { task, def })
task.start()
persist()
}
// convert input to schedule def
const toScheduleDef = ({ name, when, tz = DEFAULT_TZ, oneShot = false, template = { name: '' }, parameters = {}, entrypoint, userId }) => {
const schedule = when?.cron ?? cronFromISO(when?.iso, tz)
return {
name: scopedName(name, userId),
displayName: name,
userId,
timezone: tz,
schedule,
oneShot: Boolean(oneShot),
template,
parameters,
entrypoint
}
}
const publicDir = path.join(__dirname, 'public');
function reqToURL(req) {
let pathname = '/';
try {
pathname = new URL(req.url, `http://${req.headers.host || 'localhost'}`).pathname;
} catch {
pathname = req.url || '/';
}
return pathname;
}
// http server
const server = http.createServer(async (req, res) => {
try {
// very light cors
const origin = req.headers.origin || '*'
res.setHeader('access-control-allow-origin', origin)
res.setHeader('vary', 'origin')
res.setHeader('access-control-allow-headers', 'content-type, x-user-id')
res.setHeader('access-control-allow-methods', 'GET, POST, DELETE, OPTIONS')
if (req.method === 'OPTIONS') return res.writeHead(204).end()
const pathname = reqToURL(req),
allowed = ['/', '/index.html', '/script.js', '/style.css'];
//#region GET requests
// list schedules for the calling user
if (req.method === 'GET' && pathname === '/api/schedules') {
const userId = requireUserId(req),
items = [...tasks.values()]
.map(v => v.def)
.filter(d => d.userId === userId)
.map(d => ({
name: d.name,
displayName: d.displayName,
userId: d.userId,
timezone: d.timezone,
schedules: [d.schedule],
oneShot: d.oneShot,
templateRef: d.template,
entrypoint: d.entrypoint
}));
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items }))
}
// list "workflow templates" => just expose templates.json names
if (req.method === 'GET' && pathname === '/api/workflowtemplates') {
const items = loadTemplates().map(t => ({ name: t.name }))
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items }))
}
if (req.method === 'GET' && allowed.includes(pathname)) {
try {
const fileName = pathname === '/' ? 'index.html' : pathname.slice(1),
filePath = path.join(publicDir, fileName),
ext = path.extname(fileName).toLowerCase(),
type = ext === '.js' ? 'application/javascript' : ext === '.css' ? 'text/css' : 'text/html; charset=utf-8',
content = fs.readFileSync(filePath, 'utf8')
return res.writeHead(200, { 'content-type': type }).end(content);
} catch {
return res.writeHead(404).end('ui not found');
}
}
// DO NOT PUT ANY GET REQUESTS BELOW THIS LINE, THEY WILL FAIL
//#endregion
// create/update a user-scoped schedule
if (req.method === 'POST' && pathname === '/schedules') {
const userId = requireUserId(req),
input = await readBodyJson(req),
def = toScheduleDef({ ...input, userId })
scheduleOrReplace(def)
return res.writeHead(201, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
}
// run a job now for the calling user (no schedule)
if (req.method === 'POST' && pathname === '/run-now') {
const userId = requireUserId(req),
input = await readBodyJson(req)
await runContainer({
displayName: input.name,
template: input.template,
parameters: input.parameters || {},
userId,
entrypoint: input.entrypoint
})
return res.writeHead(201, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
}
// delete a schedule owned by the calling user
if (req.method === 'DELETE' && pathname.startsWith('/schedules/')) {
const userId = requireUserId(req),
nameParam = decodeURIComponent(pathname.split('/').pop()),
// stored names are already scoped; accept either raw or scoped
key = tasks.has(nameParam) ? nameParam : scopedName(nameParam, userId),
existing = tasks.get(key)?.def
if (!existing) return res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: 'not found' }))
if (existing.userId !== userId) return res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: 'forbidden: schedule not owned by this user' }))
try { tasks.get(key).task.stop() } catch { }
tasks.delete(key)
persist()
return res.writeHead(204).end()
}
res.writeHead(404).end('not found')
} catch (e) {
const code = Number(e.status) || 500
res.writeHead(code, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: e.message || String(e) }))
}
})
// boot
restore()
server.listen(PORT, "0.0.0.0", () => console.log(`schedules api listening on ${server.address().address}:${PORT}`));
+636
View File
@@ -0,0 +1,636 @@
import http, { IncomingMessage } from 'http'
import fs from 'fs'
import path from 'path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'url'
// import Docker from 'dockerode'
import cron from 'node-cron'
import loginUser, { getUser } from './helpers/resolve-user'
import { authCall, callNewChat, ollamaInp, schedInp } from './helpers/ollamaCalls'
const DEFAULT_TZ = "America/New_York"
const openApiSpec = {
openapi: '3.0.1',
info: {
title: 'Scheduler API',
description: 'API for managing scheduled prompts. Cron expressions run in America/New_York (EST).',
version: '1.0.0'
},
servers: [
{
url: '/',
description: 'Current server'
}
],
paths: {
'/api/schedules': {
get: {
summary: 'List schedules',
security: [{ BearerAuth: [] }],
responses: {
200: {
description: 'Successful response',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ok: { type: 'boolean', example: true },
items: {
type: 'array',
items: { $ref: '#/components/schemas/Schedule' }
}
}
}
}
}
},
401: { $ref: '#/components/responses/Unauthorized' }
}
},
post: {
summary: 'Create or replace a schedule',
security: [{ BearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/ScheduleInput' }
}
}
},
responses: {
201: {
description: 'Schedule created or updated',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ok: { type: 'boolean', example: true }
}
}
}
}
},
400: { $ref: '#/components/responses/BadRequest' },
401: { $ref: '#/components/responses/Unauthorized' }
}
}
},
'/api/schedules/{name}': {
delete: {
summary: 'Delete a schedule',
security: [{ BearerAuth: [] }],
parameters: [
{
name: 'name',
in: 'path',
description: 'Schedule name (raw or scoped)',
required: true,
schema: { type: 'string' }
}
],
responses: {
204: { description: 'Schedule deleted' },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' }
}
}
}
},
components: {
securitySchemes: {
BearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT'
}
},
schemas: {
Schedule: {
type: 'object',
properties: {
name: { type: 'string', description: 'Scoped identifier used internally' },
displayName: { type: 'string' },
userId: { type: 'string' },
schedules: {
type: 'array',
items: { type: 'string', description: 'Cron expression' }
},
startAt: { type: 'string', format: 'date-time', nullable: true },
oneShot: { type: 'boolean' },
templateRef: { $ref: '#/components/schemas/TemplateRef' },
prompt: { type: 'string' },
model: { type: 'string' },
tools: { type: 'array', items: { type: 'string' } },
features: {
type: 'object',
additionalProperties: { type: 'boolean' }
},
files: {
type: 'array',
items: {
type: 'object',
properties: {
fname: { type: 'string' },
fkey: { type: 'string' }
}
}
}
},
required: ['name', 'userId', 'schedules', 'oneShot']
},
ScheduleInput: {
type: 'object',
properties: {
name: { type: 'string' },
when: { $ref: '#/components/schemas/ScheduleWhen' },
oneShot: { type: 'boolean', default: false },
template: { $ref: '#/components/schemas/TemplateInput' },
parameters: {
type: 'object',
additionalProperties: true
},
files: {
type: 'array',
items: {
type: 'object',
properties: {
fname: { type: 'string' },
fkey: {
type: 'string',
description: 'Optional previously stored file reference'
},
content: {
type: 'string',
description: 'Base64 encoded file contents'
}
},
required: ['fname']
}
},
prompt: { type: 'string' },
model: { type: 'string' },
tools: { type: 'array', items: { type: 'string' } },
features: {
type: 'object',
additionalProperties: { type: 'boolean' }
}
},
required: ['name', 'when', 'template', 'parameters', 'prompt', 'model', 'tools']
},
ScheduleWhen: {
type: 'object',
properties: {
cron: {
type: 'string',
description: '5-field cron expression evaluated in America/New_York'
},
start: {
type: 'string',
format: 'date-time',
description: 'Optional start gate; required when oneShot is true'
}
},
required: []
},
TemplateInput: {
type: 'object',
properties: {
name: { type: 'string' },
clusterScope: { type: 'boolean', default: false }
},
required: ['name']
},
TemplateRef: {
type: 'object',
properties: {
name: { type: 'string' },
clusterScope: { type: 'boolean' }
},
required: ['name']
}
},
responses: {
Unauthorized: {
description: 'Missing or invalid credentials'
},
BadRequest: {
description: 'Invalid request payload',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
ok: { type: 'boolean', example: false },
error: { type: 'string' }
}
}
}
}
},
Forbidden: {
description: 'Schedule exists but belongs to another user'
},
NotFound: {
description: 'Schedule not found'
}
}
}
} as const;
const __filename = fileURLToPath(import.meta.url),
__dirname = path.dirname(__filename),
// folders/files
DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'),
SCHEDULES_FILE = path.join(DATA_DIR, 'schedules.json'),
TEMPLATES_FILE = process.env.TEMPLATES_FILE || path.join(__dirname, 'templates.json'),
FILES_DIR_PREFERRED = process.env.FILES_DIR || '/app/data/files',
FILES_DIR_FALLBACK = path.join(DATA_DIR, 'files');
let FILES_DIR = FILES_DIR_PREFERRED;
// defaults
PORT = Number(process.env.PORT) || 12253
// connect to docker (via local socket by default)
// const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' })
// in-memory schedule registry
const tasks = new Map() // key -> { task, def }
export const readBodyJson = (req: http.IncomingMessage): Promise<any> => new Promise((resolve, reject) => {
let d = ''; req.on('data', c => d += c)
req.on('end', () => { try { resolve(JSON.parse(d || '{}')) } catch (e) { reject(e) } })
req.on('error', reject)
});
const ensureDir = (p: string) => { try { fs.mkdirSync(p, { recursive: true }) } catch { } },
readJsonFile = (p: string, fallback: any = null) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return fallback } },
writeJsonFile = (p: string, obj: object) => fs.writeFileSync(p, JSON.stringify(obj, null, 2))
// build cron string from an iso timestamp (treated as America/New_York)
const cronFromISO = (iso?: string) => {
if (!iso) return;
const dt = new Date(iso);
if (Number.isNaN(dt.getTime())) return;
const parts: { year: number, month: number, day: number, hour: number, minute: number } = new Intl.DateTimeFormat('en-US', {
timeZone: DEFAULT_TZ, year: 'numeric', month: 'numeric', day: 'numeric',
hour: 'numeric', minute: '2-digit', hour12: false
}).formatToParts(dt).reduce((a, p) => (a[p.type] = Number(p.value), a), {} as any);
return `${parts.minute} ${parts.hour} ${parts.day} ${parts.month} *`;
}
const normalizeFeatures = (value: unknown): Record<string, boolean> => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, Boolean(v)]));
};
type IncomingFile = { fname?: string, fkey?: string, content?: string };
const sanitizeBase64 = (raw?: string) => typeof raw === 'string' ? raw.replace(/^data:[^;]+;base64,/, '') : '';
const prepareIncomingFiles = (value: unknown): { fname: string, fkey: string }[] | string => {
if (!value) return [];
if (!Array.isArray(value)) return 'files must be an array';
const saved: { fname: string, fkey: string }[] = [];
for (const entry of value as IncomingFile[]) {
if (!entry || typeof entry !== 'object') return 'invalid file entry';
const fname = typeof entry.fname === 'string' ? entry.fname.trim() : '';
if (!fname) return 'file entries require fname';
if (entry.content && typeof entry.content === 'string') {
const b64 = sanitizeBase64(entry.content);
if (!b64) return `file ${fname} is missing content`;
const fkey = crypto.randomUUID();
const target = path.join(FILES_DIR, fkey);
try {
fs.writeFileSync(target, Buffer.from(b64, 'base64'));
} catch (err) {
console.error('failed to store file', fname, err);
return `failed to store file ${fname}`;
}
saved.push({ fname, fkey });
continue;
}
if (entry.fkey && typeof entry.fkey === 'string' && entry.fkey.trim()) {
saved.push({ fname, fkey: entry.fkey.trim() });
continue;
}
return `file ${fname} is missing content or fkey`;
}
return saved;
};
// derive a docker-safe, user-scoped name and preserve a human display name
const scopedName = (name: string, userId: string) => {
const base = String(name).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40),
suffix = String(userId).toLowerCase().replace(/[^a-z0-9]+/g, '').slice(0, 8) || 'anon';
return `${base}--u-${suffix}`;
}
const fetchUserID = async (req: IncomingMessage) => {
const authheader = String(req.headers['authorization'] || '').trim();
if (!authheader) throw Object.assign(new Error('Missing authorization header'), { status: 401 });
if (!authheader.startsWith('Bearer')) throw Object.assign(new Error('Invalid token'), { status: 401 });
try {
const token = authheader.split(' ')[1],
user = await authCall(token);
return user.id;
}
catch {
throw Object.assign(new Error('Invalid token'), { status: 401 });
}
}
// load templates (maps "name" -> { image, command?, args?, env? })
const loadTemplates = () => readJsonFile(TEMPLATES_FILE, { items: [] }).items || [];
// persistence of schedules (for restart durability)
ensureDir(DATA_DIR)
ensureDir(FILES_DIR)
if (!fs.existsSync(FILES_DIR)) {
FILES_DIR = FILES_DIR_FALLBACK;
ensureDir(FILES_DIR);
}
const persist = () => {
const defs = [...tasks.values()].map(v => v.def)
writeJsonFile(SCHEDULES_FILE, { items: defs })
};
const restore = () => {
const saved = readJsonFile(SCHEDULES_FILE, { items: [] }).items || [];
for (const def of saved) scheduleOrReplace(def);
};
// schedule management (create/update)
function scheduleOrReplace(defInput: ollamaInp) {
const def: ollamaInp = { ...defInput, features: normalizeFeatures(defInput?.features) };
// stop existing
const key = def.name
if (tasks.has(key)) { try { tasks.get(key).task.stop() } catch { } tasks.delete(key) }
const startTs = def.startAt ? new Date(def.startAt).getTime() : null;
// forbid overlapping runs per schedule (like argo's Forbid)
let running = false
const task = cron.schedule(def.schedule, async () => {
if (running) return;
if (startTs && Date.now() < startTs) return;
running = true
try {
await callNewChat(def);
// one-shot: stop after first success
if (def.oneShot) {
try { task.stop() } catch { }
tasks.delete(key)
persist()
}
} catch (err) {
// TODO: collect errors here
console.error(err);
// this failed, stop the task now
try { task.stop() } catch { }
try { tasks.delete(key) } catch { }
persist()
} finally {
running = false
}
}, { timezone: DEFAULT_TZ })
tasks.set(key, { task, def })
task.start()
persist()
}
// also does validation
const toScheduleDef = ({ name, when, oneShot = false, template = { name: '' }, parameters = {}, prompt, userId, model, tools, features = {}, files = [] }: schedInp): ollamaInp | string => {
const normalizedFeatures = normalizeFeatures(features);
const startAt = when.start?.trim() || '';
const hasStartAt = Boolean(startAt);
const processedFiles = prepareIncomingFiles(files);
if (typeof processedFiles === 'string') return processedFiles;
if (oneShot) {
if (!hasStartAt) return "One-shot schedules require a start date";
const schedule = cronFromISO(startAt);
if (!schedule) return "Invalid start date";
const startDate = new Date(startAt);
if (startDate.getTime() < Date.now()) return "Date can not be in the past";
return {
name: scopedName(name, userId),
displayName: name,
userId,
schedule,
startAt,
oneShot: true,
template,
parameters,
prompt,
model,
tools,
features: normalizedFeatures,
cookie: '',
files: processedFiles
};
}
const schedule = when.cron?.trim();
if (!schedule) return "Cron expression is required";
if (hasStartAt) {
const startDate = new Date(startAt);
if (Number.isNaN(startDate.getTime())) return "Invalid start date";
if (startDate.getTime() < Date.now()) return "Start date can not be in the past";
}
return {
name: scopedName(name, userId),
displayName: name,
userId,
schedule,
startAt: hasStartAt ? startAt : undefined,
oneShot: false,
template,
parameters,
prompt,
model,
tools,
features: normalizedFeatures,
cookie: '',
files: processedFiles
};
}
const publicDir = path.join(__dirname, 'public');
function reqToURL(req: IncomingMessage) {
let pathname = '/';
try {
pathname = new URL(req.url!, `http://${req.headers.host || 'localhost'}`).pathname;
} catch {
pathname = req.url || '/';
}
return pathname;
}
// http server
const server = http.createServer(async (req, res) => {
try {
// very light cors
const origin = req.headers.origin || '*'
res.setHeader('access-control-allow-origin', origin)
res.setHeader('vary', 'origin')
res.setHeader('access-control-allow-headers', 'content-type, x-user-id')
res.setHeader('access-control-allow-methods', 'GET, POST, DELETE, OPTIONS')
if (req.method === 'OPTIONS') return res.writeHead(204).end()
const pathname = reqToURL(req),
allowed = ['/', ...fs.readdirSync(publicDir).map(f => `/${f}`)];
if (req.method === 'GET' && pathname === '/openapi.json') {
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(openApiSpec, null, 2));
}
// list schedules for the calling user
if (req.method === 'GET' && pathname === '/api/schedules') {
const userId = await fetchUserID(req),
items = [...tasks.values()]
.map(v => v.def)
.filter(d => d.userId === userId)
.map(d => ({
name: d.name,
displayName: d.displayName,
userId: d.userId,
schedules: [d.schedule],
startAt: d.startAt,
oneShot: d.oneShot,
templateRef: d.template,
prompt: d.prompt,
model: d.model,
tools: d.tools,
features: d.features,
files: d.files
}));
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items }))
}
// list "workflow templates" => just expose templates.json names
if (req.method === 'GET' && pathname === '/api/workflowtemplates') {
const items = loadTemplates().map((t: { name: string }) => ({ name: t.name }))
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items }))
}
if (req.method === 'GET' && allowed.includes(pathname)) {
try {
const fileName = pathname === '/' ? 'index.html' : pathname.slice(1),
filePath = path.join(publicDir, fileName),
ext = path.extname(fileName).toLowerCase(),
type = ext === '.js' ? 'application/javascript' : ext === '.css' ? 'text/css' : 'text/html; charset=utf-8',
content = fs.readFileSync(filePath, 'utf8')
return res.writeHead(200, { 'content-type': type }).end(content);
} catch {
return res.writeHead(404).end('ui not found');
}
}
// create/update a user-scoped schedule
if (req.method === 'POST' && pathname === '/api/schedules') {
const userId = await fetchUserID(req),
input = await readBodyJson(req),
def = toScheduleDef({ ...input, userId });
if (typeof def === 'string') {
return res.writeHead(400, { 'content-type': 'application/json' })
.end(JSON.stringify({ error: def, ok: false }));
}
def.cookie = req.headers['authorization'].split(' ')[1];
scheduleOrReplace(def);
return res.writeHead(201, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }));
}
if (req.method === 'POST' && pathname.startsWith('/login')) {
return await loginUser(req, res).catch((err) => {
console.error(err);
res.writeHead(500).end();
});
}
if (req.method === 'GET' && pathname.startsWith('/api/me')) {
return await getUser(req, res).catch((err) => {
console.error(err);
res.writeHead(500).end();
});
}
// delete a schedule owned by the calling user
if (req.method === 'DELETE' && pathname.startsWith('/api/schedules/')) {
const paramRaw = pathname.split('/').pop();
if (!paramRaw) {
return res.writeHead(400).end({ error: "missing schedule ID" });
}
const userId = await fetchUserID(req),
nameParam = decodeURIComponent(paramRaw),
// stored names are already scoped; accept either raw or scoped
key = tasks.has(nameParam) ? nameParam : scopedName(nameParam, userId),
existing = tasks.get(key)?.def;
if (!existing) {
return res.writeHead(404, {
'content-type': 'application/json'
}).end(JSON.stringify({
ok: false,
error: 'not found'
}));
}
if (existing.userId !== userId) {
return res.writeHead(403, {
'content-type': 'application/json'
}).end(JSON.stringify({
ok: false,
error: 'forbidden: schedule not owned by this user'
}));
}
try { tasks.get(key).task.stop(); } catch { }
tasks.delete(key);
persist();
return res.writeHead(204).end();
}
res.writeHead(404).end('not found');
} catch (e: any) {
console.error(e);
const code = Number(e.status) || 500
res.writeHead(code, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: e.message || String(e) }))
}
})
// boot
restore();
server.listen(PORT, "0.0.0.0", () => {
const addr = server!.address();
console.log(`schedules api listening on ${typeof addr === 'string' ? addr : addr?.address}:${PORT}`)
});
-22
View File
@@ -1,22 +0,0 @@
NAMESPACE NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
argocd argocd-application-controller-0 1/1 Running 0 52s 10.244.0.142 minikube <none> <none>
argocd argocd-applicationset-controller-54f96997f8-g2gzf 1/1 Running 0 52s 10.244.0.170 minikube <none> <none>
argocd argocd-dex-server-798cbff4c7-n4crz 1/1 Running 0 52s 10.244.0.42 minikube <none> <none>
argocd argocd-notifications-controller-644f66f7df-xcxxl 1/1 Running 0 52s 10.244.0.86 minikube <none> <none>
argocd argocd-redis-6684c6947f-9c4ps 1/1 Running 0 52s 10.244.0.190 minikube <none> <none>
argocd argocd-repo-server-6fccc5759b-gp4js 1/1 Running 0 52s 10.244.0.169 minikube <none> <none>
argocd argocd-server-64d5fcbd58-p99wl 1/1 Running 0 52s 10.244.0.75 minikube <none> <none>
ingress-nginx ingress-nginx-admission-create-xwcms 0/1 Completed 2 2m22s 10.244.0.125 minikube <none> <none>
ingress-nginx ingress-nginx-admission-patch-v46s6 0/1 Completed 2 2m22s 10.244.0.133 minikube <none> <none>
ingress-nginx ingress-nginx-controller-67c5cb88f-9zlz7 1/1 Running 0 2m22s 10.244.0.201 minikube <none> <none>
kube-system cilium-6g8th 1/1 Running 0 2m23s 192.168.49.2 minikube <none> <none>
kube-system cilium-envoy-vz775 1/1 Running 0 2m23s 192.168.49.2 minikube <none> <none>
kube-system cilium-operator-5876b8b787-7225g 1/1 Running 0 2m22s 192.168.49.2 minikube <none> <none>
kube-system coredns-674b8bbfcf-s74qz 1/1 Running 0 101s 10.244.0.113 minikube <none> <none>
kube-system etcd-minikube 1/1 Running 0 2m27s 192.168.49.2 minikube <none> <none>
kube-system kube-apiserver-minikube 1/1 Running 0 2m27s 192.168.49.2 minikube <none> <none>
kube-system kube-controller-manager-minikube 1/1 Running 0 2m27s 192.168.49.2 minikube <none> <none>
kube-system kube-ingress-dns-minikube 1/1 Running 0 68s 192.168.49.2 minikube <none> <none>
kube-system kube-proxy-pmxbc 1/1 Running 0 2m23s 192.168.49.2 minikube <none> <none>
kube-system kube-scheduler-minikube 1/1 Running 0 2m28s 192.168.49.2 minikube <none> <none>
kube-system storage-provisioner 1/1 Running 1 (113s ago) 2m25s 192.168.49.2 minikube <none> <none>