7 Commits

Author SHA1 Message Date
ION606 2659c51b02 fixed some more bugs 2025-10-11 11:29:11 -04:00
ION606 b915ae88a4 fixed some bugs 2025-10-03 15:43:32 -04:00
ION606 665fcbe191 updated README 2025-10-03 09:38:46 -04:00
ION606 e954bf82fb added nextcloud 2025-10-03 08:41:37 -04:00
ION606 133ef3f48b added scheduler 2025-09-26 14:28:04 -04:00
ION606 5f535a61c1 fixed dark mode 2025-09-15 10:54:35 -04:00
ION606 a7f6c9edb5 modifying scheduler 2025-09-15 10:45:38 -04:00
34 changed files with 6389 additions and 686 deletions
+4
View File
@@ -132,3 +132,7 @@ dist
__pycache__/
.venv/
bun.lock
tmp/
temp.*
+194 -159
View File
@@ -1,185 +1,220 @@
# ML Repo — Architecture and External RAG Server Design (for Ollama/Open WebUI)
# ML Stack — Local AI Orchestration Toolkit
My openWebUI/searxng configs, plugins, RAG server, as well as a custom program that runs the AI's code in isolated Docker containers
This repository packages a complete self-hosted assistant stack around Open WebUI plus several companion services: a scheduler that can trigger chats and workflows, a docker-backed code runner, a Roku remote tool server, Nextcloud file access, SearxNG metasearch, and a headless browser UI for deep-research sessions. Everything is wired together through `docker-compose.yml` so the stack can be brought up on a single host.
*Last updated: 2025-09-10*
_Last updated: 2025-10-03_
---
## Summary :3
## A (Few) Notes
This repository wires together a local AI stack built around **Open WebUI**, **Ollama**, **SearxNG**, and two custom utilities: a **code runner** (executes model-generated code inside sandboxed containers) and a **headless research browser UI**. The current compose setup already gives you working RAG (retrieval-augmented generation) **inside Open WebUI** without needing a separate RAG service.
1. ports are currently exposed on most services for development purposes (e.g. 12253 for the scheduler), remove these in production or consider adding a proxy
2. **ALL DATA IS STORED IN VOLUMES!!!** This means if you do `docker compose down -v` your data **WILL** dissapear. Consider mounting a persistant directory to avoid this
3. Before starting the cluster, check if you need the different components (e.g. Nextcloud Tool Server). They are set to restart on failiure and will throw if missing env vars/credentials, which will loop endlessly
4. If you do not use cloudflared for tunneling, please adjust the CORS policies accordingly, and consider adding a reverse proxy to either your local machine or the compose
5. The code runner and scheduler both mount the host Docker socket. Ensure the host user/group IDs match the compose configuration (`DOCKER_GID` build arg defaults to 977) so containers can operate without root. This will be replaced when I enentually migrate this to a kubernetes cluster
6. When adjusting `NEXTCLOUD_ACCESS_DIRS`, remember to restart `ollama-nextcloud` so the regex list is reloaded
---
## Repo map and how each piece fits
## Stack At A Glance
| Compose service | Directory / build context | External ports | Primary role |
|-----------------|---------------------------|----------------|--------------|
| `open-webui` | (image: `ghcr.io/open-webui/open-webui:main`) | `4000 -> 8080` | Chat UI, agent orchestration, embedded knowledge base & RAG powered by Postgres |
| `postgres` | | | Persistence for Open WebUI (users, KB, events) |
| `searxng` | `searxng.yml` | `4001 -> 8080` (debug only) | Private SearxNG instance used for live web search tools |
| `coderunner` | `coderunner/` | (internal `8787`) | Bun service that executes pure source code inside sandboxed Docker containers |
| `openwebui_tools` | `tools/` | (internal `1331`) | Python Roku remote API exposed as an OpenAPI tool server |
| `browser` | `browser/` | `7788 -> 7788` | Playwright Chromium UI for autonomous browsing / research |
| `schedules-api` | `scheduler/` | `12253 -> 12253` | Cron-style job scheduler that can open chats, call templates, and upload files |
| `ollama-nextcloud` | `nextcloud/` | `13284 -> 1111` | Nextcloud WebDAV proxy with caching and access controls |
Volumes declared in compose: `open-webui`, `pgdata`, `searxng_data`, `webui_data`, `schedule_data`, and `nextcloud_data`
> [!CAUTION]
> PLEASE I BEG OF YOU REMEMBER TO BACK THESE UP/USE A LOCAL DIRECTORY.
> IF YOU DO NOT AND REMOVE OR PRUNE THE VOLUMES YOU WILL LOSE *ALL* DATA
---
## Service Details
### Open WebUI (`open-webui`)
- Runs the latest `ghcr.io/open-webui/open-webui:main` image with Postgres backing for durable data (`open-webui` and `pgdata` volumes)
- `.env` enables the login form, optional API keys (not currently used), and forwards identifying headers so downstream tools know which user initiated a request
- Depends on the tool containers (`openwebui_tools`, `coderunner`, `schedules-api`, `ollama-nextcloud`) via internal networking; discover their OpenAPI docs from inside the UI to register tools
### Postgres (`postgres`)
> [!IMPORTANT]
> If you plan on exposing ports on this service, please move the inline credentials to the `.env` file
- Standard `postgres:latest` image. Credentials are set inline in compose for local development
- Health-checked with `pg_isready`; the data volume `pgdata` stores Open WebUI metadata
### SearxNG (`searxng`)
- Private SearxNG deployment for agent web search tasks with HTML/JSON outputs enabled
- Mounts `searxng.yml` and persists internal data to `searxng_data`. External port 4001 is exposed only for local debugging and should be removed in production
### Code Runner (`coderunner`)
- Bun-based HTTP server that accepts pure source code plus optional extra files, then runs the workload in a throwaway Docker container pinned to an allow-listed base image per language
- Enforces strict limits (`--network=none`, read-only root FS, tmpfs workdir, CPU/memory caps, dropped capabilities). Supported Languages:
- `python`
- `node`
- `bun`
- `bash`
- `ruby`
- `go`
- `rust`
- `java`
- `c`
- `cpp`
- Exposes `GET /openapi.json` and `POST /execute` inside the internal network (`http://coderunner:8787`). Requires the host Docker socket to spawn child sandboxes; the compose file mounts it read-only with matching group ID.
### Roku Tool Server (`openwebui_tools`)
- Lightweight Python HTTP server that proxies Roku remote commands
- Reads `ROKU_IP` from `.env`; returns helpful errors when the IP is missing or the device is offline
- Serves `GET /roku/openapi.json` for automatic tool registration and handles `GET /roku/{command}` requests. Supported command list matches the enum in `spec/roku.openapi.json` (navigation, inputs, power, volume, remote finder)
### Browser Research UI (`browser`)
- Builds the upstream `browser-use/web-ui` project, installs Chromium plus dependencies, and launches the UI on port 7788
- Runs as an unprivileged user (uid 1000) with dedicated tmpfs directories and a `webui_data` volume for persisted history/state
- Configure resolution, telemetry, and default LLM via `browser/.env` or container environment variables
- The browser-use docs can be found at https://docs.browser-use.com/
### Scheduler API (`schedules-api`)
- Bun/Node cron worker that lets you schedule Open WebUI chats or template-driven jobs using authenticated user tokens
- Persists schedule definitions to `schedule_data` (JSON payload) and can store uploaded supporting files under the same volume
- Reads workflow templates from the bundled `scheduler/templates.json`. To inject custom templates, mount a host file or populate the root-level `templates.json/` directory and update the compose volume mapping
- Key endpoints (documented in `scheduler/openapi.json`):
- `GET /openapi.json`: tool contract.
- `POST /api/schedules`: create or replace a schedule (cron or one-shot ISO timestamp). Validates feature flags, attachments, and template references
- `GET /api/schedules`: list schedules scoped to the calling user (identified via Open WebUI bearer token)
- `DELETE /api/schedules/{name}`: remove a schedule the user owns
- Includes a static UI in `scheduler/public/` for manual interaction. Uses `node-cron` to avoid overlapping executions; failed jobs clean themselves up
### Nextcloud Files Tool (`ollama-nextcloud`)
- Express + WebDAV proxy that exposes a simple JSON API for browsing, downloading, and uploading files stored in Nextcloud
- Environment variables (configured in `.env`):
- `NEXTCLOUD_APP_ID` / `NEXTCLOUD_APP_PASS` / `NEXTCLOUD_WEBDAV_ADDR`: service credentials
- `NEXTCLOUD_ACCESS_DIRS`: JSON array of regex strings that whitelist readable paths (e.g. `["^/Notes", "^/School"]`). When unset, the tool has full access
- Cached downloads are stored under `/tmp` using an embedded SQLite index (`cache.ts`). The server keeps ETags in sync and reuses cached bytes when possible unless `bypasscache` is requested
- Major endpoints (see `nextcloud/openapi.json`):
- `GET /openapi.json`: discovery document for tool registration.
- `POST /file`: fetch a file. Automatically caches and returns metadata + content-type.
- `POST /dir`: list directory contents (shallow or recursive).
- `PUT /file`: upload via multipart form-data (optional recursive dir creation, never overwrites existing files).
### Cloudflared Tunnel Config
- `cloudflared-tunnel-config.yml` maps friendly hostnames to the local services (Ollama, Open WebUI, tool servers). Use it as a blueprint when exposing the stack through Cloudflare Tunnels.
---
## Configuration (`.env`)
```env
ROKU_IP=
WEBUI_URL=
# use built-in login form (username/password)
ENABLE_LOGIN_FORM="true"
# forward identity on outbound model requests (if you're going to use openAI/external LLM)
ENABLE_FORWARD_USER_INFO_HEADERS="true"
# allow user api keys for the scheduler calling OWUIs
ENABLE_API_KEY_AUTH="true"
NEXTCLOUD_APP_ID=
NEXTCLOUD_APP_PASS=
NEXTCLOUD_WEBDAV_ADDR=
NEXTCLOUD_ACCESS_DIRS=
```
---
## Running the Stack
1. Install Docker and Docker
2. Populate `.env` with the correct Roku and Nextcloud settings plus any Open WebUI options
3. Build images (pull base layers and bake GID overrides where needed):
```sh
docker compose build --pull
```
4. Launch everything:
```sh
docker compose up -d
```
5. Open WebUI is available on http://localhost:4000 (use credentials from the UI setup). The supporting services are reachable on the ports listed above or through the internal Docker network
To inspect logs for a specific service:
```sh
.
├─ docker-compose.yml
├─ searxng.yml # searxng settings; defaults, json+html enabled; not a public instance
├─ cloudflared-tunnel-config.yml # cloudflare tunnel routing to ollama, openwebui, and tools
├─ README.md
├─ LICENSE # apache-2.0
├─ rag-server/
│ ├─ Dockerfile # Runs the file that does the RAG stuff
│ └─ index.tsx # Does the RAG stuff
├─ browser/
│ └─ Dockerfile # builds browser-use/web-ui (playwright chromium) on :7788
|
└─ coderunner/
├─ Dockerfile # bun-based service that exposes an OpenAPI tool for sandboxed code exec
├─ index.ts # the server; integrates with Open WebUI as a tool via /openapi.json
└─ package.json # @types/node only (dev) to feed the OCD
docker compose logs -f coderunner
```
### Open WebUI (in `docker-compose.yml`)
Bring the stack down (volumes persist):
* purpose: chat UI + orchestration layer; **includes a built-in knowledge base + RAG** with chunking, embedding, search, and prompt templating.
* notable: backed by Postgres in this compose. exposes `4000:8080`.
* storage: a docker volume `open-webui:` holds app data; Postgres uses `pgdata:`.
### Postgres (in `docker-compose.yml`)
* purpose: persistence for Open WebUI features (users, knowledge, etc.). health-checked with `pg_isready`.
### SearxNG (in `docker-compose.yml` + `searxng.yml`)
* purpose: metasearch engine used by Open WebUI tools/agents for live web lookups.
* config highlights: `use_default_settings: true`, `public_instance: false`, `limiter: false`; formats: `html` and `json`.
### Coderunner service (`coderunner/`)
* **what it is:** a small HTTP server (Bun runtime) that executes pure source code in short-lived, sandboxed containers.
* **why it exists:** lets Open WebUI tools run code safely with tight resource limits (no network, read-only fs, cgroup limits, `--cap-drop=ALL`, `no-new-privileges`).
* **integration contract:** exposes an **OpenAPI schema at `/openapi.json`** and a single POST `/execute` endpoint. Open WebUI can import this as a **tool server**.
* **security posture:** pulls allow-listed base images (gcc, python, node, bun, etc.), mounts only a tmpfs workdir, times out jobs ≈25s, and runs with non-root uid/gid. The container has access to the hosts docker socket *only* to run the sandbox containers.
### Browser-use web-ui (`browser/`)
* purpose: “autonomous” research browser UI (chromium via playwright), reachable on `:7788`.
* built from upstream `browser-use/web-ui` repo, with python deps and browsers installed in the image.
### Cloudflared tunnel (`cloudflared-tunnel-config.yml`)
* maps hostnames (like `mlep.domain.com` for Ollama, `owebui.domain.com` for Open WebUI, and a `tools` host) to the internal services. Useful for private, authenticated access without public inbound ports.
---
## Why I currently **dont** use an external RAG server
Open WebUI ships with pretty good **knowledge / RAG** support: add files/URLs, it chunks + embeds, indexes, retrieves, and automatically **prefixes retrieved context** to the model prompt using a RAG template. For lightweight to mid-sized corpora and single-user/small-team usage, thats often all you need.
**Stay with built-in RAG if most of these are true:**
* total corpus is ≤ \~100k chunks and grows slowly.
* single user or small team (no multi-tenant isolation needed).
* no special retrieval logic (hybrid lexical+semantic, rerankers, metadata filters) beyond what Open WebUI provides.
* tolerance for “UI-managed” knowledge; you dont need programmatic ingestion pipelines or job queues.
## When an external RAG server makes sense
Adopt a decoupled RAG service when you need one or more of:
* **bigger data / throughput**: millions of chunks, higher QPS, horizontal scaling.
* **advanced retrieval**: custom chunkers, hybrid search (bm25 + vector), **reranking**, time-decay, per-tenant filters, embeddings A/B, or multi-modal (image/audio) retrieval.
* **programmatic ingestion**: CI-driven pipelines from git/docs/confluence/S3; delta updates; background jobs.
* **governance / isolation**: strict multi-tenant separation, PII retention controls, audit trails.
* **interoperability**: a clean HTTP API and OpenAPI so other apps (beyond Open WebUI) can reuse your index.
---
## External RAG Server — Design and Reference Implementation
This is a small, dependency-light service designed to run with **Bun** and integrate with both **Ollama** and **Open WebUI**.
### Goals
* minimal moving parts; runs fine on a single host.
* uses Ollama for **embeddings** and **chat**.
* supports **collections**, **upserts**, **queries**, and an opinionated `/chat` that does retrieve-then-generate.
* ships an **OpenAPI** so Open WebUI can import it as a tool server.
* default in-memory store (persisted to JSON) for simplicity; optional adapters for vector DBs later.
### API surface
* `GET /openapi.json` schema for tool integration.
* `POST /collections` create a logical collection `{ name }`.
* `GET /collections` list collections.
* `POST /upsert` `{ collection, items:[{ id?, text, metadata? }] }`; chunks+embeds text and stores vectors.
* `POST /query` `{ collection, query, topK?=5, where? }` --> nearest chunks with scores.
* `POST /chat` `{ collection, query, topK?=5, model?, embedModel? }` --> runs RAG and calls Ollama chat, returns the answer + citations.
### Storage Strategy
* **default:** in-memory + JSON file on disk (`./data/rag.json`). good for dev/small usage.
* **plug-in adapters:** swap in Qdrant, SQLite-Vec, pgvector, Weaviate, etc., without changing the HTTP API.
---
### Add to `docker-compose.yml`
```yaml
rag:
build:
context: ./rag-server
dockerfile: Dockerfile
environment:
OLLAMA_BASE: "http://mlep.domain.com:11434"
OLLAMA_CHAT_MODEL: "llama3.1"
OLLAMA_EMBED_MODEL: "nomic-embed-text"
volumes:
- rag_data:/app/data
networks:
- internal
restart: unless-stopped
volumes:
rag_data:
```sh
docker compose down
```
> if you already expose services via cloudflared, add another hostname mapping to the `rag` container (`- hostname: rag.domain.com -> service: http://rag:8788`).
---
## Registering Tool Servers in Open WebUI
Inside Open WebUI (Settings --> Tools --> Add tool server), point to the internal URLs:
- Code runner: `http://coderunner:8787/openapi.json`
- Scheduler: `http://schedules-api:12253/openapi.json`
- Nextcloud files: `http://ollama-nextcloud:1111/openapi.json`
- Roku remote: `http://openwebui_tools:1331/roku/openapi.json`
These should be fully internal in the docker network. If you expose them consider using a reverse proxy/authentication
---
## Wiring the RAG server into Open WebUI and Ollama
## Data, Volumes, and Shared Paths
### 1. Pull models
- `open-webui` volume: Open WebUI application state (uploads, knowledge base, configs)
- `pgdata` volume: Postgres cluster data directory
- `searxng_data` volume: SearxNG runtime files
- `webui_data` volume: browser-use web UI session data
- `schedule_data` volume: scheduler persisted schedules and stored file attachments
- `nextcloud_data` volume: temp storage for cached Nextcloud content
* `ollama pull nomic-embed-text` (embeddings)
* `ollama pull llama3.1` (chat)
### 2. Expose the OpenAPI to Open WebUI as a **tool server**
* in Open WebUI --> **settings --> tools** --> **add tool server**
* paste the url for the cloudflared hostname
* youll now see tool functions like `listCollections`, `createCollection`, `upsert`, `query`, `chat` available to the assistant
### 3. Usage pattern inside a chat
* to build a knowledge base, call the `createCollection` and `upsert` tools with your documents
* to answer, call `chat` which performs retrieve-then-generate against your chosen collection
---
## FAQ — Built-in vs. External RAG
**Q: will Open WebUIs built-in RAG conflict with this server?**
no — you can use either, or both. Open WebUIs knowledge base is great for ad-hoc use. this service is for programmatic/control-plane needs or when you outgrow the UIs storage/retrieval.
**Q: how do enforce tenant isolation?**
use one collection per tenant and never mix. for stronger guarantees, run separate RAG instances or choose Qdrant with per-collection access control.
**Q: how can use my chunker/reranker?**
yes. place them ahead of `/upsert` and `/query` respectively, or add endpoints like `/rerank` and `/embed` to experiment.
**Q: can this call OpenAI-compatible endpoints instead of native Ollama?**
Ollama exposes an experimental OpenAI-compatible API. you can add a thin client if you already point tools at `/v1/chat/completions`.
> [!IMPORTANT]
> Back up the volumes you care about before upgrading images
---
## License
This write-up and reference code are provided under the same **Apache-2.0** terms as the repository.
The repository and reference code are released under Apache-2.0 (see `LICENSE`).
+10 -4
View File
@@ -9,19 +9,25 @@ RUN apk add --no-cache docker-cli tini curl;
# ----- map container 'docker' group to host docker.sock GID -----
# pass the host's docker.sock GID at build time: --build-arg DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
ARG DOCKER_GID=977
# create (or reuse) a group with that GID, then add the existing 'bun' user to it
RUN addgroup -g "${DOCKER_GID}" -S docker || true \
&& addgroup bun docker;
RUN chown -R bun:bun /app
# switch to the nonroot bun user (already default in the base image, but explicit is nice)
USER bun
# your app
COPY index.ts ./index.ts
# files
COPY package.json .
COPY index.ts .
COPY openapi.json .
RUN bun i
# expose your tool server
EXPOSE 8787
ENV PORT=8787
# default docker host path; adjust if you mount elsewhere
ENV DOCKER_HOST=unix:///var/run/docker.sock
+1 -77
View File
@@ -45,83 +45,7 @@ type fileType = {
const DOCKER_BIN = process.env.DOCKER_BIN || "docker";
// basic openapi for open webui
const OPENAPI = {
openapi: "3.1.0",
info: {
title: "Container Code Runner",
version: "1.0.0",
description:
"run source code inside a sandboxed container. important: provide pure source code only; do not wrap code in shell commands or pipelines."
},
paths: {
"/execute": {
post: {
operationId: "execute",
summary: "Run code in a sandboxed container",
// the model sees this text
description:
"use the language directly, not bash + the language. e.g., `#include...` (good) vs `echo '#include...' && gcc` (bad). pass only pure source text in `code`.",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
language: {
type: "string",
enum: Object.keys(LANGS),
description:
"the programming language to run. do not use 'bash' to wrap or invoke compilers/interpreters; select the actual language (e.g., 'c', 'cpp', 'python')."
},
code: {
type: "string",
description:
"pure source code only. do not include shell commands, redirections, pipes, or `echo`/`printf` wrappers. examples: good: `print('hi')`; bad: `echo \"print('hi')\" | python`."
},
args: { type: "array", items: { type: "string" } },
files: {
type: "array",
items: {
type: "object",
properties: {
path: { type: "string" },
content: { type: "string" }
},
required: ["path", "content"],
description:
"optional supporting files. contents must be pure file text, not shell commands."
}
}
},
required: ["language", "code"]
}
}
}
},
responses: {
"200": {
description: "Execution result",
content: {
"application/json": {
schema: {
type: "object",
properties: {
stdout: { type: "string" },
stderr: { type: "string" },
exitCode: { type: "integer" },
timedOut: { type: "boolean" }
}
}
}
}
}
}
}
}
}
};
const OPENAPI = JSON.parse((await import('fs')).readFileSync('openapi.json'))
function sendJson(res, status, obj) {
const body = JSON.stringify(obj);
+93
View File
@@ -0,0 +1,93 @@
{
"openapi": "3.1.0",
"info": {
"title": "Container Code Runner",
"version": "1.0.0",
"description": "run source code inside a sandboxed container. important: provide pure source code only; do not wrap code in shell commands or pipelines."
},
"paths": {
"/execute": {
"post": {
"operationId": "execute",
"summary": "Run code in a sandboxed container",
"description": "use the language directly, not bash + the language. e.g., `#include...` (good) vs `echo '#include...' && gcc` (bad). pass only pure source text in `code`.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"language": {
"type": "string",
"enum": "Object.keys(LANGS)",
"description": "the programming language to run. do not use 'bash' to wrap or invoke compilers/interpreters; select the actual language (e.g., 'c', 'cpp', 'python')."
},
"code": {
"type": "string",
"description": "pure source code only. do not include shell commands, redirections, pipes, or `echo`/`printf` wrappers. examples:\n\tgood: `print('hi')`;\n\tbad: `echo \"print('hi')\" | python`."
},
"args": {
"type": "array",
"items": {
"type": "string"
}
},
"files": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {
"type": "string"
},
"content": {
"type": "string"
}
},
"required": [
"path",
"content"
],
"description": "optional supporting files. contents must be pure file text, not shell commands."
}
}
},
"required": [
"language",
"code"
]
}
}
}
},
"responses": {
"200": {
"description": "Execution result",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"stdout": {
"type": "string"
},
"stderr": {
"type": "string"
},
"exitCode": {
"type": "integer"
},
"timedOut": {
"type": "boolean"
}
}
}
}
}
}
}
}
}
}
}
-29
View File
@@ -1,29 +0,0 @@
{
"name": "coderunner",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"@types/node": "^24.3.1"
}
},
"node_modules/@types/node": {
"version": "24.3.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
"integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.10.0"
}
},
"node_modules/undici-types": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"dev": true,
"license": "MIT"
}
}
}
+12 -1
View File
@@ -1,5 +1,16 @@
{
"name": "coderunner",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/node": "^24.3.1"
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@types/node": "^24.6.2",
"http": "^0.0.1-security"
}
}
+162 -115
View File
@@ -1,131 +1,178 @@
services:
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: open-webui
ports:
- "4000:8080"
volumes:
- open-webui:/app/backend/data
extra_hosts:
- host.docker.internal:host-gateway
restart: always
depends_on:
- postgres
- tools
networks:
- internal
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: open-webui
ports:
- "4000:8080"
volumes:
- open-webui:/app/backend/data
extra_hosts:
- host.docker.internal:host-gateway
restart: always
depends_on:
- postgres
- tools
networks:
- internal
tools:
container_name: openwebui_tools
build:
context: ./tools
dockerfile: Dockerfile
env_file: .env
restart: on-failure
tools:
container_name: openwebui_tools
build:
context: ./tools
dockerfile: Dockerfile
env_file: .env
restart: on-failure
networks:
- internal
networks:
- internal
postgres:
image: postgres:latest
container_name: openwebui_postgres
restart: always
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=mypassword
- POSTGRES_DB=openwebui_db
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
postgres:
image: postgres:latest
container_name: openwebui_postgres
restart: always
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=mypassword
- POSTGRES_DB=openwebui_db
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
# 8080
searxng:
image: searxng/searxng:latest
container_name: searxng
volumes:
- ./searxng.yml:/etc/searxng/settings.yml:ro,Z
- searxng_data:/etc/searxng:rw
restart: always
# 8080
searxng:
image: searxng/searxng:latest
container_name: searxng
volumes:
- ./searxng.yml:/etc/searxng/settings.yml:ro,Z
- searxng_data:/etc/searxng:rw
restart: always
# DELETEME: for local testing only (extern port closed)
ports:
- "4001:8080"
networks:
- internal
# DELETEME: for local testing only (extern port closed)
ports:
- "4001:8080"
networks:
- internal
coderunner:
build:
context: ./coderunner
dockerfile: Dockerfile
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8787/openapi.json"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
coderunner:
build:
context: ./coderunner
dockerfile: Dockerfile
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8787/openapi.json"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
user: "1000:1000"
group_add:
- "977"
user: "1000:1000"
group_add:
- "977"
# death
environment:
DOCKER_HOST: "unix:///var/run/docker.sock"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:Z
# - ./tmp:/tmp
# death
environment:
DOCKER_HOST: "unix:///var/run/docker.sock"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:Z
# - ./tmp:/tmp
read_only: true
tmpfs:
- /run:rw,nosuid,nodev
- /tmp:rw,exec,nosuid,nodev,size=64m
read_only: true
tmpfs:
- /run:rw,nosuid,nodev
- /tmp:rw,exec,nosuid,nodev,size=64m
security_opt:
- no-new-privileges:true
- label=disable
networks:
- internal
security_opt:
- no-new-privileges:true
- label=disable
networks:
- internal
browser:
build:
context: ./browser
dockerfile: Dockerfile
container_name: browser
networks:
- internal
# playwright/chromium has larger /dev/shm :D
shm_size: "1gb"
user: "1000:1000"
environment:
WEBUI_IP: "0.0.0.0"
WEBUI_PORT: "7788"
ports:
- "7788:7788"
tmpfs:
- /opt/web-ui/tmp:rw,exec,nosuid,nodev,mode=1777,size=64m
volumes:
- webui_data:/data
# - webui_env:/opt/web-ui/.env
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7788').read()"]
interval: 30s
timeout: 5s
retries: 5
restart: unless-stopped
browser:
build:
context: ./browser
dockerfile: Dockerfile
container_name: browser
networks:
- internal
# playwright/chromium has larger /dev/shm :D
shm_size: "1gb"
user: "1000:1000"
environment:
WEBUI_IP: "0.0.0.0"
WEBUI_PORT: "7788"
ports:
- "7788:7788"
tmpfs:
- /opt/web-ui/tmp:rw,exec,nosuid,nodev,mode=1777,size=64m
volumes:
- webui_data:/data
# - webui_env:/opt/web-ui/.env
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:7788').read()",
]
interval: 30s
timeout: 5s
retries: 5
restart: unless-stopped
schedules-api:
build: ./scheduler
restart: unless-stopped
ports:
- "12253:12253"
environment:
- PORT=12253
- DATA_DIR=/app/data
- 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
ollama-nextcloud:
build: ./nextcloud
restart: unless-stopped
# ports:
# - "13284:1111"
env_file: .env
networks:
- internal
volumes:
- ./tmp:/hosttmp
- nextcloud_data:/data/
# to store the files the model looks at (caching-ish)
- type: tmpfs
target: /tmp
tmpfs:
size: 1000m
# mode: 1777
volumes:
open-webui:
pgdata:
searxng_data:
webui_data:
open-webui:
pgdata:
searxng_data:
webui_data:
schedule_data:
nextcloud_data:
networks:
internal:
driver: bridge
internal:
driver: bridge
+5
View File
@@ -0,0 +1,5 @@
node_modules
npm-cache
bun.lock
bun.lockb
*.log
+17
View File
@@ -0,0 +1,17 @@
FROM oven/bun:1 AS base
WORKDIR /app
# prod deps
COPY package.json ./package.json
RUN bun install --ci --production
COPY . .
RUN mkdir -p /app/data && chown -R bun:bun /app/data
USER bun
ENV NODE_ENV=production
CMD ["bun", "run", "server.ts"]
+65
View File
@@ -0,0 +1,65 @@
import { Database } from 'bun:sqlite';
import { createHash } from 'crypto';
import path from 'path';
import fs from 'fs/promises';
type CacheRow = {
path_key: string,
fpath: string,
etag: string,
size: number,
mime: string | null,
cache_path: string,
updated_at: number
};
export const CACHE_DIR = "/tmp",
db = new Database(path.resolve(`${CACHE_DIR}/index.sqlite`));
db.run(`
create table if not exists file_cache (
path_key text primary key,
fpath text not null,
etag text not null,
size integer not null,
mime text,
cache_path text not null,
updated_at integer not null
);
`);
const qGet = db.query<CacheRow, string>('select * from file_cache where path_key = ?;');
const qUpsert = db.query(`
insert into file_cache (path_key, fpath, etag, size, mime, cache_path, updated_at)
values (?1, ?2, ?3, ?4, ?5, ?6, ?7)
on conflict(path_key) do update set
etag=excluded.etag, size=excluded.size, mime=excluded.mime,
cache_path=excluded.cache_path, updated_at=excluded.updated_at;
`);
export const base64url = (buf: Buffer): string =>
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); // no padding
export const pathKey = (addr: string, fpath: string): string => {
// normalize to avoid duplicate keys for equivalent paths
const canonical = new URL(fpath, addr).toString(),
digest = createHash('sha256').update(canonical).digest();
return base64url(digest);
};
export const fanoutPath = (key: string): string =>
path.join(`${CACHE_DIR}/files`, key.slice(0, 2), key.slice(2, 4), key);
export async function ensureDir(p: string): Promise<void> {
await fs.mkdir(path.dirname(p), { recursive: true });
}
export function getRow(key: string): CacheRow | undefined {
return qGet.get(key) as unknown as CacheRow | undefined;
}
export function upsertRow(row: CacheRow): void {
qUpsert.run(row.path_key, row.fpath, row.etag, row.size, row.mime, row.cache_path, row.updated_at);
}
+127
View File
@@ -0,0 +1,127 @@
import { Request } from "express";
import { WebDAVClient } from "webdav";
import { DIRS } from "./server";
import fetch from 'node-fetch'
import { XMLParser } from 'fast-xml-parser';
export async function statOne(fpath: string, client: WebDAVClient) {
const parent = fpath.replace(/\/[^/]+$/, '') || '/',
base = fpath.split('/').filter(Boolean).pop(),
list = await client.getDirectoryContents(parent, { deep: false }).catch(err => {
console.error(`error for "${parent}":`);
console.error(err);
if (err.response?.status === 404) {
throw `Path "${fpath}" not found`;
}
throw err;
}),
entry = Array.isArray(list) ? list.find((e: any) => e.basename === base) : undefined;
if (!entry) throw `Path "${fpath}" not found`;
return { etag: entry.etag as string, size: Number(entry.size), mime: entry.mime as string | undefined };
}
export const toRegExp = (spec: string): RegExp | null => {
// slash-delimited form: /pattern/flags
const m = spec.match(/^\/(.+)\/([a-z]*)$/i);
try {
if (m) {
const [, source, flags] = m;
return new RegExp(source, flags);
}
// raw pattern form: "pattern"
return new RegExp(spec);
} catch {
return null;
}
}
export function checkIfHasPerms(req: Request | string) {
try {
if (typeof req === 'string') {
if (req.includes('..')) return false;
return !DIRS || DIRS.find(r => req.match(r));
}
else if (!req.body) {
return false;
}
const { fpath, obj_type, deep, usecache } = req.body,
o = { fpath, obj_type, deep, usecache };
if (!fpath) return false;
if (!DIRS) return o;
if (fpath.includes('..')) return false;
return DIRS.find(r => fpath.match(r)) ? o : false;
}
catch (err) {
console.error(err);
return false;
}
}
function buildCountPropfindXml() {
return `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:"
xmlns:oc="http://owncloud.org/ns"
xmlns:nc="http://nextcloud.org/ns">
<d:prop>
<nc:contained-file-count/>
<nc:contained-folder-count/>
<oc:contained-file-count/>
<oc:contained-folder-count/>
</d:prop>
</d:propfind>`;
}
const authHeaders = (() => {
const u = process.env.NEXTCLOUD_APP_ID!,
p = process.env.NEXTCLOUD_APP_PASS!,
token = Buffer.from(`${u}:${p}`).toString("base64");
return `Basic ${token}`;
})();
export async function getDirectoryFileCount(dirPath: string, client: WebDAVClient) {
const url = client.getFileDownloadLink(dirPath),
xml = buildCountPropfindXml();
const resp = await fetch(url, {
method: "PROPFIND",
headers: {
"Content-Type": "text/xml",
"Depth": "0",
"Authorization": authHeaders
},
body: xml
});
if (!resp.ok) {
throw resp;
}
// parse XML
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
allowBooleanAttributes: true,
numberParseOptions: {
hex: false,
leadingZeros: false
}
}),
text = await resp.text(),
doc = parser.parse(text),
objs = doc['d:multistatus']['d:response']['d:propstat'],
obj = objs.find((o: any) => o['d:status'].includes("200"))['d:prop'];
return {
file_count: obj['nc:contained-file-count'],
folder: obj['nc:contained-folder-count']
};
}
+564
View File
@@ -0,0 +1,564 @@
{
"openapi": "3.1.0",
"info": {
"title": "Nextcloud Files API (openwebui-friendly)",
"description": "simple file/directory access via nextcloud webdav, with local disk cache.",
"version": "1.0.1"
},
"servers": [
{
"url": "http://ollama-nextcloud:1111",
"description": "local server (use absolute url for tool discovery)"
}
],
"paths": {
"/ping": {
"get": {
"operationId": "nextcloudPing",
"summary": "health / verification probe",
"description": "simple json probe used by tool registrars to verify the server is reachable",
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": true
},
"time": {
"type": "string",
"format": "date-time",
"example": "2025-09-01T12:00:00Z"
}
},
"required": [
"ok"
]
}
}
}
}
}
}
},
"/file": {
"post": {
"operationId": "nextcloudGetFile",
"summary": "Fetch a file (proxied via WebDAV, cached locally)",
"description": "returns the raw file bytes. content-type mirrors the upstream mime when available; otherwise application/octet-stream. also supports an application/json metadata variant for tool registration and LLM-friendly responses.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"fpath": {
"type": "string",
"description": "absolute path in nextcloud webdav (e.g., /Documents/report.pdf)"
},
"bypasscache": {
"type": "boolean",
"default": false,
"description": "if true, skip the local cache and fetch from upstream"
}
},
"required": [
"fpath"
]
},
"examples": {
"default": {
"value": {
"fpath": "/Documents/report.pdf",
"bypasscache": false
}
}
}
}
}
},
"responses": {
"200": {
"description": "file bytes or metadata",
"headers": {
"content-type": {
"description": "mime type from upstream or application/octet-stream",
"schema": {
"type": "string"
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileMeta"
},
"examples": {
"example": {
"value": {
"filename": "/Documents/report.pdf",
"basename": "report.pdf",
"lastmod": "Mon, 01 Sep 2025 12:34:56 GMT",
"size": 2048,
"type": "file",
"etag": "\"a1b2c3d4e5\"",
"mime": "application/pdf",
"cached": true,
"download_url": "http://ollama-nextcloud:1111/file?fpath=/Documents/report.pdf"
}
}
}
},
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
},
"*/*": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"500": {
"$ref": "#/components/responses/ServerError"
}
}
},
"put": {
"operationId": "nextcloudUploadFile",
"summary": "Upload a file (application/json, base64-encoded)",
"description": "Uploads a file into a target directory in nextcloud via webdav. the server will not overwrite existing files. the body must be application/json and include `fdir` and `file.buffer` (base64).",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NewFileMeta"
},
"examples": {
"json-base64": {
"value": {
"fdir": "/Documents",
"createnewdirs": false,
"file": {
"filename": "file",
"originalname": "photo.jpg",
"mimetype": "image/jpeg",
"size": 52345,
"buffer": "/9j/4AAQSkZJRgABAQAAAQABAAD... (truncated base64)"
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "upload succeeded",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": true
},
"uploaded": {
"$ref": "#/components/schemas/DirEntry"
}
},
"required": [
"ok",
"uploaded"
]
}
},
"text/plain": {
"schema": {
"type": "string"
},
"example": "file uploaded successfully to https://nextcloud.example.com/remote.php/dav/files/user/Documents/example.txt"
}
}
},
"400": {
"description": "missing file upload field or invalid base64",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": false
},
"error": {
"type": "string",
"example": "missing file or invalid buffer"
}
}
}
},
"text/plain": {
"schema": {
"type": "string"
},
"example": "missing file"
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"503": {
"description": "file exists and overwrite is not permitted",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": false
},
"error": {
"type": "string",
"example": "file already exists, you do not have permissions to overwrite files"
}
}
}
},
"text/plain": {
"schema": {
"type": "string"
},
"example": "file already exists, you do not have permissions to overwrite files"
}
}
},
"500": {
"$ref": "#/components/responses/ServerError"
}
}
}
},
"/dir": {
"post": {
"operationId": "nextcloudListDirectory",
"summary": "List a directory",
"description": "Lists directory entries from nextcloud webdav. Supports shallow or deep listing.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"fpath": {
"type": "string",
"description": "directory path in nextcloud webdav (e.g., /Documents)"
},
"deep": {
"type": "boolean",
"default": false,
"description": "whether to recurse into subdirectories"
},
"startInd": {
"type": "number",
"default": 0,
"description": "start listing from this index (for pagination)"
},
"limit": {
"type": "number",
"default": 250,
"description": "maximum number of entries to return"
}
},
"required": [
"fpath"
]
},
"examples": {
"default": {
"value": {
"fpath": "/Documents",
"deep": false
}
}
}
}
}
},
"responses": {
"200": {
"description": "directory listing",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DirEntry"
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"500": {
"$ref": "#/components/responses/ServerError"
}
}
}
},
"/openapi.json": {
"get": {
"operationId": "nextcloudGetOpenapi",
"summary": "Serve OpenAPI schema",
"description": "Serves this specification (used by open webui when registering a tool server).",
"responses": {
"200": {
"description": "openapi document",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"DirEntry": {
"type": "object",
"description": "Entry returned by webdav client",
"properties": {
"filename": {
"type": "string",
"example": "/Documents/report.pdf"
},
"basename": {
"type": "string",
"example": "report.pdf"
},
"lastmod": {
"type": "string",
"example": "Mon, 01 Sep 2025 12:34:56 GMT"
},
"size": {
"type": "integer",
"format": "int64",
"example": 2048
},
"type": {
"type": "string",
"enum": [
"file",
"directory"
]
},
"etag": {
"type": "string",
"example": "\"a1b2c3d4e5\""
},
"mime": {
"type": "string",
"nullable": true,
"example": "application/pdf"
}
},
"required": [
"basename",
"type"
]
},
"FileMeta": {
"type": "object",
"description": "Metadata about a file (json-friendly alternative to raw bytes)",
"properties": {
"filename": {
"type": "string"
},
"basename": {
"type": "string"
},
"lastmod": {
"type": "string"
},
"size": {
"type": "integer",
"format": "int64"
},
"type": {
"type": "string",
"enum": [
"file",
"directory"
]
},
"etag": {
"type": "string"
},
"mime": {
"type": "string",
"nullable": true
},
"cached": {
"type": "boolean",
"description": "whether the file was served from local cache"
},
"download_url": {
"type": "string",
"format": "uri",
"description": "url to download raw bytes (may be the same endpoint with different accept header)"
}
},
"required": [
"basename",
"type"
]
},
"NewFileMeta": {
"type": "object",
"properties": {
"fdir": {
"type": "string",
"description": "destination directory path (e.g., /Documents)"
},
"createnewdirs": {
"type": "boolean",
"default": false,
"description": "if true and the directory does not exist, create it recursively before uploading"
},
"file": {
"type": "object",
"description": "uploaded file metadata + contents (base64 if image or misc formats otherwise)",
"properties": {
"filename": {
"type": "string",
"description": "name of the form field associated with this file or client-side identifier"
},
"originalname": {
"type": "string",
"default": "",
"description": "name of the file on the uploader's computer"
},
"mimetype": {
"type": "string",
"default": "text/plain",
"description": "value of the Content-Type for this file, e.g. image/jpeg"
},
"size": {
"type": "integer",
"format": "int64",
"default": 0,
"description": "size of the file in bytes"
},
"buffer": {
"type": "string",
"format": "byte",
"contentEncoding": "any",
"contentMediaType": "application/octet-stream",
"description": "the contents of the file"
}
},
"required": [
"filename",
"mimetype",
"buffer"
]
}
},
"required": [
"fdir",
"file"
]
}
},
"responses": {
"Unauthorized": {
"description": "not authorized to access the requested path (middleware denied or missing parameters)",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": false
},
"error": {
"type": "string"
}
}
}
}
}
},
"NotFound": {
"description": "path not found upstream",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": false
},
"error": {
"type": "string"
}
}
}
}
}
},
"ServerError": {
"description": "unexpected server error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": false
},
"error": {
"type": "string"
}
}
}
}
}
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "nextcloud",
"private": true,
"devDependencies": {
"@types/bun": "latest",
"@types/xmldom": "^0.1.34"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"cors": "^2.8.5",
"express": "^5.1.0",
"webdav": "^5.8.0"
}
}
+292
View File
@@ -0,0 +1,292 @@
import express from 'express';
import fs from 'fs/promises';
import fsSync from 'fs';
import { randomUUID } from 'crypto';
import { createClient } from 'webdav';
import { pathKey, fanoutPath, ensureDir, getRow, upsertRow, CACHE_DIR } from './cache.ts';
import { checkIfHasPerms, getDirectoryFileCount, statOne, toRegExp } from './helpers.ts';
import path from 'path';
type statRetType = {
"filename": string,
"basename": string,
"lastmod": string,
"size": number,
"type": "file" | "directory",
"etag": string,
"mime"?: string
};
type ReqFileType = {
filename: string; // Name of the form field associated with this file.
originalname?: string; // Name of the file on the uploader's computer.
mimetype: string; // Value of the Content-Type header for this file.
size?: number; // Size of the file in bytes.
buffer: Buffer; // A Buffer containing the file content.
localFilename: string; // the temporary ID assigned to the file
};
declare global {
namespace Express {
interface Request {
fobj?: {
fpath: any,
deep?: boolean,
bypasscache?: boolean,
startInd?: number,
limit?: number
},
file: ReqFileType
}
}
}
const {
NEXTCLOUD_APP_ID: APP_ID,
NEXTCLOUD_APP_PASS: APP_PASS,
NEXTCLOUD_WEBDAV_ADDR: ADDR,
NEXTCLOUD_ACCESS_DIRS: _DIRS,
PORT: PORT_RAW
} = process.env,
PORT = PORT_RAW || 1111;
if (!(ADDR && APP_ID && APP_PASS)) {
throw new Error("VARIABLES NOT FOUND IN ENV");
}
// const ADDR_URL = new URL(ADDR);
const app = express();
app.use(express.json());
app.use(express.text());
app.use((await import('cors')).default());
app.use((req, res, next) => {
if (req.path === '/openapi.json') {
return res.sendFile('openapi.json', { root: '.' });
}
// GET/PUT has independant handlers
else if (['GET', 'PUT'].includes(req.method)) return next();
const pth = checkIfHasPerms(req);
if (!pth && req.body?.fpath) {
return res.status(401).send(`Not allowed to access "${req.body.fpath}"`);
}
else if (!pth) {
return res.status(404).send("Unknown path");
}
else if (typeof pth === 'string') {
console.warn(`somehow got string out of checkIfHasPerms:\npth: ${pth}\nreq.body: ${req.body}`);
return res.sendStatus(400);
}
req.fobj = pth;
next();
});
export const DIRS: string[] = (() => {
try {
if (!_DIRS) return null;
return JSON.parse(_DIRS).map(toRegExp)
}
catch (err) { console.error(err); }
return null;
})();
if (!DIRS) {
console.warn("NEXTCLOUD_ACCESS_DIRS not specified, tool has full access to all files");
}
const client = createClient(process.env.NEXTCLOUD_WEBDAV_ADDR!, {
username: process.env.NEXTCLOUD_APP_ID!,
password: process.env.NEXTCLOUD_APP_PASS!
});
app.post('/file', async (req, res) => {
if (!req.fobj) return res.sendStatus(401);
const { fpath, bypasscache } = req.fobj;
try {
// read current etag/size from webdav
const { etag, size, mime } = await statOne(fpath, client).catch((err) => {
if (typeof err === 'string') {
console.error(err);
if (!res.headersSent) res.sendStatus(404).send(err);
return { etag: undefined, size: -1, mime: -1 };
}
throw err;
});
if (!etag) return;
// lookup mapping
const key = pathKey(process.env.NEXTCLOUD_WEBDAV_ADDR!, fpath),
cache_path = fanoutPath(key),
row = bypasscache ? null : getRow(key);
// cached file
if (row && row.etag === etag) {
const data = await fs.readFile(row.cache_path);
res.setHeader('content-type', row.mime ?? 'application/octet-stream');
return res.send(data);
}
// TODO: If-None-Match here if client lib exposes raw headers
const buf = await client.getFileContents(fpath).catch(console.error);
if (!buf) return res.sendStatus(500);
await ensureDir(cache_path);
await fs.writeFile(cache_path, buf as any);
upsertRow({
path_key: key,
fpath,
etag,
size,
mime: mime ?? null,
cache_path,
updated_at: Date.now()
});
res.setHeader('content-type', mime ?? 'application/octet-stream');
res.send(buf);
} catch (err) {
console.error(err);
if (!res.headersSent) res.sendStatus(500);
}
});
app.post('/dir', async (req, res) => {
try {
if (!req.fobj) return res.sendStatus(401);
const { fpath, deep } = req.fobj,
{ startInd = 0, limit = 250 } = req.body;
if (!fpath) return res.sendStatus(400);
if (!(await client.exists(fpath))) {
return res.status(404).send("Directory not found");
}
const getFilesInDir = async () => {
for (let i = 0; i < 3; i++) {
try {
return await client.getDirectoryContents(fpath, {
deep
});
}
catch (err: any) {
if (err.response?.status === 500) {
await new Promise(resolve => setTimeout(resolve, 2000));
continue;
}
console.error(`error for "${fpath}":`);
throw err;
}
}
const counts = await getDirectoryFileCount('/Photos', client);
let err = "failed to find dir contents (perhaps the server returned 500 too many times?)";
err += `\n"${fpath}" contains the following, consider listing directories individually (instead of using 'deep' if too many are present: ${JSON.stringify(counts)}`;
res.status(500).send(err);
}
const ret = await getFilesInDir();
if (!ret) return;
const filesInDir = Array.isArray(ret) ? ret : ret.data;
res.json(filesInDir.slice(startInd, limit));
}
catch (err) {
console.error(err);
res.sendStatus(500);
}
});
// openwebui doesn't really help with `multerInstance.single('file'),`
const ensureMockFile = async (req: express.Request, res: express.Response, next: express.NextFunction) => {
try {
const { file, fdir } = req.body as { fdir: any, file: { filename: any, buffer: any, mimetype: any } };
if (!file) return res.status(400).send("missing file");
if (typeof fdir !== 'string') {
return res.status(400).send("Missing fdir in request");
}
if (typeof file.filename !== 'string') {
return res.status(400).send("Invalid filename");
}
if (typeof file.buffer === 'undefined') {
return res.status(400).send("Invalid file upload payload");
}
if (!checkIfHasPerms(fdir)) return res.sendStatus(401);
const fname = `${randomUUID().replaceAll('-', '')}_${file.filename}`,
fpath = path.join(CACHE_DIR, fname);
await fs.writeFile(fpath, file.buffer).catch((err) => {
console.error(err);
res.sendStatus(500);
});
if (res.headersSent) return;
req.file = { ...file, localFilename: fname, mimetype: req.body.mimetype || '' };
next();
}
catch (err) {
console.error(err);
if (!res.headersSent) res.sendStatus(500);
}
};
app.put('/file', ensureMockFile, async (req, res) => {
const file = req.file;
if (!file) return;
const { fdir, createnewdirs } = req.body as {
fdir: string,
createnewdirs: boolean
};
if (!fdir) return (res.headersSent || res.status(400).send("missing fdir in body"));
if (createnewdirs && !(await client.exists(fdir))) {
await client.createDirectory(fdir, {
recursive: true
});
}
const fname = file.filename || file.originalname,
fpath = path.join(fdir, fname!);
if (await client.exists(fpath)) {
return res.status(503).send('file already exists, you do not have permissions to overwrite files');
}
const sstr = fsSync.createReadStream(path.join(CACHE_DIR, file.localFilename)).pipe(client.createWriteStream(fpath, {
overwrite: false
}, (r) => {
if (res.headersSent) return;
res.send(`file uploaded successfully to ${r.url}`);
}));
sstr.on('error', (err) => {
console.error(err);
if (res.headersSent) return;
res.status(500).send("failed to upload file");
});
});
app.get('/ping', (_, res) => res.sendStatus(200));
app.listen(PORT, (err) => {
if (err) throw err;
console.log(`app listening on port ${PORT}`);
});
-12
View File
@@ -1,12 +0,0 @@
# syntax=docker/dockerfile:1
FROM oven/bun:1.2.2-alpine
WORKDIR /app
COPY index.ts ./index.ts
ENV PORT=8788
EXPOSE 8788
CMD ["bun","run","index.ts"]
-289
View File
@@ -1,289 +0,0 @@
import { serve } from "bun";
import fs from "node:fs";
import path from "node:path";
// types
interface Chunk {
id: string;
text: string;
metadata?: Record<string, unknown>;
vector: number[];
}
interface Collection {
name: string;
chunks: Chunk[];
}
interface OllamaChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
interface OllamaChatRequest {
model?: string;
messages: OllamaChatMessage[];
stream?: boolean;
}
interface OllamaChatResponse {
message?: OllamaChatMessage;
[k: string]: unknown;
}
interface UpsertInputItem {
text: string;
metadata?: Record<string, unknown>;
}
interface OpenAPIObject {
openapi: string;
info: { title: string; version: string };
paths: Record<string, unknown>;
}
// env
const PORT: number = Number(process.env.PORT || 8788),
HOST: string = process.env.HOST || "0.0.0.0",
OLLAMA_BASE: string = process.env.OLLAMA_BASE || "http://localhost:11434",
OLLAMA_CHAT_MODEL: string = process.env.OLLAMA_CHAT_MODEL || "llama3.1",
OLLAMA_EMBED_MODEL: string = process.env.OLLAMA_EMBED_MODEL || "nomic-embed-text",
DATA_DIR: string = process.env.DATA_DIR || path.resolve("./data"),
SNAPSHOT: string = path.join(DATA_DIR, "rag.json");
// in-memory db
const db: Map<string, Collection> = new Map();
// util: smol json persistence
function ensureDirs(): void {
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
}
// you can probably guess
function loadSnapshot(): void {
try {
ensureDirs();
if (fs.existsSync(SNAPSHOT)) {
const raw = fs.readFileSync(SNAPSHOT, "utf8");
const obj = JSON.parse(raw || "{}") as Record<string, Collection>;
for (const [name, value] of Object.entries(obj)) db.set(name, value);
}
} catch (e) {
console.warn("failed to load snapshot:", e);
}
}
// you can probably guess 2
function saveSnapshot(): void {
try {
ensureDirs();
const obj = Object.fromEntries(db.entries());
fs.writeFileSync(SNAPSHOT, JSON.stringify(obj, null, 2));
} catch (e) {
console.warn("failed to save snapshot:", e);
}
}
loadSnapshot();
// basic text splitter (recursive by punctuation, then by length)
function chunkText(text: string, maxLen = 800): string[] {
const parts = text
.split(/\n{2,}/g)
.flatMap(p => p.split(/(?<=[.!?])\s+/g))
.flatMap(s => s.length > maxLen ? s.match(new RegExp(`.{1,${maxLen}}`, "g")) || [] : [s])
.map(s => s.trim())
.filter(Boolean);
return parts;
}
// cosine similarity
function dot(a: number[], b: number[]): number { let s = 0; for (let i = 0; i < a.length; i++) s += (a[i] || 0) * (b[i] || 0); return s; }
function norm(a: number[]): number { return Math.sqrt(dot(a, a)); }
function cosineSim(a: number[], b: number[]): number { const d = dot(a, b), n = norm(a) * norm(b) || 1; return d / n; }
// call ollama embeddings
async function embedAll(texts: string[]): Promise<number[][]> {
const primary = await fetch(`${OLLAMA_BASE}/api/embed`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: OLLAMA_EMBED_MODEL, input: texts })
});
if (primary.ok) {
const j: { embeddings: number[][] } = await primary.json();
return j.embeddings;
}
const results: number[][] = [];
for (const t of texts) {
const r = await fetch(`${OLLAMA_BASE}/api/embeddings`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: OLLAMA_EMBED_MODEL, prompt: t })
});
if (!r.ok) throw new Error(`embed failed: ${r.status}`);
const j: { embedding: number[] } = await r.json();
results.push(j.embedding);
}
return results;
}
// call ollama chat/generate with retrieved context
async function ollamaChat(req: OllamaChatRequest): Promise<OllamaChatResponse> {
const res = await fetch(`${OLLAMA_BASE}/api/chat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: req.model || OLLAMA_CHAT_MODEL, messages: req.messages, stream: req.stream })
});
if (!res.ok) throw new Error(`ollama chat failed: ${res.status}`);
const j: OllamaChatResponse = await res.json();
return j;
}
// openapi for open webui tool integration
const OPENAPI: OpenAPIObject = {
openapi: "3.1.0",
info: { title: "RAG Server (Ollama)", version: "1.0.0" },
paths: {
"/collections": {
get: { operationId: "listCollections" },
post: { operationId: "createCollection" }
},
"/upsert": { post: { operationId: "upsert" } },
"/query": { post: { operationId: "query" } },
"/chat": { post: { operationId: "chat" } }
}
};
// tiny router
async function json<T = any>(req: Request): Promise<T> { try { return await req.json() as T; } catch { return {} as T; } }
function sendJson(_res: unknown, status: number, obj: unknown): Response {
return new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json; charset=utf-8" } });
}
async function handleCollections(req: Request): Promise<Response> {
if (req.method === "GET") {
return sendJson(null, 200, { collections: Array.from(db.keys()) });
}
if (req.method === "POST") {
const body = await json<{ name?: string }>(req),
name = String(body?.name || "").trim();
if (!name) return sendJson(null, 400, { error: "name required" });
if (!db.has(name)) db.set(name, { name, chunks: [] });
saveSnapshot();
return sendJson(null, 200, { ok: true });
}
return new Response("not found", { status: 404 });
}
async function handleUpsert(req: Request): Promise<Response> {
const body = await json<{ collection?: string; items?: UpsertInputItem[] }>(req),
collection = String(body?.collection || "").trim(),
items: UpsertInputItem[] = Array.isArray(body?.items) ? body.items : [];
if (!collection) return sendJson(null, 400, { error: "collection required" });
if (!db.has(collection)) db.set(collection, { name: collection, chunks: [] });
const col = db.get(collection)!,
chunksToIndex: { text: string; metadata?: Record<string, unknown>; _id: string }[] = [];
for (const it of items) {
const parts = chunkText(String(it.text || ""));
for (const p of parts) chunksToIndex.push({ text: p, metadata: it.metadata || {}, _id: crypto.randomUUID() });
}
const vecs = await embedAll(chunksToIndex.map(x => x.text));
for (let i = 0; i < chunksToIndex.length; i++) {
const item = chunksToIndex[i],
doc: Chunk = { id: item._id, text: item.text, metadata: item.metadata, vector: vecs[i] };
col.chunks.push(doc);
}
saveSnapshot();
return sendJson(null, 200, { ok: true, indexed: chunksToIndex.length });
}
async function handleQuery(req: Request): Promise<Response> {
const body = await json<{ collection?: string; query?: string; topK?: number }>(req),
collection = String(body?.collection || "").trim(),
query = String(body?.query || "").trim(),
topK = Number(body?.topK || 5);
if (!collection || !query) return sendJson(null, 400, { error: "collection and query required" });
const col = db.get(collection);
if (!col) return sendJson(null, 404, { error: "collection not found" });
const [qvec] = await embedAll([query]),
scored = col.chunks.map((c) => ({ c, score: cosineSim(qvec, c.vector) }))
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.map(x => ({ id: x.c.id, text: x.c.text, metadata: x.c.metadata, score: x.score }));
return sendJson(null, 200, { matches: scored });
}
async function handleChat(req: Request): Promise<Response> {
const body = await json<{ collection?: string; query?: string; topK?: number; model?: string }>(req),
collection = String(body?.collection || "").trim(),
query = String(body?.query || "").trim(),
topK = Number(body?.topK || 5),
model = body?.model || OLLAMA_CHAT_MODEL;
if (!collection || !query) return sendJson(null, 400, { error: "collection and query required" });
const col = db.get(collection);
if (!col) return sendJson(null, 404, { error: "collection not found" });
const [qvec] = await embedAll([query]),
matches = col.chunks.map((c) => ({ c, score: cosineSim(qvec, c.vector) }))
.sort((a, b) => b.score - a.score)
.slice(0, topK);
const context = matches.map((m, i) => `[[doc ${i + 1} score=${m.score.toFixed(3)}]]\n${m.c.text}`).join("\n\n"),
system: string = `you are a helpful assistant. use ONLY the provided context to answer. if the answer isn't in the context, say you don't know. cite as [doc N].`,
user: string = `question: ${query}\n\ncontext:\n${context}`;
const out = await ollamaChat({ model, messages: [{ role: "system", content: system }, { role: "user", content: user }], stream: false });
return sendJson(null, 200, {
answer: out?.message?.content || "",
citations: matches.map((m, i) => ({ id: m.c.id, score: m.score, text: m.c.text }))
});
}
const pickFunc = (pathname: string) => {
switch (pathname) {
case "/collections":
return handleCollections;
case "/upsert":
return handleUpsert;
case "/query":
return handleQuery;
case "/chat":
return handleChat;
default:
return undefined;
}
}
const server = serve({
port: PORT,
hostname: HOST,
fetch: async (req: Request): Promise<Response> => {
const u = new URL(req.url);
if (req.method === "GET" && u.pathname === "/") return new Response("ok");
if (req.method === "GET" && u.pathname === "/openapi.json") return sendJson(null, 200, OPENAPI);
return pickFunc(u.pathname)?.call(req) || new Response("not found", { status: 404 });
}
});
console.log(`[rag] listening on http://${HOST}:${PORT}`);
+7
View File
@@ -0,0 +1,7 @@
node_modules
npm-cache
bun.lock
bun.lockb
.DS_Store
*.log
templates.json
+19
View File
@@ -0,0 +1,19 @@
FROM oven/bun:1 AS base
WORKDIR /app
# prod deps
COPY package.json ./package.json
RUN bun install --ci --production
COPY . .
RUN mkdir -p /app/data && chown -R bun:bun /app/data
USER bun
EXPOSE 12253
ENV NODE_ENV=production
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));
}
+357
View File
@@ -0,0 +1,357 @@
{
"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"
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "ollama-scheduler",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "bun run server.mjs",
"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 };
}
+298
View File
@@ -0,0 +1,298 @@
<!doctype html>
<html lang="en">
<head>
<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>
<!-- app shell header -->
<header class="app-header">
<div class="brand">
<div class="logo" aria-hidden="true">⏱️</div>
<div class="titles">
<h1>Schedules</h1>
<p class="subtitle">manage your tasks and followups</p>
</div>
</div>
<!-- theme toggle kept with same ids so your code still works -->
<button id="themeToggle" class="theme-toggle card compact" aria-pressed="false" title="toggle dark / light">
<span id="themeToggleIcon" aria-hidden="true">🌙</span>
<span id="themeToggleLabel">Dark</span>
</button>
</header>
<!-- content grid -->
<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">
<h2>login</h2>
<p class="muted">enter your open webui user id (uuid). this is sent as the <code
class="inline">x-user-id</code> header on api requests.</p>
</header>
<!-- Login panel -->
<section class="auth-card">
<h2>Login</h2>
<p id="authStatus" role="status">not logged in</p>
<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>
</form>
</section>
</section>
</aside>
<!-- right column: schedules + create/update -->
<section class="stack col-right">
<!-- schedules list -->
<section class="card">
<header class="card-header actions between">
<h2>your schedules</h2>
<div class="actions">
<button id="refreshBtn">refresh</button>
</div>
</header>
<p id="listStatus" class="muted" aria-live="polite"></p>
<div class="table-wrap" role="region" aria-label="schedules table" tabindex="0">
<table>
<thead>
<tr>
<th scope="col">name</th>
<th scope="col">schedules</th>
<th scope="col">start</th>
<th scope="col">template</th>
<th scope="col">prompt</th>
<th scope="col">one-shot</th>
<th scope="col">actions</th>
</tr>
</thead>
<tbody id="schedulesTbody"></tbody>
</table>
</div>
</section>
<!-- create/update schedule -->
<section class="card">
<header class="card-header">
<h2>create / update schedule</h2>
</header>
<form id="createForm" class="form-stack">
<div class="row">
<div>
<label for="name">name</label>
<input id="name" name="name" type="text" required placeholder="daily-report" />
</div>
<div class="row">
<div>
<label for="templateName">workflow template</label>
<input id="templateName" name="templateName" type="text" required
placeholder="report-template" />
</div>
</div>
</div>
<div class="row">
<div>
<label for="startAt">start at (optional)</label>
<input id="startAt" name="startAt" type="datetime-local" />
</div>
<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>
<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">
<button type="submit">upsert schedule</button>
<button type="button" id="loadTemplatesBtn" aria-variant="ghost">load workflow
templates</button>
</div>
<span id="createStatus" class="muted" aria-live="polite"></span>
</div>
</div>
</form>
</section>
</section>
</main>
<!-- scripts -->
<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);
}
+625
View File
@@ -0,0 +1,625 @@
const storedMe = (() => {
try {
const raw = localStorage.getItem("me") ?? localStorage.getItem("user");
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
})();
const state = {
me: storedMe,
};
/**
* @returns {HTMLElement}
*/
const $ = (sel) => document.querySelector(sel),
setText = (sel, v) => {
const el = $(sel);
if (el) el.textContent = v;
};
const authStatusEl = $("#authStatus"),
listStatusEl = $("#listStatus"),
createStatusEl = $("#createStatus"),
runNowStatusEl = $("#runNowStatus"),
schedulesTbody = $("#schedulesTbody"),
templatesUl = $("#templatesUl"),
oneShotCheckmark = $("#oneShot"),
startAtInput = $("#startAt"),
cronInput = $("#cron"),
cronError = $("#cronError"),
filesInput = $("#scheduleFiles"),
filesList = $("#scheduleFilesList");
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 {
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 applyTheme = (mode) => {
const b = document.body;
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");
if (icon && label) {
const isDark = mode === "dark";
icon.textContent = isDark ? "🌙" : "☀️";
label.textContent = isDark ? "Dark" : "Light";
}
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";
applyTheme(next);
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");
});
}
// 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.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
let msg = `${resp.status} ${resp.statusText}`;
try {
const data = await resp.json();
if (data && data.error) msg = data.error;
} catch { }
throw new Error(msg);
}
return resp;
}
// render list
function renderSchedules(items = []) {
schedulesTbody.innerHTML = "";
items.forEach((it) => {
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.startAt || "")}</td>
<td>${escapeHtml(tRef)}</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;");
}
// wire up events
$("#loginForm").addEventListener("submit", async (e) => {
e.preventDefault();
const username = $("#username").value.trim(),
password = $("#password").value;
if (!username || !password) {
authStatusEl.textContent = "please enter username and password";
return;
}
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}`;
}
});
// 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"),
data = await res.json();
renderSchedules(data.items || []);
listStatusEl.textContent = `loaded ${Array.isArray(data.items) ? data.items.length : 0} schedule(s)`;
} catch (e) {
listStatusEl.textContent = `error: ${e.message}`;
}
});
// delete handler (delegated)
schedulesTbody.addEventListener("click", async (e) => {
const target = e.target;
if (!(target instanceof HTMLButtonElement)) return;
const name = target.getAttribute("data-del");
if (!name) return;
try {
target.disabled = true;
const res = await apiFetch(`/api/schedules/${name}`, {
method: "DELETE",
});
if (res.status === 204) {
target.closest("tr")?.remove();
listStatusEl.textContent = "deleted";
} else {
listStatusEl.textContent = "unexpected response";
}
} catch (err) {
listStatusEl.textContent = `error: ${err.message}`;
} finally {
target.disabled = false;
}
});
// create/update schedule
$("#createForm").addEventListener("submit", async (e) => {
e.preventDefault();
try {
createStatusEl.textContent = "saving...";
const name = $("#name").value.trim(),
startAt = startAtInput?.value || "",
cron = $("#cron").value.trim(),
templateName = $("#templateName").value.trim(),
prompt = $("#prompt").value.trim(),
clusterScope = $("#clusterScope").checked,
oneShot = $("#oneShot").checked,
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 {
parameters = JSON.parse(paramsRaw);
} catch {
throw new Error("parameters must be valid json");
}
}
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,
oneShot,
template: { name: templateName, clusterScope },
parameters,
prompt,
model,
tools,
features
};
if (filesPayload.length) payload.files = filesPayload;
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}`;
}
});
// load workflow templates for convenience
$("#loadTemplatesBtn").addEventListener("click", async () => {
try {
templatesUl.innerHTML = "";
templatesUl.parentElement.open = true;
const res = await apiFetch("/api/workflowtemplates"),
data = await res.json();
(data.items || []).forEach((t) => {
const li = document.createElement("li");
li.textContent = t.name;
templatesUl.appendChild(li);
});
} catch (e) {
templatesUl.innerHTML = `<li class="danger">error: ${escapeHtml(e.message)}</li>`;
}
});
// window.onbeforeunload = () => CookieStore.delete('token');
const reffunc = () => {
// auto-refresh if already logged in
if (state.me && state.me.id) $("#refreshBtn").click();
}
// boot
paintAuth().then(reffunc);
+624
View File
@@ -0,0 +1,624 @@
:root {
/* base tokens */
--bg: #0b1224;
--bg-2: #0e1730;
--surface: #0f1c3a;
--card: #0f1b33;
--text: #e7eef9;
--muted: #9fb2ce;
--border: rgba(255, 255, 255, 0.1);
--accent: #7aa2ff;
--accent-600: #4f7dff;
--danger: #ef4444;
--ok: #16a34a;
--radius-lg: 16px;
--radius-md: 12px;
--radius-sm: 8px;
--gap: 1rem;
--maxw: 1120px;
color-scheme: dark light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
font-size: 16px;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f6f8fc;
--bg-2: #eef2fb;
--surface: #ffffff;
--card: #ffffff;
--text: #0f172a;
--muted: #6b7280;
--border: #e5e9f2;
--accent: #2563eb;
--accent-600: #1e40af;
}
}
body.theme-dark {
color-scheme: dark;
--bg: #0b1224;
--bg-2: #0e1730;
--surface: #0f1c3a;
--card: #0f1b33;
--text: #e7eef9;
--muted: #9fb2ce;
--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 {
color-scheme: light;
--bg: #f6f8fc;
--bg-2: #eef2fb;
--surface: #ffffff;
--card: #ffffff;
--text: #0f172a;
--muted: #6b7280;
--border: #e5e9f2;
--accent: #2563eb;
--accent-600: #1e40af;
/* warn box */
--box-bg: #ffffff;
--box-text: #000000;
--box-border: #d9534f;
--icon-fill: #d9534f;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background:
radial-gradient(60rem 60rem at -10% -20%, rgba(79, 125, 255, 0.15), transparent 60%),
radial-gradient(60rem 60rem at 110% 120%, rgba(122, 162, 255, 0.17), transparent 60%),
linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%);
color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* header */
.app-header {
position: sticky;
top: 0;
z-index: 20;
backdrop-filter: saturate(140%) blur(8px);
background: color-mix(in srgb, var(--bg-2) 75%, transparent);
border-bottom: 1px solid var(--border);
padding: 1rem clamp(1rem, 3vw, 1.25rem);
display: flex;
align-items: center;
justify-content: space-between;
}
.brand {
display: flex;
gap: .9rem;
align-items: center;
}
.logo {
width: 40px;
height: 40px;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-radius: 12px;
}
.titles h1 {
margin: 0;
font-size: 1.35rem;
letter-spacing: -0.01em;
}
.subtitle {
margin: .15rem 0 0 0;
color: var(--muted);
font-size: .95rem;
}
/* content */
.content-grid {
max-width: var(--maxw);
/* margin: 1.2rem auto 2rem; */
padding: 0 clamp(1rem, 3vw, 1.25rem);
display: grid;
grid-template-columns: 360px 1fr;
gap: 1.2rem;
}
@media (max-width: 980px) {
.content-grid {
grid-template-columns: 1fr;
}
}
.stack {
display: grid;
gap: 1rem;
}
.col-left {}
.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 {
padding: .55rem .8rem;
border-radius: 999px;
}
.card-header {
padding: 1rem 1rem .75rem;
border-bottom: 1px dashed var(--border);
}
.card>.form-stack,
.card>.table-wrap,
.card>p {
padding: 1rem;
}
/* forms */
.form-stack {
display: grid;
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;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
}
@media (max-width: 720px) {
.row {
grid-template-columns: 1fr;
}
}
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;
margin-bottom: .3rem;
color: var(--text);
}
input[type="text"],
input[type="datetime-local"],
select,
textarea {
width: 100%;
padding: 0.65rem 0.8rem;
border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
background: color-mix(in srgb, var(--surface) 92%, transparent);
color: var(--text);
outline: none;
font-size: .98rem;
transition: box-shadow .18s ease, border-color .12s ease, transform .06s ease, background .18s ease;
}
input::placeholder,
textarea::placeholder {
color: color-mix(in srgb, var(--muted) 80%, transparent);
}
input:focus,
select:focus,
textarea:focus,
.theme-toggle:focus,
button:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 65%, transparent);
border-color: var(--accent);
}
textarea {
min-height: 120px;
font-family: ui-monospace, Menlo, "Roboto Mono", SFMono-Regular, monospace;
}
.checkbox label {
font-weight: 500;
display: inline-flex;
gap: .5rem;
align-items: center;
}
/* buttons */
button {
padding: 0.6rem 0.9rem;
border-radius: 10px;
border: 1px solid rgba(2, 6, 23, 0.1);
cursor: pointer;
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-600) 100%);
color: #fff;
font-weight: 650;
box-shadow: 0 10px 26px rgba(122, 162, 255, .18);
transition: transform .08s ease, box-shadow .12s ease, opacity .12s ease, background .18s ease;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 14px 34px rgba(122, 162, 255, .22);
}
button:active {
transform: translateY(0) scale(.997);
}
button[disabled] {
opacity: .6;
cursor: not-allowed;
box-shadow: none;
}
button[aria-variant="ghost"] {
background: transparent;
color: var(--accent);
border: 1px dashed color-mix(in srgb, var(--accent) 30%, transparent);
box-shadow: none;
}
/* theme toggle refinements */
.theme-toggle {
border-color: color-mix(in srgb, var(--border) 60%, transparent);
background: color-mix(in srgb, var(--surface) 85%, transparent);
color: var(--text);
}
/* tables */
.table-wrap {
overflow: auto;
border-radius: calc(var(--radius-lg) - 2px);
border: 1px dashed var(--border);
background: color-mix(in srgb, var(--surface) 86%, transparent);
}
table {
width: 100%;
border-collapse: collapse;
min-width: 760px;
font-size: .96rem;
}
thead th {
position: sticky;
top: 0;
z-index: 1;
background: linear-gradient(180deg, rgba(255, 255, 255, .07), rgba(255, 255, 255, .03));
backface-visibility: hidden;
text-align: left;
font-weight: 700;
font-size: .9rem;
color: var(--muted);
}
th,
td {
padding: .75rem .9rem;
border-bottom: 1px dashed var(--border);
vertical-align: middle;
}
tbody tr:hover {
background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 9%, transparent), transparent);
}
td:first-child {
font-weight: 700;
}
/* helpers */
.actions {
display: flex;
gap: .5rem;
align-items: center;
padding: 10px 0px;
width: 100%;
justify-content: space-evenly;
}
.actions.wrap {
flex-wrap: wrap;
}
.actions.between {
justify-content: space-between;
}
.muted {
color: var(--muted);
opacity: .95;
}
.status {
display: inline-block;
padding: .25rem .6rem;
border-radius: 999px;
font-size: .84rem;
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--accent-600);
border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent);
}
/* inline code */
code.inline {
padding: .14rem .3rem;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--surface) 92%, transparent);
font-family: ui-monospace, Menlo, monospace;
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) {
input:focus,
select:focus,
textarea:focus,
button:focus,
.theme-toggle:focus {
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;
}
+408
View File
@@ -0,0 +1,408 @@
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'
// KEEP AS STRING
const openApiSpec = fs.readFileSync('./openapi.json', 'utf-8'),
__filename = fileURLToPath(import.meta.url),
__dirname = path.dirname(__filename),
DEFAULT_TZ = "America/New_York",
// 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
const 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(),
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(openApiSpec);
}
// 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}`)
});
+25
View File
@@ -0,0 +1,25 @@
{
"items": [
{
"name": "echo",
"image": "bash:5.2",
"command": [
"-lc"
],
"args": [
"echo \"hello $DISPLAY_NAME from $USER_ID with $PARAM_MSG\""
]
},
{
"name": "alpine-task",
"image": "alpine:3.20",
"command": [
"sh",
"-lc"
],
"args": [
"echo running && sleep 3 && echo done"
]
}
]
}