Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2659c51b02 | |||
| b915ae88a4 | |||
| 665fcbe191 | |||
| e954bf82fb | |||
| 133ef3f48b | |||
| 5f535a61c1 | |||
| a7f6c9edb5 |
@@ -132,3 +132,7 @@ dist
|
||||
|
||||
__pycache__/
|
||||
.venv/
|
||||
|
||||
bun.lock
|
||||
tmp/
|
||||
temp.*
|
||||
|
||||
@@ -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 OWUI’s
|
||||
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 host’s 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 **don’t** 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, that’s 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 don’t 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
|
||||
* you’ll 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 WebUI’s built-in RAG conflict with this server?**
|
||||
no — you can use either, or both. Open WebUI’s knowledge base is great for ad-hoc use. this service is for programmatic/control-plane needs or when you outgrow the UI’s 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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
-29
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
npm-cache
|
||||
bun.lock
|
||||
bun.lockb
|
||||
*.log
|
||||
@@ -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"]
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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']
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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"]
|
||||
@@ -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}`);
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
npm-cache
|
||||
bun.lock
|
||||
bun.lockb
|
||||
.DS_Store
|
||||
*.log
|
||||
templates.json
|
||||
@@ -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"]
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 follow‑ups</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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`)
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user